clone

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

feat: add vault descriptions and unified vault updates

Dami committed d14c517
- nzip vault add <name> --description TEXT
- nzip vault update <name> --name NEW --description TEXT (empty clears)
- descriptions shown in vault ls, vault ls --json, and status
- renames update the invoking client's default vault and allow-list
- D1 migration 0004_vault_descriptions.sql plus runtime coverage
commit
d14c5178f9876fc17e80c89129e7317663861dcf
parent
890b49b482be9f441510ae26e5a551d7b8b19194
author
Dami <dami.etoile@gmail.com>
14 files changed, +231 -37
MREADME.md11+++++-
Mcli/README.md8++++-
Mcli/commands/sites.ts9++++-
Mcli/commands/vault.ts40++++++++++++++++++--
Mcli/lib/api.ts15+++++++-
Mcli/main.ts9+++--
Mshared/types.ts2+
Aworker/migrations/0004_vault_descriptions.sql1+
Mworker/schema.sql1+
Mworker/setup.md5++-
Mworker/src/api.ts5++-
Mworker/src/api/vaults.ts51++++++++++++++++++++++++--
Mworker/src/db.ts52+++++++++++++++++++++------
Mworker/test/index.runtime.test.ts59+++++++++++++++++++++++++++---
M README.md +9 -2
@@ -74,7 +74,10 @@ Commands accept any of three target forms:
7474 7575 ```text7676 nzip auth [--server URL] [--token T] authenticate and save config77-nzip vault add <name> [--slot N] register a vault (16 slots, 0x0–0xf)77+nzip vault add <name> [--slot N] [--description TEXT]78+ register a vault (16 slots, 0x0–0xf)79+nzip vault update <name> [--name NEW_NAME] [--description TEXT]80+ rename or describe a vault7881 nzip vault ls | default <name> list vaults / set the default7982 nzip push <dir|file> [target] [--ttl …] [--password PW | --no-password]8083 nzip cp <target> [dir] [--overwrite] copy the current hosted bundle locally
@@ -86,6 +89,10 @@ nzip status server + vault overview
8689 nzip revert <target> [--to N] [--list] repoint to a previous push8790 ```8891 92+Vault descriptions are returned by `vault ls --json` so agents can choose an93+appropriate destination from its purpose or audience. Pass an empty description94+to `vault update` to clear it.95+8996 Password and TTL are committed with the content. On a new site, omitting9097 `--password` creates an unprotected site; on an existing target, omission9198 preserves its current password. Pass `--no-password` to clear protection
@@ -194,7 +201,7 @@ Content metadata lives in three tables:
194201 195202 | table | keys |196203 | -------- | -------------------------------------------------------------------------------- |197-| `vaults` | slot (0–15), name |204+| `vaults` | slot (0–15), name, optional description |198205 | `sites` | address (0–65535), vault, alias, current manifest, expiry, password hash/version |199206 | `pushes` | per-site history (seq, manifest, note), capped at 10, powers `revert` |200207
M cli/README.md +7 -1
M cli/commands/sites.ts +7 -2
M cli/commands/vault.ts +36 -4
@@ -8,6 +8,8 @@ export async function cmdVault(
88 sub: string | undefined,99 name: string | undefined,1010 slot: number | undefined,11+ newName: string | undefined,12+ description: string | undefined,1113 ): Promise<void> {1214 const api = new ApiClient(config);1315
@@ -19,10 +21,11 @@ export async function cmdVault(
1921 return;2022 }2123 console.log(table(22- ["SLOT", "VAULT", "SITES"],24+ ["SLOT", "VAULT", "DESCRIPTION", "SITES"],2325 vaults.map((v) => [2426 `0x${v.slot.toString(16)}`,2527 v.name === config.defaultVault ? bold(`${v.name} *`) : v.name,28+ v.description ?? "",2629 String(v.siteCount),2730 ]),2831 ));
@@ -31,13 +34,13 @@ export async function cmdVault(
3134 }3235 3336 if (sub === "add") {34- if (!name) fail("usage: nzip vault add <name> [--slot N]");37+ if (!name) fail("usage: nzip vault add <name> [--slot N] [--description TEXT]");3538 try {3639 assertVaultAllowed(name, config);3740 } catch (e) {3841 return fail((e as Error).message);3942 }40- const v = await api.createVault(name, slot);43+ const v = await api.createVault(name, slot, description);4144 const madeDefault = !config.defaultVault;4245 if (madeDefault) await saveConfig({ ...config, defaultVault: v.name });4346 emit(() => {
@@ -53,6 +56,35 @@ export async function cmdVault(
5356 return;5457 }5558 59+ if (sub === "update") {60+ if (!name || (newName === undefined && description === undefined)) {61+ fail("usage: nzip vault update <name> [--name NEW_NAME] [--description TEXT]");62+ }63+ try {64+ assertVaultAllowed(name, config);65+ } catch (e) {66+ return fail((e as Error).message);67+ }68+ const v = await api.updateVault(name, { name: newName, description });69+ if (newName !== undefined && newName !== name) {70+ await saveConfig({71+ ...config,72+ ...(config.defaultVault === name ? { defaultVault: newName } : {}),73+ ...(config.allowVaults74+ ? { allowVaults: config.allowVaults.map((vault) => vault === name ? newName : vault) }75+ : {}),76+ });77+ }78+ emit(79+ () =>80+ console.log(81+ `${green("✓")} vault ${bold(name)} updated${newName ? ` as ${bold(newName)}` : ""}`,82+ ),83+ { ok: true, ...v },84+ );85+ return;86+ }87+5688 if (sub === "default") {5789 if (!name) fail("usage: nzip vault default <name>");5890 try {
@@ -70,5 +102,5 @@ export async function cmdVault(
70102 return;71103 }72104 73- fail(`unknown vault subcommand: ${sub} (use ls | add | default)`);105+ fail(`unknown vault subcommand: ${sub} (use ls | add | update | default)`);74106 }
M cli/lib/api.ts +13 -2
M cli/main.ts +6 -3
M shared/types.ts +2 -0
A worker/migrations/0004_vault_descriptions.sql +1 -0
M worker/schema.sql +1 -0
M worker/setup.md +3 -2
M worker/src/api.ts +4 -1
M worker/src/api/vaults.ts +48 -3
@@ -1,27 +1,72 @@
11 import { isValidName, VAULT_SLOTS } from "../../../shared/mod.ts";2-import { createVault, listVaults } from "../db.ts";2+import { createVault, listVaults, updateVault } from "../db.ts";33 import { type Env, json } from "../env.ts";44 import { ApiError, readJson } from "./errors.ts";55 6+const DESCRIPTION_MAX_LENGTH = 500;7+8+function descriptionValue(value: unknown): string | null {9+ if (typeof value !== "string") {10+ throw new ApiError(400, "description must be a string");11+ }12+ const description = value.trim();13+ if (description.length > DESCRIPTION_MAX_LENGTH) {14+ throw new ApiError(400, `description must be at most ${DESCRIPTION_MAX_LENGTH} characters`);15+ }16+ return description || null;17+}18+619 export async function handleVaults(720 request: Request,821 env: Env,922 ): Promise<Response> {1023 if (request.method === "GET") return json(await listVaults(env));11- const body = await readJson<{ name: string; slot?: number }>(request);24+ const body = await readJson<{ name: string; slot?: number; description?: unknown }>(request);1225 if (!isValidName(body.name)) throw new ApiError(400, "invalid vault name");1326 if (body.slot !== undefined && (body.slot < 0 || body.slot >= VAULT_SLOTS)) {1427 throw new ApiError(400, "slot must be 0-15");1528 }29+ const description = body.description === undefined ? null : descriptionValue(body.description);1630 try {17- const vault = await createVault(env, body.name, body.slot);31+ const vault = await createVault(env, body.name, body.slot, description);1832 return json({1933 slot: vault.slot,2034 name: vault.name,35+ description: vault.description,2136 createdAt: vault.created_at,37+ siteCount: 0,2238 }, 201);2339 } catch (cause) {2440 const message = cause instanceof Error ? cause.message : "vault conflict";2541 throw new ApiError(409, message, { cause });2642 }2743 }44+45+export async function handleVault(46+ request: Request,47+ env: Env,48+ encodedName: string,49+): Promise<Response> {50+ if (request.method !== "PATCH") throw new ApiError(405, "method not allowed");51+ const currentName = decodeURIComponent(encodedName);52+ const body = await readJson<{ name?: unknown; description?: unknown }>(request);53+ if (body.name === undefined && body.description === undefined) {54+ throw new ApiError(400, "provide name and/or description");55+ }56+ if (body.name !== undefined && (typeof body.name !== "string" || !isValidName(body.name))) {57+ throw new ApiError(400, "invalid vault name");58+ }59+ const patch = {60+ ...(typeof body.name === "string" ? { name: body.name } : {}),61+ ...(body.description !== undefined ? { description: descriptionValue(body.description) } : {}),62+ };63+ try {64+ const vault = await updateVault(env, currentName, patch);65+ if (!vault) throw new ApiError(404, `unknown vault: ${currentName}`);66+ return json(vault);67+ } catch (cause) {68+ if (cause instanceof ApiError) throw cause;69+ const message = cause instanceof Error ? cause.message : "vault conflict";70+ throw new ApiError(409, message, { cause });71+ }72+}
M worker/src/db.ts +41 -11
@@ -5,12 +5,7 @@ import {
55 VAULT_SLOTS,66 vaultSlotOf,77 } from "../../shared/mod.ts";8-import type {9- PushInfo,10- SiteDetail,11- SiteInfo,12- VaultInfo,13-} from "../../shared/mod.ts";8+import type { PushInfo, SiteDetail, SiteInfo, VaultInfo } from "../../shared/mod.ts";149 import { type Env, siteUrl } from "./env.ts";1510 1611 export const HISTORY_CAP = 10;
@@ -30,6 +25,7 @@ export interface SiteRow {
3025 export interface VaultRow {3126 slot: number;3227 name: string;28+ description: string | null;3329 created_at: number;3430 }3531
@@ -201,15 +197,22 @@ export async function siteHistory(
201197 202198 export async function listVaults(env: Env): Promise<VaultInfo[]> {203199 const res = await env.DB.prepare(204- `SELECT v.slot, v.name, v.created_at, COUNT(s.address) AS site_count200+ `SELECT v.slot, v.name, v.description, v.created_at, COUNT(s.address) AS site_count205201 FROM vaults v LEFT JOIN sites s ON s.vault_slot = v.slot206202 GROUP BY v.slot ORDER BY v.slot`,207203 ).all<208- { slot: number; name: string; created_at: number; site_count: number }204+ {205+ slot: number;206+ name: string;207+ description: string | null;208+ created_at: number;209+ site_count: number;210+ }209211 >();210212 return res.results.map((r) => ({211213 slot: r.slot,212214 name: r.name,215+ description: r.description,213216 createdAt: r.created_at,214217 siteCount: r.site_count,215218 }));
@@ -219,6 +222,7 @@ export async function createVault(
219222 env: Env,220223 name: string,221224 slot?: number,225+ description: string | null = null,222226 ): Promise<VaultRow> {223227 const now = Math.floor(Date.now() / 1000);224228 let chosen = slot;
@@ -236,10 +240,36 @@ export async function createVault(
236240 if (chosen === undefined) throw new Error("all 16 vault slots are taken");237241 }238242 await env.DB.prepare(239- "INSERT INTO vaults (slot, name, created_at) VALUES (?, ?, ?)",243+ "INSERT INTO vaults (slot, name, description, created_at) VALUES (?, ?, ?, ?)",240244 )241- .bind(chosen, name, now).run();242- return { slot: chosen, name, created_at: now };245+ .bind(chosen, name, description, now).run();246+ return { slot: chosen, name, description, created_at: now };247+}248+249+export async function updateVault(250+ env: Env,251+ currentName: string,252+ patch: { name?: string; description?: string | null },253+): Promise<VaultInfo | null> {254+ const assignments: string[] = [];255+ const values: unknown[] = [];256+ if (patch.name !== undefined) {257+ assignments.push("name = ?");258+ values.push(patch.name);259+ }260+ if (patch.description !== undefined) {261+ assignments.push("description = ?");262+ values.push(patch.description);263+ }264+ if (assignments.length === 0) return null;265+266+ const result = await env.DB.prepare(267+ `UPDATE vaults SET ${assignments.join(", ")} WHERE name = ?`,268+ ).bind(...values, currentName).run();269+ if (result.meta.changes === 0) return null;270+271+ const updatedName = patch.name ?? currentName;272+ return (await listVaults(env)).find((vault) => vault.name === updatedName) ?? null;243273 }244274 245275 export function siteRowToInfo(
M worker/test/index.runtime.test.ts +53 -6
@@ -1,9 +1,4 @@
1-import {2- createExecutionContext,3- env,4- SELF,5- waitOnExecutionContext,6-} from "cloudflare:test";1+import { createExecutionContext, env, SELF, waitOnExecutionContext } from "cloudflare:test";72 import { describe, expect, it } from "vitest";83 import worker from "../src/index.ts";94 import { hashPassword, verifyPassword } from "../src/password.ts";
@@ -41,6 +36,58 @@ describe("Worker runtime", () => {
4136 expect((await response.json<{ version: string }>()).version).toBe("0.3.1");4237 });4338 39+ it("creates, lists, renames, and redescribes vaults", async () => {40+ const headers = {41+ authorization: "Bearer runtime-test-token",42+ "content-type": "application/json",43+ };44+ const created = await SELF.fetch("https://share.example.com/api/vaults", {45+ method: "POST",46+ headers,47+ body: JSON.stringify({48+ name: "agent-work",49+ description: "Scratch space for agent-generated review artifacts",50+ }),51+ });52+ expect(created.status).toBe(201);53+ expect(await created.json()).toMatchObject({54+ name: "agent-work",55+ description: "Scratch space for agent-generated review artifacts",56+ siteCount: 0,57+ });58+59+ const updated = await SELF.fetch(60+ "https://share.example.com/api/vaults/agent-work",61+ {62+ method: "PATCH",63+ headers,64+ body: JSON.stringify({65+ name: "reviews",66+ description: "Human review links; safe to share with collaborators",67+ }),68+ },69+ );70+ expect(updated.status).toBe(200);71+ expect(await updated.json()).toMatchObject({72+ name: "reviews",73+ description: "Human review links; safe to share with collaborators",74+ });75+76+ const listed = await SELF.fetch("https://share.example.com/api/vaults", { headers });77+ expect(await listed.json()).toContainEqual(expect.objectContaining({78+ name: "reviews",79+ description: "Human review links; safe to share with collaborators",80+ }));81+82+ const cleared = await SELF.fetch("https://share.example.com/api/vaults/reviews", {83+ method: "PATCH",84+ headers,85+ body: JSON.stringify({ description: "" }),86+ });87+ expect(cleared.status).toBe(200);88+ expect(await cleared.json()).toMatchObject({ name: "reviews", description: null });89+ });90+4491 it("evaluates a real D1 probe window through the scheduled handler", async () => {4592 const now = Math.floor(Date.now() / 300_000) * 300;4693 const bucket = now - 300;