clone

push a directory of html from the terminal and get a four-character url back. a deno cli on cloudflare

fix: harden vault updates per review

Dami committed 0e74b83
- refuse renaming a vault to a name outside allowVaults (guardrail bypass)
- require --no-description to clear a description; bare/empty
  --description is now a usage error instead of silently clearing
- rewrite local push breadcrumbs on rename so nzip where keeps working
- accept description: null in PATCH as the JSON clear idiom
- reject control characters in descriptions (rendered raw into tables)
- return 400 instead of 500 on malformed percent-encoding in vault paths
- move DESCRIPTION to the last table column, keeping SITES positional
- share one vaultRowToInfo mapping; keyed re-read in updateVault instead
  of scanning listVaults
commit
0e74b835315f4c6d26932052411d0595354edec7
parent
47ada2bd81e75ba14b7e81912b5d7780e58babc9
author
Dami <dami.etoile@gmail.com>
10 files changed, +124 -47
MREADME.md4+-
Magent/skills/nzip/SKILL.md8+++---
Mcli/README.md4+-
Mcli/commands/sites.ts4+-
Mcli/commands/vault.ts47++++++++++++++++++++++++++----
Mcli/lib/paths.ts12++++++++
Mcli/main.ts11+++++--
Mworker/src/api/vaults.ts24+++++++++------
Mworker/src/db.ts35+++++++++++-----------
Mworker/test/index.runtime.test.ts22++++++++++++++
M README.md +2 -2
M agent/skills/nzip/SKILL.md +4 -4
@@ -87,7 +87,7 @@ Prefer `--json` when consuming command output programmatically.
8787 - Inspect readiness: `nzip status --json`8888 - List destinations: `nzip vault ls --json`8989 - Register a vault: `nzip vault add <name> [--slot N] [--description TEXT]`90-- Rename or describe a vault: `nzip vault update <name> [--name NEW_NAME] [--description TEXT]`90+- Rename or describe a vault: `nzip vault update <name> [--name NEW_NAME] [--description TEXT | --no-description]`9191 - Set the default vault: `nzip vault default <name>`9292 - List sites: `nzip ls [vault] --json`9393 - Inspect a site: `nzip site <target> --json`
@@ -102,9 +102,9 @@ Prefer `--json` when consuming command output programmatically.
102102 103103 Use password protection when the content or user requires it; `nzip push` accepts the same104104 `--password <pw> | --no-password` flags to set it at publish time. Vault descriptions appear in105-`vault ls` and `status`; use them to pick the right destination, and pass an empty `--description`106-to clear one. A vault rename updates the invoking client's default vault and allow-list, but other107-clients keep the old name. Treat `revert`, vault renames, password changes, TTL changes, and105+`vault ls` and `status`; use them to pick the right destination, and pass `--no-description` to106+clear one. A vault rename updates the invoking client's default vault, allow-list, and local push107+records, but other clients keep the old name; renaming to a name outside `allowVaults` is refused. Treat `revert`, vault renames, password changes, TTL changes, and108108 deletion as state changes; do not perform them while merely diagnosing. Never infer deletion109109 confirmation.110110
M cli/README.md +2 -2
M cli/commands/sites.ts +2 -2
M cli/commands/vault.ts +40 -7
@@ -2,16 +2,41 @@ import { ApiClient } from "../lib/api.ts";
22 import type { Config } from "../lib/config.ts";33 import { assertVaultAllowed, saveConfig } from "../lib/config.ts";44 import { bold, dim, emit, fail, green, table } from "../lib/fmt.ts";5+import { renameVault } from "../lib/paths.ts";6+7+export interface VaultFlags {8+ slot?: number;9+ newName?: string;10+ description?: string;11+ clearDescription?: boolean;12+}13+14+/**15+ * A bare `--description` (value swallowed by the shell) parses to "", which16+ * would otherwise silently clear the stored text — require `--no-description`17+ * for that instead.18+ */19+function descriptionFlag(flags: VaultFlags): string | undefined {20+ if (flags.clearDescription) {21+ if (flags.description !== undefined) {22+ fail("pass either --description TEXT or --no-description, not both");23+ }24+ return "";25+ }26+ if (flags.description !== undefined && flags.description.trim() === "") {27+ fail("--description requires text; use --no-description to clear it");28+ }29+ return flags.description;30+}531 632 export async function cmdVault(733 config: Config,834 sub: string | undefined,935 name: string | undefined,10- slot: number | undefined,11- newName: string | undefined,12- description: string | undefined,36+ flags: VaultFlags,1337 ): Promise<void> {1438 const api = new ApiClient(config);39+ const { slot, newName } = flags;1540 1641 if (sub === "ls" || sub === undefined) {1742 const vaults = await api.listVaults();
@@ -21,12 +46,12 @@ export async function cmdVault(
2146 return;2247 }2348 console.log(table(24- ["SLOT", "VAULT", "DESCRIPTION", "SITES"],49+ ["SLOT", "VAULT", "SITES", "DESCRIPTION"],2550 vaults.map((v) => [2651 `0x${v.slot.toString(16)}`,2752 v.name === config.defaultVault ? bold(`${v.name} *`) : v.name,28- v.description ?? "",2953 String(v.siteCount),54+ v.description ?? "",3055 ]),3156 ));3257 }, { ok: true, defaultVault: config.defaultVault ?? null, vaults });
@@ -35,6 +60,7 @@ export async function cmdVault(
3560 3661 if (sub === "add") {3762 if (!name) fail("usage: nzip vault add <name> [--slot N] [--description TEXT]");63+ const description = descriptionFlag(flags) || undefined;3864 try {3965 assertVaultAllowed(name, config);4066 } catch (e) {
@@ -57,11 +83,17 @@ export async function cmdVault(
5783 }5884 5985 if (sub === "update") {60- if (!name || (newName === undefined && description === undefined)) {61- fail("usage: nzip vault update <name> [--name NEW_NAME] [--description TEXT]");86+ if (87+ !name || (newName === undefined && flags.description === undefined && !flags.clearDescription)88+ ) {89+ fail(90+ "usage: nzip vault update <name> [--name NEW_NAME] [--description TEXT | --no-description]",91+ );6292 }93+ const description = descriptionFlag(flags);6394 try {6495 assertVaultAllowed(name, config);96+ if (newName !== undefined) assertVaultAllowed(newName, config);6597 } catch (e) {6698 return fail((e as Error).message);6799 }
@@ -74,6 +106,7 @@ export async function cmdVault(
74106 ? { allowVaults: config.allowVaults.map((vault) => vault === name ? newName : vault) }75107 : {}),76108 });109+ await renameVault(name, newName);77110 }78111 emit(79112 () =>
M cli/lib/paths.ts +12 -0
M cli/main.ts +8 -3
M worker/src/api/vaults.ts +14 -10
@@ -1,18 +1,23 @@
11 import { isValidName, VAULT_SLOTS } from "../../../shared/mod.ts";2-import { createVault, listVaults, updateVault } from "../db.ts";2+import { createVault, listVaults, updateVault, vaultRowToInfo } from "../db.ts";33 import { type Env, json } from "../env.ts";44 import { ApiError, readJson } from "./errors.ts";55 66 const DESCRIPTION_MAX_LENGTH = 500;77 88 function descriptionValue(value: unknown): string | null {9+ if (value === null) return null;910 if (typeof value !== "string") {10- throw new ApiError(400, "description must be a string");11+ throw new ApiError(400, "description must be a string or null");1112 }1213 const description = value.trim();1314 if (description.length > DESCRIPTION_MAX_LENGTH) {1415 throw new ApiError(400, `description must be at most ${DESCRIPTION_MAX_LENGTH} characters`);1516 }17+ // Descriptions are rendered raw into CLI tables; keep them single-line.18+ if (/[\x00-\x1f\x7f-\x9f]/.test(description)) {19+ throw new ApiError(400, "description must not contain control characters");20+ }1621 return description || null;1722 }1823
@@ -29,13 +34,7 @@ export async function handleVaults(
2934 const description = body.description === undefined ? null : descriptionValue(body.description);3035 try {3136 const vault = await createVault(env, body.name, body.slot, description);32- return json({33- slot: vault.slot,34- name: vault.name,35- description: vault.description,36- createdAt: vault.created_at,37- siteCount: 0,38- }, 201);37+ return json(vaultRowToInfo(vault, 0), 201);3938 } catch (cause) {4039 const message = cause instanceof Error ? cause.message : "vault conflict";4140 throw new ApiError(409, message, { cause });
@@ -48,7 +47,12 @@ export async function handleVault(
4847 encodedName: string,4948 ): Promise<Response> {5049 if (request.method !== "PATCH") throw new ApiError(405, "method not allowed");51- const currentName = decodeURIComponent(encodedName);50+ let currentName: string;51+ try {52+ currentName = decodeURIComponent(encodedName);53+ } catch {54+ throw new ApiError(400, "invalid vault name");55+ }5256 const body = await readJson<{ name?: unknown; description?: unknown }>(request);5357 if (body.name === undefined && body.description === undefined) {5458 throw new ApiError(400, "provide name and/or description");
M worker/src/db.ts +18 -17
@@ -195,27 +195,23 @@ export async function siteHistory(
195195 }));196196 }197197 198+export function vaultRowToInfo(row: VaultRow, siteCount: number): VaultInfo {199+ return {200+ slot: row.slot,201+ name: row.name,202+ description: row.description,203+ createdAt: row.created_at,204+ siteCount,205+ };206+}207+198208 export async function listVaults(env: Env): Promise<VaultInfo[]> {199209 const res = await env.DB.prepare(200210 `SELECT v.slot, v.name, v.description, v.created_at, COUNT(s.address) AS site_count201211 FROM vaults v LEFT JOIN sites s ON s.vault_slot = v.slot202212 GROUP BY v.slot ORDER BY v.slot`,203- ).all<204- {205- slot: number;206- name: string;207- description: string | null;208- created_at: number;209- site_count: number;210- }211- >();212- return res.results.map((r) => ({213- slot: r.slot,214- name: r.name,215- description: r.description,216- createdAt: r.created_at,217- siteCount: r.site_count,218- }));213+ ).all<VaultRow & { site_count: number }>();214+ return res.results.map((r) => vaultRowToInfo(r, r.site_count));219215 }220216 221217 export async function createVault(
@@ -269,7 +265,12 @@ export async function updateVault(
269265 if (result.meta.changes === 0) return null;270266 271267 const updatedName = patch.name ?? currentName;272- return (await listVaults(env)).find((vault) => vault.name === updatedName) ?? null;268+ const row = await getVaultByName(env, updatedName);269+ if (!row) return null;270+ const count = await env.DB.prepare(271+ "SELECT COUNT(*) AS n FROM sites WHERE vault_slot = ?",272+ ).bind(row.slot).first<{ n: number }>();273+ return vaultRowToInfo(row, count?.n ?? 0);273274 }274275 275276 export function siteRowToInfo(
M worker/test/index.runtime.test.ts +22 -0