- 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>
| M | README.md | 4 | +- |
| M | agent/ | 8 | +++--- |
| M | cli/ | 4 | +- |
| M | cli/ | 4 | +- |
| M | cli/ | 47 | ++++++++++++++++++++++++++---- |
| M | cli/ | 12 | ++++++++ |
| M | cli/ | 11 | +++++-- |
| M | worker/ | 24 | +++++++++------ |
| M | worker/ | 35 | +++++++++++----------- |
| M | worker/ | 22 | ++++++++++++++ |
@@ -76,7 +76,7 @@ Commands accept any of three target forms:
7676 nzip auth [--server URL] [--token T] authenticate and save config7777 nzip vault add <name> [--slot N] [--description TEXT]7878 register a vault (16 slots, 0x0–0xf)79-nzip vault update <name> [--name NEW_NAME] [--description TEXT]79+nzip vault update <name> [--name NEW_NAME] [--description TEXT | --no-description]8080 rename or describe a vault8181 nzip vault ls | default <name> list vaults / set the default8282 nzip push <dir|file> [target] [--ttl …] [--password PW | --no-password]@@ -90,7 +90,7 @@ nzip revert <target> [--to N] [--list] repoint to a previous push
9090 ```9191 9292 Vault descriptions are returned by `vault ls --json` so agents can choose an93-appropriate destination from its purpose or audience. Pass an empty description93+appropriate destination from its purpose or audience. Pass `--no-description`9494 to `vault update` to clear it.9595 9696 Password and TTL are committed with the content. On a new site, omitting@@ -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 @@ -40,7 +40,7 @@ and site is already there.
4040 nzip auth [--server URL] [--token T] authenticate and save config4141 nzip vault add <name> [--slot N] [--description TEXT]4242 register a vault (16 slots, 0x0–0xf)43-nzip vault update <name> [--name NEW_NAME] [--description TEXT]43+nzip vault update <name> [--name NEW_NAME] [--description TEXT | --no-description]4444 rename or describe a vault4545 nzip vault ls | default <name> list vaults / set the default4646 nzip push <dir|file> [target] [--ttl …] [--password PW | --no-password]@@ -54,7 +54,7 @@ nzip revert <target> [--to N] [--list] repoint to a previous push
5454 ```5555 5656 Vault descriptions are included in `vault ls --json`. Use57-`nzip vault update <name> --description ""` to clear one.57+`nzip vault update <name> --no-description` to clear one.5858 5959 Password and TTL are committed with the content. On a new site, omitting `--password` creates an6060 unprotected site; on an existing target, omission preserves its current password. Pass@@ -141,14 +141,14 @@ export async function cmdStatus(config: Config): Promise<void> {
141141 );142142 if (status.vaults.length > 0) {143143 console.log(table(144- ["SLOT", "VAULT", "DESCRIPTION", "SITES"],144+ ["SLOT", "VAULT", "SITES", "DESCRIPTION"],145145 status.vaults.map((146146 v,147147 ) => [148148 `0x${v.slot.toString(16)}`,149149 v.name,150- v.description ?? "",151150 String(v.siteCount),151+ v.description ?? "",152152 ]),153153 ));154154 }@@ -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 () =>@@ -107,6 +107,18 @@ export async function lookup(
107107 return null;108108 }109109 110+/** Rewrite breadcrumbs after `nzip vault update --name` so `nzip where` keeps working. */111+export async function renameVault(112+ oldName: string,113+ newName: string,114+): Promise<void> {115+ const reg = pruneExpired(await load());116+ for (const e of Object.values(reg)) {117+ if (e.vault === oldName) e.vault = newName;118+ }119+ await save(reg);120+}121+110122 /** Forget a single site (called from `nzip rm`). */111123 export async function forget(112124 q: { address?: string; vault?: string; alias?: string },@@ -33,7 +33,7 @@ usage:
3333 nzip auth [--server URL] [--token T] authenticate against the server3434 nzip vault add <name> [--slot N] [--description TEXT]3535 register a vault (16 slots, 0x0-0xf)36- nzip vault update <name> [--name NEW_NAME] [--description TEXT]36+ nzip vault update <name> [--name NEW_NAME] [--description TEXT | --no-description]3737 rename or describe a vault3838 nzip vault ls list vaults3939 nzip vault default <name> set default vault@@ -60,7 +60,7 @@ to named vaults — pushes/aliases outside the list are refused (agent guardrail
6060 async function main(): Promise<void> {6161 const args = parseArgs(Deno.args, {6262 string: ["ttl", "to", "slot", "server", "token", "password", "name", "description"],63- boolean: ["yes", "list", "help", "no-password", "overwrite", "json"],63+ boolean: ["yes", "list", "help", "no-password", "overwrite", "json", "no-description"],6464 alias: { h: "help", y: "yes" },6565 });6666 setJsonMode(args.json);@@ -110,7 +110,12 @@ async function main(): Promise<void> {
110110 case "revert":111111 return await cmdRevert(config, rest[0], toSeq, args.list);112112 case "vault":113- return await cmdVault(config, rest[0], rest[1], slot, args.name, args.description);113+ return await cmdVault(config, rest[0], rest[1], {114+ slot,115+ newName: args.name,116+ description: args.description,117+ clearDescription: args["no-description"],118+ });114119 default:115120 fail(`unknown command: ${command}\n\n${HELP}`);116121 }@@ -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");@@ -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(@@ -86,6 +86,28 @@ describe("Worker runtime", () => {
8686 });8787 expect(cleared.status).toBe(200);8888 expect(await cleared.json()).toMatchObject({ name: "reviews", description: null });89+90+ const clearedWithNull = await SELF.fetch("https://share.example.com/api/vaults/reviews", {91+ method: "PATCH",92+ headers,93+ body: JSON.stringify({ description: null }),94+ });95+ expect(clearedWithNull.status).toBe(200);96+ expect(await clearedWithNull.json()).toMatchObject({ name: "reviews", description: null });97+98+ const multiline = await SELF.fetch("https://share.example.com/api/vaults/reviews", {99+ method: "PATCH",100+ headers,101+ body: JSON.stringify({ description: "line1\nline2" }),102+ });103+ expect(multiline.status).toBe(400);104+105+ const badEncoding = await SELF.fetch("https://share.example.com/api/vaults/%zz", {106+ method: "PATCH",107+ headers,108+ body: JSON.stringify({ description: "x" }),109+ });110+ expect(badEncoding.status).toBe(400);89111 });90112 91113 it("evaluates a real D1 probe window through the scheduled handler", async () => {