- commit
- e4af3d79ae92d3b4c06eac942b78c2c00377e07f
- parent
- 90f7f29da832b3a9424ebe4a4ab3fcf22a536898
- author
- FelineStateMachine <dami.etoile@gmail.com>
@@ -36,11 +36,30 @@ between steps:
3636 oversized blobs.3737 3. `POST /api/push/commit` verifies every blob, resolves or allocates the3838 address, applies TTL and password policy, repoints the site, and appends39- retained history atomically.39+ retained history atomically. TTL resolution is explicit request, existing40+ site, vault default, then the 14-day global fallback; the response identifies41+ the winning layer.4042 4143 Content-addressed blobs deduplicate across every site. The last ten pushes per4244 site remain addressable for revert operations.4345 46+## App-origin reservation47+48+`POST /api/apps` reserves a named address before content exists. The reservation49+is separate from the live `sites` row and participates in allocation forever.50+This gives lofi a stable origin before `credentialOrigins`, WebAuthn RP IDs,51+service-worker scope, OPFS, or IndexedDB state are configured. A reservation52+without content serves a small non-indexed placeholder. Deploying creates or53+updates the ordinary site row at the reserved address; deletion and expiry may54+remove that row but never the reservation tombstone.55+56+The CLI's tracked `nzip.app.json` stores only framework, target, address, origin,57+and build configuration. The owner bearer token remains solely in the mode-060058+CLI config. `app deploy` runs lofi's Deno build at the origin root, validates the59+build identity and PWA artifacts, and sends the generated CSP with the commit so60+the Worker can mirror it as an HTTP response header. Sync-node configuration is61+application data and remains orthogonal to hosting.62+4463 ## Serving and caching4564 4665 The configured control origin owns authenticated APIs, notification enrollment,@@ -79,11 +98,13 @@ wins.
7998 8099 Core publishing state:81100 82-| table | purpose |83-| -------- | -------------------------------------------------------------------- |84-| `vaults` | named vault slots, descriptions, and site grouping |85-| `sites` | address, vault, alias, current manifest, expiry, and password policy |86-| `pushes` | retained per-site manifest history |101+| table | purpose |102+| ------------------ | --------------------------------------------------------------------- |103+| `vaults` | named slots, descriptions, and optional default TTL |104+| `vault_defaults` | temporary/permanent lifecycle selection |105+| `app_reservations` | permanent address and alias tombstones for application origins |106+| `sites` | live address, manifest, expiry, password, and optional app CSP policy |107+| `pushes` | retained per-site manifest history |87108 88109 Security and notification state:89110 @@ -58,14 +58,17 @@ also the site hostname; see the parent-domain caveat in [SECURITY.md](SECURITY.m
5858 | ----------- | ----------- | -------------------------- |5959 | address | `2a3f` | direct hexadecimal address |6060 | vault alias | `work:demo` | alias within a named vault |61-| bare alias | `demo` | alias in the default vault |61+| bare alias | `demo` | alias in the temporary default vault |6262 6363 ## Features6464 6565 - **Instant and content-addressed.** Push one canonical manifest and only the blobs the server does6666 not already have. Identical files are stored once across every site.67-- **Ephemeral by default.** Shares expire after 14 days unless `--ttl` selects another finite68- duration or `forever`.67+- **Lifecycle-aware by default.** Fresh servers use `personal` (slot `0`, 14 days) for temporary68+ shares and `public` (slot `f`, forever) for permanent app origins. Every vault can override its69+ own default TTL.70+- **First-class lofi apps.** `app init` reserves the final browser origin before the first build;71+ `app deploy` validates and publishes a root-scoped lofi PWA without ever recycling that origin.6972 - **Revertible.** The last ten pushes per site are retained and available through7073 `nzip site revert`.7174 - **Password-protectable.** Password policy is committed atomically with content. Signed, host-only@@ -87,11 +90,14 @@ nzip
8790 ├─ --version [--json] show the installed CLI version8891 ├─ auth [--server URL] [--token T] authenticate and save config8992 ├─ status show server and vault status93+├─ app94+│ ├─ init <alias|vault:alias> reserve a stable app URL95+│ └─ deploy build and deploy the configured lofi app9096 ├─ vault91-│ ├─ add <name> [--slot N] [--description TEXT]92-│ ├─ update <name> [--name NEW_NAME] [--description TEXT | --no-description]97+│ ├─ add <name> [--slot N] [--default-ttl 14d|forever|inherit]98+│ ├─ update <name> [--default-ttl 14d|forever|inherit]9399 │ ├─ ls list vaults94-│ └─ default <name> set the default vault100+│ └─ default <temporary|permanent> <name>95101 ├─ site96102 │ ├─ push <dir|file> [target] [--ttl …] [--password PW | --no-password]97103 │ ├─ cp <target> [dir] [--overwrite] copy a hosted bundle@@ -110,9 +116,32 @@ nzip
110116 └─ revoke <device-id> [--yes] revoke a notification device111117 ```112118 113-Password and TTL values are committed with content. On an existing site, omitted settings preserve114-their current values; `--no-password` clears protection explicitly. Directory pushes skip dotfiles115-and `node_modules` and honor a `.nzipignore` file containing one glob per line.119+Password and TTL values are committed with content. New-site TTL resolves in this order: explicit120+`--ttl`, existing-site expiry, vault default, then the global 14-day fallback. Changing a vault121+default never rewrites existing sites. `public` names a durable namespace, not an access policy;122+sites there may still be password-protected. `status --json` and `vault ls --json` expose123+`defaultVaults`, `globalDefaultTtl`, and each vault's effective default for agent planning. Push JSON124+reports `ttl` and `ttlSource`. Directory pushes skip dotfiles and `node_modules` and honor a125+`.nzipignore` file containing one glob per line.126+127+## Host a lofi app128+129+Reserve the production origin before configuring credentials or building:130+131+```sh132+cd field-notes133+nzip app init field-notes134+# add the printed origin to src/app.ts credentialOrigins135+nzip app deploy136+```137+138+`app init` defaults to the permanent vault, is idempotent, and writes a token-free139+`nzip.app.json` that is safe to commit. Before the first deploy, the reserved URL serves a small140+not-deployed page. `app deploy` runs the configured Deno build task with `LOFI_BASE_PATH=/`, verifies141+the lofi build identity, PWA files, stable credential origin, and optional passkey RP ID, then142+publishes `dist/`. It also mirrors lofi's generated CSP as a response header. Deleting or expiring143+app content does not free its address: the origin reservation remains a permanent tombstone because144+service workers, OPFS, IndexedDB, and passkeys bind application identity to that origin.116145 117146 See [`cli/README.md`](cli/README.md) for detailed CLI behavior and examples.118147 @@ -12,6 +12,13 @@ nzip's unlock cookies are host-only. The control-plane shorthand
1212 `https://<control-origin>/<address>` is redirect-only and never serves artifact1313 bytes.1414 15+App origins are treated as permanent identity boundaries. `app init` records an16+address tombstone before deployment, and neither site deletion nor expiry makes17+that address allocatable again. This avoids handing a previous application's18+service-worker scope, browser storage namespace, or passkey origin to unrelated19+future content. A vault named `public` describes durable retention; it does not20+disable password protection or make an address confidential.21+1522 Subdomain isolation is a browser security boundary, not a content-safety review.1623 A hosted application still has its own origin's ambient authority, can make1724 cross-origin network requests where a target permits them, and can deceive its@@ -40,6 +47,7 @@ suggested mitigation.
4047 - Every `/api/*` management route requires the deployment-wide bearer token.4148 - The bearer token stays in Worker secrets and the CLI's mode-0600 configuration4249 file; browsers and installed notification apps never receive it.50+- `nzip.app.json` contains a public target and origin but never the bearer token.4351 - Public site addresses occupy only 16 bits and should be treated as enumerable.4452 - The wildcard route accepts only exact four-character lowercase hexadecimal4553 site hostnames. Unknown wildcard hosts cannot dispatch management or@@ -46,11 +46,14 @@ nzip
4646 ├─ --version [--json] show the installed CLI version4747 ├─ auth [--server URL] [--token T] authenticate and save config4848 ├─ status show server and vault status49+├─ app50+│ ├─ init <alias|vault:alias> reserve a stable app URL51+│ └─ deploy build and deploy the configured lofi app4952 ├─ vault50-│ ├─ add <name> [--slot N] [--description TEXT]51-│ ├─ update <name> [--name NEW_NAME] [--description TEXT | --no-description]53+│ ├─ add <name> [--slot N] [--default-ttl 14d|forever|inherit]54+│ ├─ update <name> [--default-ttl 14d|forever|inherit]5255 │ ├─ ls list vaults53-│ └─ default <name> set the default vault56+│ └─ default <temporary|permanent> <name>5457 ├─ site5558 │ ├─ push <dir|file> [target] [--ttl …] [--password PW | --no-password]5659 │ ├─ cp <target> [dir] [--overwrite] copy a hosted bundle@@ -69,12 +72,23 @@ nzip
6972 └─ revoke <device-id> [--yes] revoke a notification device7073 ```7174 72-Vault descriptions are included in `vault ls --json`. Use73-`nzip vault update <name> --no-description` to clear one.74-75-Password and TTL are committed with the content. On a new site, omitting `--ttl` uses 14 days and76-omitting `--password` creates an unprotected site. On an existing target, omitted settings preserve77-their current values. Pass `--no-password` to clear protection explicitly.75+Vault descriptions, lifecycle roles, configured TTLs, and effective TTLs are included in76+`vault ls --json`. Use `nzip vault update <name> --no-description` to clear a description,77+`--default-ttl inherit` to restore the global fallback, and78+`nzip vault default <temporary|permanent> <name>` to select lifecycle defaults.79+80+Password and TTL are committed with the content. TTL precedence is explicit flag, existing-site81+expiry, vault default, then the global 14-day fallback. Fresh servers create `personal` in slot `0`82+as the temporary 14-day default and `public` in slot `f` as the permanent forever default, without83+overwriting occupied slots on upgrade. `public` does not mean unprotected. Pass `--no-password` to84+clear protection explicitly. Push JSON includes the resolved `ttl` and `ttlSource`.85+86+For a lofi PWA, run `nzip app init <alias>` before editing its deployed credential origins. The87+command reserves the permanent default vault's final URL and writes a token-free `nzip.app.json`.88+After adding the printed origin to `src/app.ts` `credentialOrigins`, run `nzip app deploy`. It89+builds at `/`, validates the lofi output and stable identity settings, mirrors the generated CSP,90+and pushes to the reservation. The address remains reserved after content deletion or expiry and is91+never returned to ordinary site allocation.7892 7993 `nzip site cp work:demo ./recovered-demo` reconstructs the current hosted bundle into an empty8094 directory. It uses the configured bearer token, verifies file hashes, and never exposes source via@@ -0,0 +1,212 @@
1+import { isAbsolute, relative, resolve } from "@std/path";2+import { isValidName } from "@nzip/shared";3+import { ApiClient } from "../lib/api.ts";4+import type { Config } from "../lib/config.ts";5+import { assertVaultAllowed } from "../lib/config.ts";6+import { bold, cyan, dim, emit, fail, green } from "../lib/fmt.ts";7+import { cmdPush } from "./push.ts";8+9+const APP_CONFIG_FILE = "nzip.app.json";10+11+export interface NzipAppConfig {12+ v: 1;13+ framework: "lofi";14+ target: string;15+ address: string;16+ origin: string;17+ build: { task: string; output: string };18+}19+20+interface AppFlags {21+ framework?: string;22+ output?: string;23+ buildTask?: string;24+}25+26+async function readAppConfig(optional = false): Promise<NzipAppConfig | null> {27+ let text: string;28+ try {29+ text = await Deno.readTextFile(APP_CONFIG_FILE);30+ } catch (cause) {31+ if (optional && cause instanceof Deno.errors.NotFound) return null;32+ return fail(`no ${APP_CONFIG_FILE} found — run \`nzip app init <alias>\` first`);33+ }34+ try {35+ const value = JSON.parse(text) as Partial<NzipAppConfig>;36+ if (37+ value.v !== 1 || value.framework !== "lofi" ||38+ typeof value.target !== "string" || typeof value.address !== "string" ||39+ typeof value.origin !== "string" || typeof value.build?.task !== "string" ||40+ typeof value.build.output !== "string"41+ ) {42+ throw new Error("unsupported shape");43+ }44+ return value as NzipAppConfig;45+ } catch (cause) {46+ return fail(`invalid ${APP_CONFIG_FILE}: ${(cause as Error).message}`);47+ }48+}49+50+function splitAppTarget(raw: string, defaultVault: string | null): [string, string] {51+ const separator = raw.indexOf(":");52+ const vault = separator === -1 ? defaultVault : raw.slice(0, separator);53+ const alias = separator === -1 ? raw : raw.slice(separator + 1);54+ if (!vault) fail("server has no permanent default vault — use vault:alias");55+ if (!isValidName(vault) || !isValidName(alias)) {56+ fail("app target must be an alias or vault:alias using lowercase names");57+ }58+ return [vault, alias];59+}60+61+function containsStringLiteral(source: string, value: string): boolean {62+ return source.includes(JSON.stringify(value)) ||63+ source.includes(`'${value.replaceAll("'", "\\'")}'`);64+}65+66+async function cmdAppInit(67+ config: Config,68+ rawTarget: string | undefined,69+ flags: AppFlags,70+): Promise<void> {71+ const existing = await readAppConfig(true);72+ if (existing && rawTarget === undefined) rawTarget = existing.target;73+ if (!rawTarget) fail("usage: nzip app init <alias|vault:alias> [--framework lofi]");74+ if (flags.framework !== undefined && flags.framework !== "lofi") {75+ fail("the first-class app host currently supports --framework lofi");76+ }77+78+ const api = new ApiClient(config);79+ const status = await api.status();80+ const [vault, alias] = splitAppTarget(rawTarget, status.defaultVaults?.permanent ?? null);81+ assertVaultAllowed(vault, config);82+ const target = `${vault}:${alias}`;83+ if (existing && existing.target !== target) {84+ fail(`${APP_CONFIG_FILE} already reserves ${existing.target}; refusing to replace its origin`);85+ }86+87+ const reservation = await api.initApp(vault, alias);88+ const origin = new URL(reservation.url).origin;89+ if (existing && (existing.address !== reservation.address || existing.origin !== origin)) {90+ fail(`server reservation for ${target} does not match ${APP_CONFIG_FILE}`);91+ }92+ const appConfig: NzipAppConfig = existing ?? {93+ v: 1,94+ framework: "lofi",95+ target,96+ address: reservation.address,97+ origin,98+ build: {99+ task: flags.buildTask ?? "build",100+ output: flags.output ?? "dist",101+ },102+ };103+ await Deno.writeTextFile(APP_CONFIG_FILE, `${JSON.stringify(appConfig, null, 2)}\n`);104+105+ emit(106+ () => {107+ console.log(`${green("✓")} reserved ${bold(target)} → ${cyan(reservation.url)}`);108+ console.log(dim(` wrote ${APP_CONFIG_FILE} (safe to commit; contains no token)`));109+ console.log(110+ dim(` add ${JSON.stringify(origin)} to lofi credentialOrigins, then run nzip app deploy`),111+ );112+ },113+ { ok: true, ...reservation, framework: appConfig.framework, config: APP_CONFIG_FILE },114+ );115+}116+117+async function requireFile(path: string): Promise<void> {118+ try {119+ if (!(await Deno.stat(path)).isFile) throw new Error("not a file");120+ } catch {121+ fail(`lofi build output is missing ${path}`);122+ }123+}124+125+async function cmdAppDeploy(config: Config): Promise<void> {126+ const app = await readAppConfig();127+ if (!app) return;128+ const root = Deno.cwd();129+ const output = resolve(root, app.build.output);130+ const outputRelative = relative(root, output);131+ if (132+ isAbsolute(app.build.output) || outputRelative === ".." || outputRelative.startsWith(`..${"/"}`)133+ ) {134+ fail(`${APP_CONFIG_FILE} build.output must stay inside the app directory`);135+ }136+137+ let appSource: string;138+ try {139+ appSource = await Deno.readTextFile(resolve(root, "src/app.ts"));140+ } catch {141+ return fail("lofi app source src/app.ts is missing");142+ }143+ if (!containsStringLiteral(appSource, app.origin)) {144+ fail(145+ `src/app.ts must include ${146+ JSON.stringify(app.origin)147+ } in credentialOrigins before deployment`,148+ );149+ }150+ const hostname = new URL(app.origin).hostname;151+ if (/\bpasskey\s*:/.test(appSource) && !containsStringLiteral(appSource, hostname)) {152+ fail(`src/app.ts passkey.rpId must be pinned to ${JSON.stringify(hostname)}`);153+ }154+155+ emit(() => console.log(dim(` running deno task ${app.build.task} with LOFI_BASE_PATH=/`)));156+ const built = await new Deno.Command("deno", {157+ args: ["task", app.build.task],158+ cwd: root,159+ env: { LOFI_BASE_PATH: "/" },160+ stdin: "inherit",161+ stdout: "inherit",162+ stderr: "inherit",163+ }).spawn().status;164+ if (!built.success) fail(`lofi build failed with exit code ${built.code}`);165+166+ await Promise.all([167+ requireFile(resolve(output, "index.html")),168+ requireFile(resolve(output, "manifest.webmanifest")),169+ requireFile(resolve(output, "sw.js")),170+ requireFile(resolve(output, "lofi-precache.json")),171+ requireFile(resolve(output, "lofi-schema.json")),172+ requireFile(resolve(output, "lofi-build.json")),173+ ]);174+ let buildInfo: { lofiVersion?: unknown; sourceHash?: unknown; basePath?: unknown; csp?: unknown };175+ try {176+ buildInfo = JSON.parse(await Deno.readTextFile(resolve(output, "lofi-build.json")));177+ } catch {178+ return fail("dist/lofi-build.json is not valid JSON");179+ }180+ if (181+ typeof buildInfo.lofiVersion !== "string" || typeof buildInfo.sourceHash !== "string" ||182+ buildInfo.basePath !== "/" ||183+ (buildInfo.csp !== undefined && typeof buildInfo.csp !== "string")184+ ) {185+ fail("lofi build identity is invalid or was not built for the origin root");186+ }187+188+ await cmdPush(189+ config,190+ output,191+ app.target,192+ undefined,193+ undefined,194+ false,195+ typeof buildInfo.csp === "string" ? { contentSecurityPolicy: buildInfo.csp } : {},196+ );197+}198+199+export async function cmdApp(200+ config: Config,201+ rest: string[],202+ flags: AppFlags,203+): Promise<void> {204+ const [sub, target, ...extra] = rest;205+ if (extra.length > 0) fail(`too many arguments for nzip app ${sub ?? ""}`);206+ if (sub === "init") return await cmdAppInit(config, target, flags);207+ if (sub === "deploy") {208+ if (target !== undefined) fail("usage: nzip app deploy");209+ return await cmdAppDeploy(config);210+ }211+ fail("usage: nzip app <init|deploy> ...");212+}@@ -0,0 +1,156 @@
1+import { cmdApp } from "./app.ts";2+3+Deno.test("app init reserves the permanent default and writes token-free tracked config", async () => {4+ const directory = await Deno.makeTempDir();5+ const previousDirectory = Deno.cwd();6+ const previousFetch = globalThis.fetch;7+ const previousLog = console.log;8+ const requests: string[] = [];9+ globalThis.fetch = ((input) => {10+ const url = String(input);11+ requests.push(url);12+ if (url.endsWith("/api/status")) {13+ return Promise.resolve(14+ new Response(JSON.stringify({15+ ok: true,16+ version: "0.9.0",17+ defaultVaults: { temporary: "personal", permanent: "public" },18+ globalDefaultTtl: 14,19+ vaults: [],20+ siteCount: 0,21+ expiringSoon: 0,22+ })),23+ );24+ }25+ if (url.endsWith("/api/apps")) {26+ return Promise.resolve(27+ new Response(JSON.stringify({28+ address: "f123",29+ vault: "public",30+ alias: "field-notes",31+ url: "https://f123.n.zip/",32+ createdAt: 1,33+ deployed: false,34+ })),35+ );36+ }37+ throw new Error(`unexpected fetch: ${url}`);38+ }) as typeof fetch;39+ console.log = () => {};40+ try {41+ Deno.chdir(directory);42+ const config = { server: "https://n.zip", token: "owner-secret" };43+ await cmdApp(config, ["init", "field-notes"], {});44+ await cmdApp(config, ["init"], {});45+ const saved = JSON.parse(await Deno.readTextFile("nzip.app.json"));46+ if (47+ saved.target !== "public:field-notes" || saved.address !== "f123" ||48+ saved.origin !== "https://f123.n.zip" || saved.framework !== "lofi"49+ ) {50+ throw new Error(`unexpected app config: ${JSON.stringify(saved)}`);51+ }52+ if (JSON.stringify(saved).includes(config.token)) throw new Error("app config leaked token");53+ if (requests.filter((url) => url.endsWith("/api/apps")).length !== 2) {54+ throw new Error("idempotent init did not verify the server reservation");55+ }56+ } finally {57+ Deno.chdir(previousDirectory);58+ globalThis.fetch = previousFetch;59+ console.log = previousLog;60+ await Deno.remove(directory, { recursive: true });61+ }62+});63+64+Deno.test("app deploy builds a root-scoped lofi PWA and commits its CSP", async () => {65+ const directory = await Deno.makeTempDir();66+ const previousDirectory = Deno.cwd();67+ const previousFetch = globalThis.fetch;68+ const previousLog = console.log;69+ let committed: Record<string, unknown> | undefined;70+ globalThis.fetch = ((input, init) => {71+ const url = String(input);72+ if (url.endsWith("/api/push/prepare")) {73+ return Promise.resolve(74+ new Response(JSON.stringify({ manifestHash: "a".repeat(64), missing: [] })),75+ );76+ }77+ if (url.endsWith("/api/push/commit")) {78+ committed = JSON.parse(String(init?.body));79+ return Promise.resolve(80+ new Response(JSON.stringify({81+ address: "f123",82+ url: "https://f123.n.zip/",83+ alias: "field-notes",84+ manifestHash: "a".repeat(64),85+ expiresAt: null,86+ ttl: "forever",87+ ttlSource: "vault",88+ protected: false,89+ seq: 1,90+ })),91+ );92+ }93+ throw new Error(`unexpected fetch: ${url}`);94+ }) as typeof fetch;95+ console.log = () => {};96+ try {97+ Deno.chdir(directory);98+ await Deno.mkdir("src");99+ await Deno.writeTextFile(100+ "src/app.ts",101+ 'const app = { credentialOrigins: ["https://f123.n.zip"] };\n',102+ );103+ await Deno.writeTextFile(104+ "deno.json",105+ JSON.stringify({ tasks: { build: "deno run -A build.ts" } }),106+ );107+ await Deno.writeTextFile(108+ "build.ts",109+ `await Deno.mkdir("dist", { recursive: true });110+const files = {111+ "index.html": "<!doctype html><title>app</title>",112+ "manifest.webmanifest": "{}",113+ "sw.js": "// worker",114+ "lofi-precache.json": "[]",115+ "lofi-schema.json": "{}",116+ "lofi-build.json": JSON.stringify({117+ lofiVersion: "1.0.0",118+ sourceHash: "fixture",119+ basePath: "/",120+ csp: "default-src 'self'",121+ }),122+};123+for (const [name, value] of Object.entries(files)) await Deno.writeTextFile("dist/" + name, value);124+`,125+ );126+ await Deno.writeTextFile(127+ "nzip.app.json",128+ JSON.stringify({129+ v: 1,130+ framework: "lofi",131+ target: "public:field-notes",132+ address: "f123",133+ origin: "https://f123.n.zip",134+ build: { task: "build", output: "dist" },135+ }),136+ );137+138+ await cmdApp(139+ { server: "https://n.zip", token: "owner-secret" },140+ ["deploy"],141+ {},142+ );143+ if (144+ (committed?.target as { vault?: string })?.vault !== "public" ||145+ (committed?.app as { contentSecurityPolicy?: string })?.contentSecurityPolicy !==146+ "default-src 'self'"147+ ) {148+ throw new Error(`unexpected app commit: ${JSON.stringify(committed)}`);149+ }150+ } finally {151+ Deno.chdir(previousDirectory);152+ globalThis.fetch = previousFetch;153+ console.log = previousLog;154+ await Deno.remove(directory, { recursive: true });155+ }156+});@@ -49,7 +49,7 @@ export async function cmdAuth(
4949 }5050 5151 if (!config.defaultVault && status.vaults.length > 0) {52- config.defaultVault = status.vaults[0].name;52+ config.defaultVault = status.defaultVaults?.temporary ?? status.vaults[0].name;5353 }5454 await saveConfig(config);5555 emit(() => {@@ -57,7 +57,7 @@ export async function cmdAuth(
5757 `${green("✓")} authenticated against ${server} — saved to ${configPath()}`,5858 );5959 if (config.defaultVault) {60- console.log(` default vault: ${config.defaultVault}`);60+ console.log(` temporary default vault: ${config.defaultVault}`);6161 }6262 }, {6363 ok: true,@@ -22,6 +22,7 @@ export async function cmdPush(
2222 ttlRaw: string | undefined,2323 password: string | undefined,2424 noPassword: boolean,25+ app?: { contentSecurityPolicy?: string },2526 ): Promise<void> {2627 if (!path) {2728 fail(@@ -35,9 +36,20 @@ export async function cmdPush(
3536 3637 // Resolve (and vault-guard) the target up front — before any bundling or3738 // upload — so a disallowed vault is refused without touching the network.39+ const targetConfig = await (async (): Promise<Config> => {40+ if (targetRaw !== undefined && (/^[0-9a-f]{4}$/.test(targetRaw) || targetRaw.includes(":"))) {41+ return config;42+ }43+ const status = await api.status();44+ const defaultVault = status.defaultVaults?.temporary ?? config.defaultVault;45+ if (!defaultVault) {46+ fail("server has no temporary default vault — specify vault:alias or configure one");47+ }48+ return { ...config, defaultVault };49+ })();3850 const target = (() => {3951 try {40- return commitTargetFor(targetRaw, config);52+ return commitTargetFor(targetRaw, targetConfig);4153 } catch (e) {4254 return fail((e as Error).message);4355 }@@ -95,6 +107,7 @@ export async function cmdPush(
95107 target,96108 ttl,97109 password: passwordPolicy,110+ ...(app === undefined ? {} : { app }),98111 });99112 100113 // Breadcrumb: remember which directory this machine pushed from so@@ -151,15 +151,22 @@ export async function cmdStatus(config: Config): Promise<void> {
151151 status.expiringSoon > 0 ? ` ${amber(`(${status.expiringSoon} expiring <48h)`)}` : ""152152 }`,153153 );154+ console.log(155+ ` defaults: temporary=${status.defaultVaults.temporary ?? "unset"} permanent=${156+ status.defaultVaults.permanent ?? "unset"157+ } global ttl=${status.globalDefaultTtl}d`,158+ );154159 if (status.vaults.length > 0) {155160 console.log(table(156- ["SLOT", "VAULT", "SITES", "DESCRIPTION"],161+ ["SLOT", "VAULT", "SITES", "DEFAULT TTL", "DEFAULT FOR", "DESCRIPTION"],157162 status.vaults.map((158163 v,159164 ) => [160165 `0x${v.slot.toString(16)}`,161166 v.name,162167 String(v.siteCount),168+ String(v.effectiveDefaultTtl),169+ v.defaultFor.join(","),163170 v.description ?? "",164171 ]),165172 ));@@ -1,7 +1,8 @@
11 import { ApiClient } from "../lib/api.ts";2+import type { Ttl, VaultLifecycle } from "@nzip/shared";23 import type { Config } from "../lib/config.ts";34 import { assertVaultAllowed, saveConfig } from "../lib/config.ts";4-import { bold, dim, emit, fail, green, table } from "../lib/fmt.ts";5+import { bold, dim, emit, fail, green, parseTtl, table } from "../lib/fmt.ts";56 import { renameVault } from "../lib/paths.ts";67 78 export interface VaultFlags {@@ -9,6 +10,31 @@ export interface VaultFlags {
910 newName?: string;1011 description?: string;1112 clearDescription?: boolean;13+ defaultTtl?: string;14+ defaultFor?: string;15+ clearDefaultFor?: boolean;16+}17+18+function defaultTtlFlag(raw: string | undefined): Ttl | null | undefined {19+ if (raw === undefined) return undefined;20+ if (raw === "inherit") return null;21+ try {22+ return parseTtl(raw);23+ } catch (cause) {24+ return fail((cause as Error).message.replace("--ttl", "--default-ttl"));25+ }26+}27+28+function lifecycleFlag(flags: VaultFlags): VaultLifecycle | null | undefined {29+ if (flags.clearDefaultFor && flags.defaultFor !== undefined) {30+ fail("pass either --default-for or --no-default-for, not both");31+ }32+ if (flags.clearDefaultFor) return null;33+ if (flags.defaultFor === undefined) return undefined;34+ if (flags.defaultFor !== "temporary" && flags.defaultFor !== "permanent") {35+ fail('--default-for must be "temporary" or "permanent"');36+ }37+ return flags.defaultFor;1238 }1339 1440 /**@@ -31,42 +57,60 @@ function descriptionFlag(flags: VaultFlags): string | undefined {
3157 3258 export async function cmdVault(3359 config: Config,34- sub: string | undefined,35- name: string | undefined,60+ rest: string[],3661 flags: VaultFlags,3762 ): Promise<void> {63+ const [sub, name, third, ...extra] = rest;64+ if (extra.length > 0) fail(`too many arguments for nzip vault ${sub ?? ""}`);3865 const api = new ApiClient(config);3966 const { slot, newName } = flags;4067 4168 if (sub === "ls" || sub === undefined) {42- const vaults = await api.listVaults();69+ if (name !== undefined) fail("usage: nzip vault ls");70+ const status = await api.status();71+ const vaults = status.vaults;4372 emit(() => {4473 if (vaults.length === 0) {4574 console.log(dim("no vaults registered — run `nzip vault add <name>`"));4675 return;4776 }4877 console.log(table(49- ["SLOT", "VAULT", "SITES", "DESCRIPTION"],78+ ["SLOT", "VAULT", "SITES", "DEFAULT TTL", "DEFAULT FOR", "DESCRIPTION"],5079 vaults.map((v) => [5180 `0x${v.slot.toString(16)}`,52- v.name === config.defaultVault ? bold(`${v.name} *`) : v.name,81+ v.defaultFor.length > 0 ? bold(v.name) : v.name,5382 String(v.siteCount),83+ String(v.effectiveDefaultTtl),84+ v.defaultFor.join(","),5485 v.description ?? "",5586 ]),5687 ));57- }, { ok: true, defaultVault: config.defaultVault ?? null, vaults });88+ }, {89+ ok: true,90+ defaultVaults: status.defaultVaults,91+ globalDefaultTtl: status.globalDefaultTtl,92+ vaults,93+ });5894 return;5995 }6096 6197 if (sub === "add") {62- if (!name) fail("usage: nzip vault add <name> [--slot N] [--description TEXT]");98+ if (!name || third !== undefined) {99+ fail("usage: nzip vault add <name> [--slot N] [--description TEXT]");100+ }63101 const description = descriptionFlag(flags) || undefined;64102 try {65103 assertVaultAllowed(name, config);66104 } catch (e) {67105 return fail((e as Error).message);68106 }69- const v = await api.createVault(name, slot, description);107+ const v = await api.createVault(108+ name,109+ slot,110+ description,111+ defaultTtlFlag(flags.defaultTtl),112+ lifecycleFlag(flags),113+ );70114 const madeDefault = !config.defaultVault;71115 if (madeDefault) await saveConfig({ ...config, defaultVault: v.name });72116 emit(() => {@@ -84,10 +128,13 @@ export async function cmdVault(
84128 85129 if (sub === "update") {86130 if (87- !name || (newName === undefined && flags.description === undefined && !flags.clearDescription)131+ !name || third !== undefined ||132+ (newName === undefined && flags.description === undefined && !flags.clearDescription) &&133+ flags.defaultTtl === undefined && flags.defaultFor === undefined &&134+ !flags.clearDefaultFor88135 ) {89136 fail(90- "usage: nzip vault update <name> [--name NEW_NAME] [--description TEXT | --no-description]",137+ "usage: nzip vault update <name> [--name NEW_NAME] [--description TEXT | --no-description] [--default-ttl 14d|forever|inherit]",91138 );92139 }93140 const description = descriptionFlag(flags);@@ -97,7 +144,12 @@ export async function cmdVault(
97144 } catch (e) {98145 return fail((e as Error).message);99146 }100- const v = await api.updateVault(name, { name: newName, description });147+ const v = await api.updateVault(name, {148+ name: newName,149+ description,150+ defaultTtl: defaultTtlFlag(flags.defaultTtl),151+ defaultFor: lifecycleFlag(flags),152+ });101153 if (newName !== undefined && newName !== name) {102154 await saveConfig({103155 ...config,@@ -119,18 +171,19 @@ export async function cmdVault(
119171 }120172 121173 if (sub === "default") {122- if (!name) fail("usage: nzip vault default <name>");174+ if ((name !== "temporary" && name !== "permanent") || !third) {175+ fail("usage: nzip vault default <temporary|permanent> <name>");176+ }123177 try {124- assertVaultAllowed(name, config);178+ assertVaultAllowed(third, config);125179 } catch (e) {126180 return fail((e as Error).message);127181 }128- const vaults = await api.listVaults();129- if (!vaults.some((v) => v.name === name)) fail(`unknown vault: ${name}`);130- await saveConfig({ ...config, defaultVault: name });182+ const result = await api.setDefaultVault(name, third);183+ if (name === "temporary") await saveConfig({ ...config, defaultVault: third });131184 emit(132- () => console.log(`${green("✓")} default vault set to ${bold(name)}`),133- { ok: true, defaultVault: name },185+ () => console.log(`${green("✓")} ${name} default vault set to ${bold(third)}`),186+ { ok: true, ...result },134187 );135188 return;136189 }@@ -2,6 +2,7 @@
22 33 import type {44 ApiError,5+ AppReservationInfo,56 CommitRequest,67 CommitResponse,78 Manifest,@@ -17,7 +18,9 @@ import type {
1718 SourceResponse,1819 StatusResponse,1920 Target,21+ Ttl,2022 VaultInfo,23+ VaultLifecycle,2124 } from "@nzip/shared";2225 import { assertRawAddressAllowed, assertVaultAllowed, type Config } from "./config.ts";2326 @@ -150,13 +153,28 @@ export class ApiClient {
150153 return this.request("GET", "/api/vaults");151154 }152155 153- createVault(name: string, slot?: number, description?: string): Promise<VaultInfo> {154- return this.request("POST", "/api/vaults", JSON.stringify({ name, slot, description }));156+ createVault(157+ name: string,158+ slot?: number,159+ description?: string,160+ defaultTtl?: Ttl | null,161+ defaultFor?: VaultLifecycle | null,162+ ): Promise<VaultInfo> {163+ return this.request(164+ "POST",165+ "/api/vaults",166+ JSON.stringify({ name, slot, description, defaultTtl, defaultFor }),167+ );155168 }156169 157170 updateVault(158171 currentName: string,159- patch: { name?: string; description?: string },172+ patch: {173+ name?: string;174+ description?: string | null;175+ defaultTtl?: Ttl | null;176+ defaultFor?: VaultLifecycle | null;177+ },160178 ): Promise<VaultInfo> {161179 return this.request(162180 "PATCH",@@ -165,6 +183,25 @@ export class ApiClient {
165183 );166184 }167185 186+ setDefaultVault(187+ lifecycle: VaultLifecycle,188+ name: string,189+ ): Promise<{ defaultVaults: Record<VaultLifecycle, string | null>; vault: VaultInfo }> {190+ return this.request(191+ "PUT",192+ `/api/default-vaults/${lifecycle}`,193+ JSON.stringify({ name }),194+ );195+ }196+197+ initApp(vault: string, alias: string): Promise<AppReservationInfo> {198+ return this.request(199+ "POST",200+ "/api/apps",201+ JSON.stringify({ target: { vault, alias } }),202+ );203+ }204+168205 notify(event: NotifyRequest): Promise<NotifyResponse> {169206 return this.request("POST", "/api/notify", JSON.stringify(event));170207 }@@ -78,7 +78,7 @@ export function hintFor(message: string): string | undefined {
7878 return "list sites with: nzip site ls --json (targets: 2a3f | vault:alias | alias)";7979 }8080 if (/bare alias.*defaultVault|no defaultVault/.test(message)) {81- return "use vault:alias explicitly, or set a default with: nzip vault default <name>";81+ return "use vault:alias explicitly, or set one with: nzip vault default temporary <name>";8282 }8383 if (/blobs missing/.test(message)) {8484 return "an earlier upload was interrupted — re-run the same nzip site push";@@ -2,7 +2,7 @@
22 /**33 * `nzip` — push a directory of HTML from the terminal and get a four-character44 * URL back. This is the CLI entrypoint; it parses argv and dispatches to the5- * command handlers (`auth`, `status`, `vault`, `site`, and `notify`).5+ * command handlers (`auth`, `status`, `app`, `vault`, `site`, and `notify`).66 *77 * Install and run it as a command rather than importing it:88 *@@ -19,6 +19,7 @@
1919 import { parseArgs } from "@std/cli/parse-args";2020 import { VERSION } from "@nzip/shared";2121 import { cmdAuth } from "./commands/auth.ts";22+import { cmdApp } from "./commands/app.ts";2223 import { cmdNotify } from "./commands/notify.ts";2324 import { cmdSiteGroup } from "./commands/site.ts";2425 import { cmdStatus } from "./commands/sites.ts";@@ -36,11 +37,14 @@ commands:
3637 nzip3738 ├─ auth [--server URL] [--token T] authenticate against the server3839 ├─ status show server and vault status40+ ├─ app41+ │ ├─ init <alias|vault:alias> reserve a stable app URL42+ │ └─ deploy build and deploy the configured lofi app3943 ├─ vault40- │ ├─ add <name> [--slot N] [--description TEXT]41- │ ├─ update <name> [--name NEW_NAME] [--description TEXT | --no-description]44+ │ ├─ add <name> [--slot N] [--default-ttl 14d|forever|inherit]45+ │ ├─ update <name> [--default-ttl 14d|forever|inherit]4246 │ ├─ ls list vaults43- │ └─ default <name> set the default vault47+ │ └─ default <temporary|permanent> <name>4448 ├─ site4549 │ ├─ push <dir|file> [target] [--ttl ...] [--password PW | --no-password]4650 │ ├─ cp <target> [dir] [--overwrite] copy a hosted bundle@@ -58,7 +62,7 @@ commands:
5862 ├─ devices [--all] list current notification devices5963 └─ revoke <device-id> [--yes] revoke a notification device6064 61-targets: 2a3f | work:demo | demo (alias in default vault)65+targets: 2a3f | work:demo | demo (alias in temporary default vault)6266 6367 agent mode: add --json to any command for one-line JSON on stdout;6468 errors go to stderr as {"ok":false,"error":…,"hint":…} with a suggested next step.@@ -88,6 +92,11 @@ export async function main(argv = Deno.args): Promise<void> {
8892 "password",8993 "name",9094 "description",95+ "default-ttl",96+ "default-for",97+ "framework",98+ "output",99+ "build-task",91100 "title",92101 "open",93102 "tag",@@ -102,6 +111,7 @@ export async function main(argv = Deno.args): Promise<void> {
102111 "json",103112 "no-description",104113 "all",114+ "no-default-for",105115 ],106116 alias: { h: "help", V: "version", y: "yes" },107117 });@@ -120,7 +130,7 @@ export async function main(argv = Deno.args): Promise<void> {
120130 121131 if (command === "auth") return await cmdAuth(args.server, args.token);122132 123- if (!["site", "status", "notify", "vault"].includes(command)) {133+ if (!["app", "site", "status", "notify", "vault"].includes(command)) {124134 fail(`unknown command: ${command}\n\n${HELP}`);125135 }126136 @@ -139,6 +149,12 @@ export async function main(argv = Deno.args): Promise<void> {
139149 toSeq,140150 list: args.list,141151 });152+ case "app":153+ return await cmdApp(config, rest, {154+ framework: args.framework,155+ output: args.output,156+ buildTask: args["build-task"],157+ });142158 case "status":143159 return await cmdStatus(config);144160 case "notify":@@ -151,11 +167,14 @@ export async function main(argv = Deno.args): Promise<void> {
151167 all: args.all,152168 });153169 case "vault":154- return await cmdVault(config, rest[0], rest[1], {170+ return await cmdVault(config, rest, {155171 slot,156172 newName: args.name,157173 description: args.description,158174 clearDescription: args["no-description"],175+ defaultTtl: args["default-ttl"],176+ defaultFor: args["default-for"],177+ clearDefaultFor: args["no-default-for"],159178 });160179 default:161180 fail(`unknown command: ${command}\n\n${HELP}`);@@ -6,7 +6,7 @@
66 "fmt:check": "deno fmt --check cli shared",77 "lint": "deno lint cli shared",88 "doc": "deno doc --lint cli/main.ts shared/mod.ts",9- "test": "deno test --allow-read --allow-write cli shared"9+ "test": "deno test --allow-read --allow-write --allow-run=deno cli shared"1010 },1111 "fmt": { "lineWidth": 100 }1212 }@@ -5,7 +5,7 @@
55 "packages": {66 "": {77 "devDependencies": {8- "wrangler": "^4.110.0"8+ "wrangler": "^4.112.0"99 }1010 },1111 "node_modules/@cloudflare/kv-asset-handler": {@@ -35,9 +35,9 @@
3535 }3636 },3737 "node_modules/@cloudflare/workerd-darwin-64": {38- "version": "1.20260708.1",39- "resolved": "https://registry.npmjs.org/@cloudflare/workerd-darwin-64/-/workerd-darwin-64-1.20260708.1.tgz",40- "integrity": "sha512-HXFCvhS1wpg3uXO0CLUwmwC41i2loM5FSK69EUchOBpmYBAXxT1oHLm6EOA5lqhTk5Mu9kjRiQYxa1GwKPwfJg==",38+ "version": "1.20260714.1",39+ "resolved": "https://registry.npmjs.org/@cloudflare/workerd-darwin-64/-/workerd-darwin-64-1.20260714.1.tgz",40+ "integrity": "sha512-ZWXqAN8G7Cx9hMRQuk+59ziJhR3j1F4iO+Qs8aHdfKZ3Dq5Yi/57xvkJTgCGBnW1YU/L78r8f6HEy51bwbTpNw==",4141 "cpu": [4242 "x64"4343 ],@@ -52,9 +52,9 @@
5252 }5353 },5454 "node_modules/@cloudflare/workerd-darwin-arm64": {55- "version": "1.20260708.1",56- "resolved": "https://registry.npmjs.org/@cloudflare/workerd-darwin-arm64/-/workerd-darwin-arm64-1.20260708.1.tgz",57- "integrity": "sha512-JVlJaKDoRTVKSroHIlf8g3UCPjKj4iDbMZE2CNYht5qQ+2rL0FAUiVlV82G3BqKnnw9kHYnnsMzC08b9zVtdzA==",55+ "version": "1.20260714.1",56+ "resolved": "https://registry.npmjs.org/@cloudflare/workerd-darwin-arm64/-/workerd-darwin-arm64-1.20260714.1.tgz",57+ "integrity": "sha512-tueWxWC3wyCbMG6zRAxsMXX0YLgrRWbiAPYFQ2uJ7dUH8G+5E7UTWaQS9B1HdJ0bpKFW1NWxhs1o2noKVFSUYg==",5858 "cpu": [5959 "arm64"6060 ],@@ -69,9 +69,9 @@
6969 }7070 },7171 "node_modules/@cloudflare/workerd-linux-64": {72- "version": "1.20260708.1",73- "resolved": "https://registry.npmjs.org/@cloudflare/workerd-linux-64/-/workerd-linux-64-1.20260708.1.tgz",74- "integrity": "sha512-3daE60YdD7YX0Jtuzc9DE/r/qMkmx8ZvHTkF8Mzmp3F5tbzlV0DAzmu5PFUPF2WuvtKbAhZKbvC2cHmWpQYxnA==",72+ "version": "1.20260714.1",73+ "resolved": "https://registry.npmjs.org/@cloudflare/workerd-linux-64/-/workerd-linux-64-1.20260714.1.tgz",74+ "integrity": "sha512-1VChTZRb0l0F7R4e1G5RtLKV4oFi6x+rQgxh2+yu887j3l/3TLgatuv1L8/5zhc9gKEhATTxOh0e52Rtd9dDWQ==",7575 "cpu": [7676 "x64"7777 ],@@ -86,9 +86,9 @@
8686 }8787 },8888 "node_modules/@cloudflare/workerd-linux-arm64": {89- "version": "1.20260708.1",90- "resolved": "https://registry.npmjs.org/@cloudflare/workerd-linux-arm64/-/workerd-linux-arm64-1.20260708.1.tgz",91- "integrity": "sha512-VLdNYOx5Hj+9C6isy0ACWZsbMtSxex2DIJWEe7cZxUdlphZ58ZT8zxNXK8yunFiowd34hn3VwGMopdvdj8lvmA==",89+ "version": "1.20260714.1",90+ "resolved": "https://registry.npmjs.org/@cloudflare/workerd-linux-arm64/-/workerd-linux-arm64-1.20260714.1.tgz",91+ "integrity": "sha512-rMm3G+NirG2UdgHIRDdF1asNC6FqgIzZzkRG+VDhhDGcVxAQwvrMT1E38BivEvHr3G04MB4AfhcOczX0+GtRkQ==",9292 "cpu": [9393 "arm64"9494 ],@@ -103,9 +103,9 @@
103103 }104104 },105105 "node_modules/@cloudflare/workerd-windows-64": {106- "version": "1.20260708.1",107- "resolved": "https://registry.npmjs.org/@cloudflare/workerd-windows-64/-/workerd-windows-64-1.20260708.1.tgz",108- "integrity": "sha512-bC/aSAwLy16Vjo24i9XU3aWH+eRgz7NeR5xPKavGbembO18ZywYTQbXh14eXtY6fAqN3RzRG8psijTdhX4xydA==",106+ "version": "1.20260714.1",107+ "resolved": "https://registry.npmjs.org/@cloudflare/workerd-windows-64/-/workerd-windows-64-1.20260714.1.tgz",108+ "integrity": "sha512-cGqnU3Hg2YZS/k3SAqrMp1DjpdsyFde72tWltdl6ZT9+SFz/Zrk/8gyTU1TcxC4YApXeNVH5TyU5cOGPgUJ0pg==",109109 "cpu": [110110 "x64"111111 ],@@ -1309,16 +1309,16 @@
13091309 }13101310 },13111311 "node_modules/miniflare": {1312- "version": "4.20260708.1",1313- "resolved": "https://registry.npmjs.org/miniflare/-/miniflare-4.20260708.1.tgz",1314- "integrity": "sha512-c94O9zRDISdqO18EHt6l0iF/fWgWt8p18PJvRsA/L/NJZ9Cfke3s/F5Blg1XXF7WDutVRzWVWy8Vy4LaT5ifsA==",1312+ "version": "4.20260714.0",1313+ "resolved": "https://registry.npmjs.org/miniflare/-/miniflare-4.20260714.0.tgz",1314+ "integrity": "sha512-MYlTCLdWCPqvrYY2uLwOjXwmglXuiHE3TGGkbOW4BwjUPa1r07E0iuHwrNDIs/sxK21r+o90Jx58AV2KeNdJZw==",13151315 "dev": true,13161316 "license": "MIT",13171317 "dependencies": {13181318 "@cspotcode/source-map-support": "0.8.1",13191319 "sharp": "0.34.5",13201320 "undici": "7.28.0",1321- "workerd": "1.20260708.1",1321+ "workerd": "1.20260714.1",13221322 "ws": "8.21.0",13231323 "youch": "4.1.0-beta.10"13241324 },@@ -1443,9 +1443,9 @@
14431443 }14441444 },14451445 "node_modules/workerd": {1446- "version": "1.20260708.1",1447- "resolved": "https://registry.npmjs.org/workerd/-/workerd-1.20260708.1.tgz",1448- "integrity": "sha512-WAK+Kt/VVCSldH2qSr8lx46XCJ4Q+bdlHNaFqUtOHthBEIB8C1N8HVW+VOLrxDoTCk0NGNv0zajnBeQK4JOB9w==",1446+ "version": "1.20260714.1",1447+ "resolved": "https://registry.npmjs.org/workerd/-/workerd-1.20260714.1.tgz",1448+ "integrity": "sha512-oIbQzfdyl9UQUnG6XLegcSq0Mgt/7WKDbFOoqGgOWCS+/fhyGB460uKEgdAQQ9RHCO/ttcNCX/KiMIQzdoeu3Q==",14491449 "dev": true,14501450 "hasInstallScript": true,14511451 "license": "Apache-2.0",@@ -1456,17 +1456,17 @@
14561456 "node": ">=16"14571457 },14581458 "optionalDependencies": {1459- "@cloudflare/workerd-darwin-64": "1.20260708.1",1460- "@cloudflare/workerd-darwin-arm64": "1.20260708.1",1461- "@cloudflare/workerd-linux-64": "1.20260708.1",1462- "@cloudflare/workerd-linux-arm64": "1.20260708.1",1463- "@cloudflare/workerd-windows-64": "1.20260708.1"1459+ "@cloudflare/workerd-darwin-64": "1.20260714.1",1460+ "@cloudflare/workerd-darwin-arm64": "1.20260714.1",1461+ "@cloudflare/workerd-linux-64": "1.20260714.1",1462+ "@cloudflare/workerd-linux-arm64": "1.20260714.1",1463+ "@cloudflare/workerd-windows-64": "1.20260714.1"14641464 }14651465 },14661466 "node_modules/wrangler": {1467- "version": "4.110.0",1468- "resolved": "https://registry.npmjs.org/wrangler/-/wrangler-4.110.0.tgz",1469- "integrity": "sha512-xZeXKYi7hxQRF5anL+v77RkufJNpF9f3Eqeyqq2QBsETpLZgh0Agj0jJ6JPtkbgn6ukZdh8OK5egsGPWIditgg==",1467+ "version": "4.112.0",1468+ "resolved": "https://registry.npmjs.org/wrangler/-/wrangler-4.112.0.tgz",1469+ "integrity": "sha512-5H+XUD0TySCv1LuktFHDIEOkboH2nTfQs+35L+USt3MtntjDTMVIJprLgQcL2WBjulOyjxpd1vyTiSTJVW5MjQ==",14701470 "dev": true,14711471 "license": "MIT OR Apache-2.0",14721472 "dependencies": {@@ -1474,10 +1474,10 @@
14741474 "@cloudflare/unenv-preset": "2.16.1",14751475 "blake3-wasm": "2.1.5",14761476 "esbuild": "0.28.1",1477- "miniflare": "4.20260708.1",1477+ "miniflare": "4.20260714.0",14781478 "path-to-regexp": "6.3.0",14791479 "unenv": "2.0.0-rc.24",1480- "workerd": "1.20260708.1"1480+ "workerd": "1.20260714.1"14811481 },14821482 "bin": {14831483 "cf-wrangler": "bin/cf-wrangler.js",@@ -1491,7 +1491,7 @@
14911491 "fsevents": "2.3.3"14921492 },14931493 "peerDependencies": {1494- "@cloudflare/workers-types": "^5.20260708.1"1494+ "@cloudflare/workers-types": "^5.20260714.1"14951495 },14961496 "peerDependenciesMeta": {14971497 "@cloudflare/workers-types": {@@ -10,6 +10,11 @@ over the API. Canonical JSON serialization lives in exactly one place
1010 This package uses only Web-standard APIs (`crypto.subtle`, `TextEncoder`/`TextDecoder`, `JSON`), so1111 the same code runs under Deno, Cloudflare Workers, Node.js, Bun, and browsers.1212 13+The wire contract also defines lifecycle-aware vault metadata (`defaultVaults`, per-vault14+`defaultTtl`/`effectiveDefaultTtl`/`defaultFor`), resolved push retention (`ttl` and `ttlSource`),15+and permanent app-origin reservations. These fields let agents inspect policy before mutating16+hosting state instead of inferring defaults from names.17+1318 ## Install1419 1520 ```sh@@ -12,3 +12,6 @@ export const MAX_UNIQUE_BLOBS = 900;
1212 * Both the CLI and Worker enforce this before retaining or buffering a body.1313 */1414 export const MAX_BLOB_BYTES = 50 * 1024 * 1024;15+16+/** Fallback lifetime in days for new sites whose vault does not define one. */17+export const GLOBAL_DEFAULT_TTL = 14;@@ -44,6 +44,23 @@ export type Target =
4444 alias?: string | null;4545 };4646 47+/** Supported site lifetimes. A number is a lifetime in days. */48+export type Ttl = number | "forever";49+50+/** Why a commit received its resolved lifetime. */51+export type TtlSource = "explicit" | "existing-site" | "vault" | "global";52+53+/** Lifecycle intent used to select a default vault. */54+export type VaultLifecycle = "temporary" | "permanent";55+56+/** Server-owned default vault names for each lifecycle intent. */57+export interface DefaultVaults {58+ /** Vault selected for ordinary, finite-lifetime sharing. */59+ temporary: string | null;60+ /** Vault selected for durable application origins. */61+ permanent: string | null;62+}63+4764 /** Body of `POST /api/push/prepare`: the manifest the client wants to push. */4865 export interface PrepareRequest {4966 /** Complete manifest whose referenced blob availability should be checked. */@@ -65,9 +82,14 @@ export interface CommitRequest {
6582 /** Address or vault destination that should receive the new version. */6683 target: Target;6784 /** Lifetime in days, `"forever"` for no expiry, or omitted to preserve an existing value (14 days for a new site). */68- ttl?: number | "forever";85+ ttl?: Ttl;6986 /** Password to set, `null` to clear it, or omitted to preserve an existing value. */7087 password?: string | null;88+ /** App-only response policy recorded when deploying to a reserved app origin. */89+ app?: {90+ /** Content-Security-Policy emitted by the lofi build, if present. */91+ contentSecurityPolicy?: string;92+ };7193 }7294 7395 /** Reply from `POST /api/push/commit` identifying the published site revision. */@@ -82,6 +104,10 @@ export interface CommitResponse {
82104 manifestHash: string;83105 /** Unix expiry timestamp in seconds, or `null` for a permanent site. */84106 expiresAt: number | null;107+ /** Resolved lifetime in days, `"forever"`, or zero when an existing site is already expired. */108+ ttl: Ttl;109+ /** Policy layer that supplied the resolved lifetime. */110+ ttlSource: TtlSource;85111 /** Whether visitors must unlock the committed site with a password. */86112 protected: boolean;87113 /** Monotonically increasing push sequence number within this site. */@@ -153,6 +179,12 @@ export interface VaultInfo {
153179 createdAt: number;154180 /** Number of currently live sites assigned to this vault. */155181 siteCount: number;182+ /** Vault-specific lifetime, or `null` to inherit the global fallback. */183+ defaultTtl: Ttl | null;184+ /** Lifetime new sites receive after applying the global fallback. */185+ effectiveDefaultTtl: Ttl;186+ /** Lifecycle intents for which this vault is the server default. */187+ defaultFor: VaultLifecycle[];156188 }157189 158190 /** Reply to `GET /api/status`: server version and a snapshot of vaults and sites. */@@ -161,6 +193,10 @@ export interface StatusResponse {
161193 ok: true;162194 /** Semantic version reported by the running nzip Worker. */163195 version: string;196+ /** Server-owned default vault names, selected by lifecycle intent. */197+ defaultVaults: DefaultVaults;198+ /** Fallback lifetime in days when a vault does not define one. */199+ globalDefaultTtl: number;164200 /** All registered vaults and their current site counts. */165201 vaults: VaultInfo[];166202 /** Total number of currently registered sites across every vault. */@@ -178,11 +214,33 @@ export interface RevertRequest {
178214 /** Body of a site PATCH. Omitted access-policy fields remain unchanged. */179215 export interface PatchSiteRequest {180216 /** New lifetime in days, or `"forever"`; omission preserves the current expiry. */181- ttl?: number | "forever";217+ ttl?: Ttl;182218 /** Password to set, `null` to clear it, or omitted to leave it unchanged. */183219 password?: string | null;184220 }185221 222+/** Body of `POST /api/apps`: reserve a permanent browser origin before deployment. */223+export interface AppInitRequest {224+ /** Named app destination. App reservations require an alias. */225+ target: { vault: string; alias: string };226+}227+228+/** Stable app-origin reservation returned by the management API. */229+export interface AppReservationInfo {230+ /** Four-character lowercase hexadecimal address. */231+ address: string;232+ /** Vault that owns the reservation. */233+ vault: string;234+ /** Alias permanently associated with this reservation. */235+ alias: string;236+ /** Absolute app URL on its isolated hostname. */237+ url: string;238+ /** Unix timestamp when this origin was first reserved. */239+ createdAt: number;240+ /** Whether site content currently exists at the reserved origin. */241+ deployed: boolean;242+}243+186244 /** Standard error envelope returned by the API on failure. */187245 export interface ApiError {188246 /** Human-readable error message safe to show to the authenticated client. */@@ -64,7 +64,7 @@ Run checks in this order and stop at the first failure that needs user input:
6464 newer commands.6565 3. `nzip --help` — confirm the installed command starts and inspect its supported command surface.6666 4. `nzip status --json` — verify saved authentication, server reachability, and token validity.67-5. `nzip vault ls --json` — verify available vaults and the default vault.67+5. `nzip vault ls --json` — verify available vaults, `defaultVaults`, and effective TTLs.6868 6969 Use the JSON `error` and `hint` fields as the primary diagnosis. Re-run `nzip auth` for missing or7070 rejected credentials, correct the configured server for reachability failures, and ask the user to@@ -84,8 +84,8 @@ permissions when a sandbox blocks a check instead of reporting nzip as broken.
8484 8585 Use `1d` for plans, designs, review pages, and other disposable agent artifacts unless the user8686 requests a different review window. Choose a longer finite TTL only when the requested workflow87- plainly needs it. Do not use `forever` by default. The nzip service default is 14 days, so always88- pass `--ttl 1d` for these short-lived artifacts rather than relying on omission.87+ plainly needs it. Do not use `forever` by default. Vault defaults may vary, so always pass88+ `--ttl 1d` for these short-lived artifacts rather than relying on omission.8989 9090 4. Use an explicit `<vault>:<alias>` target when the destination matters, picking a vault from9191 `nzip vault ls --json`:@@ -108,11 +108,14 @@ nzip
108108 ├─ --version [--json]109109 ├─ auth [--server URL] [--token T]110110 ├─ status111+├─ app112+│ ├─ init <alias|vault:alias>113+│ └─ deploy111114 ├─ vault112-│ ├─ add <name> [--slot N] [--description TEXT]113-│ ├─ update <name> [--name NEW_NAME] [--description TEXT | --no-description]115+│ ├─ add <name> [--slot N] [--default-ttl 14d|forever|inherit]116+│ ├─ update <name> [--default-ttl 14d|forever|inherit]114117 │ ├─ ls115-│ └─ default <name>118+│ └─ default <temporary|permanent> <name>116119 ├─ site117120 │ ├─ push <dir|file> [target] [--ttl DAYS] [--password PW | --no-password]118121 │ ├─ cp <target> [dir] [--overwrite]@@ -138,6 +141,9 @@ change that setting. Vault descriptions appear in `vault ls` and `status`; use t
138141 right destination, and pass `--no-description` to clear one. A vault rename updates the invoking139142 client's default vault, allow-list, and local push records, but other clients keep the old name;140143 renaming to a name outside `allowVaults` is refused.144+For new sites, TTL precedence is explicit flag, existing-site expiry, vault default, then the145+global 14-day fallback. Read `ttl` and `ttlSource` from JSON results instead of inferring them.146+`public` describes durable retention, not access policy; it may still be password-protected.141147 Treat `revert`, vault renames, password changes, TTL changes, and deletion as state changes; do not142148 perform them while merely diagnosing. Never infer deletion confirmation.143149 @@ -167,6 +173,24 @@ short-lived but is not sufficient on its own; approval still uses the configured
167173 If the installed app loses its pairing cookie, instruct the user to remove it, pair again in the168174 browser, wait for `paired`, and reinstall it. Revoke a device only on explicit request.169175 176+## Host a lofi app177+178+Use this workflow only when the user asks to establish or deploy a durable application origin:179+180+1. Run `nzip app init <alias|vault:alias>`. Omitted vault selection uses181+ `defaultVaults.permanent`. This reserves the final URL before content exists and writes a182+ token-free `nzip.app.json` that may be committed.183+2. Add the printed exact origin to the lofi app's `src/app.ts` `credentialOrigins`. If the app uses184+ an explicit passkey RP ID, pin it to the printed hostname.185+3. Run `nzip app deploy`. It runs the configured Deno build at `/`, validates lofi PWA identity and186+ output, mirrors the generated CSP, and publishes to the reservation.187+4. Report the canonical URL plus the returned `ttl` and `ttlSource`.188+189+Never replace an existing `nzip.app.json` with a different target. App addresses are permanent190+identity tombstones: removing or expiring content does not make the origin reusable. Keep sync-node191+configuration out of `nzip.app.json`, logs, and built artifacts; lofi-node remains an orthogonal192+application concern.193+170194 ## Sane defaults171195 172196 - Keep generated HTML in `.nzip-<purpose>/`, not a system temp directory or a tracked source@@ -174,6 +198,7 @@ browser, wait for `paired`, and reinstall it. Revoke a device only on explicit r
174198 - Ignore all such directories with `.nzip-*/`.175199 - Publish disposable plans and designs with `--ttl 1d`.176200 - Prefer finite TTLs; use `forever` only when explicitly requested.201+- For durable lofi apps, use `app init`/`app deploy`; do not emulate the reservation with `site push`.177202 - Prefer descriptive aliases and an explicit vault when identity or audience matters.178203 - Preserve the local artifact directory for iteration and `nzip site where`; do not commit it.179204 - Use complete, self-contained HTML and relative asset paths.@@ -0,0 +1,37 @@
1+-- Add lifecycle-aware retention defaults without changing existing site expiry.2+ALTER TABLE vaults ADD COLUMN default_ttl INTEGER3+ CHECK (default_ttl IS NULL OR default_ttl BETWEEN 0 AND 3650);4+5+-- Enrich the conventional slots only when they already have the conventional6+-- names. Occupied/custom slots and existing names elsewhere are left alone.7+UPDATE vaults SET default_ttl = 148+WHERE slot = 0 AND name = 'personal';9+UPDATE vaults SET default_ttl = 010+WHERE slot = 15 AND name = 'public';11+12+INSERT OR IGNORE INTO vaults (slot, name, description, default_ttl, created_at)13+VALUES (0, 'personal', 'Personal and temporary shares', 14, unixepoch());14+INSERT OR IGNORE INTO vaults (slot, name, description, default_ttl, created_at)15+VALUES (15, 'public', 'Durable public app origins', 0, unixepoch());16+17+CREATE TABLE IF NOT EXISTS vault_defaults (18+ lifecycle TEXT PRIMARY KEY CHECK (lifecycle IN ('temporary', 'permanent')),19+ vault_slot INTEGER NOT NULL REFERENCES vaults(slot)20+);21+INSERT OR IGNORE INTO vault_defaults (lifecycle, vault_slot)22+SELECT 'temporary', slot FROM vaults WHERE slot = 0 AND name = 'personal';23+INSERT OR IGNORE INTO vault_defaults (lifecycle, vault_slot)24+SELECT 'permanent', slot FROM vaults WHERE slot = 15 AND name = 'public';25+26+ALTER TABLE sites ADD COLUMN content_security_policy TEXT;27+28+-- This is deliberately separate from sites: deleting or expiring content must29+-- not make an app origin reusable by another application.30+CREATE TABLE IF NOT EXISTS app_reservations (31+ address INTEGER PRIMARY KEY CHECK (address BETWEEN 0 AND 65535),32+ vault_slot INTEGER NOT NULL REFERENCES vaults(slot),33+ alias TEXT NOT NULL,34+ created_at INTEGER NOT NULL,35+ retired_at INTEGER,36+ UNIQUE (vault_slot, alias)37+);@@ -10,12 +10,12 @@
1010 },1111 "devDependencies": {1212 "@cloudflare/vitest-pool-workers": "^0.18.4",13- "@cloudflare/workers-types": "^5.20260712.1",13+ "@cloudflare/workers-types": "^5.20260719.1",1414 "@types/node": "^26.1.1",1515 "@types/web-push": "^3.6.4",1616 "typescript": "^5.9.3",1717 "vitest": "^4.1.10",18- "wrangler": "^4.110.0"18+ "wrangler": "^4.112.0"1919 }2020 },2121 "node_modules/@cloudflare/kv-asset-handler": {@@ -63,6 +63,42 @@
6363 "vitest": "^4.1.0"6464 }6565 },66+ "node_modules/@cloudflare/vitest-pool-workers/node_modules/wrangler": {67+ "version": "4.110.0",68+ "resolved": "https://registry.npmjs.org/wrangler/-/wrangler-4.110.0.tgz",69+ "integrity": "sha512-xZeXKYi7hxQRF5anL+v77RkufJNpF9f3Eqeyqq2QBsETpLZgh0Agj0jJ6JPtkbgn6ukZdh8OK5egsGPWIditgg==",70+ "dev": true,71+ "license": "MIT OR Apache-2.0",72+ "dependencies": {73+ "@cloudflare/kv-asset-handler": "0.5.0",74+ "@cloudflare/unenv-preset": "2.16.1",75+ "blake3-wasm": "2.1.5",76+ "esbuild": "0.28.1",77+ "miniflare": "4.20260708.1",78+ "path-to-regexp": "6.3.0",79+ "unenv": "2.0.0-rc.24",80+ "workerd": "1.20260708.1"81+ },82+ "bin": {83+ "cf-wrangler": "bin/cf-wrangler.js",84+ "wrangler": "bin/wrangler.js",85+ "wrangler2": "bin/wrangler.js"86+ },87+ "engines": {88+ "node": ">=22.0.0"89+ },90+ "optionalDependencies": {91+ "fsevents": "2.3.3"92+ },93+ "peerDependencies": {94+ "@cloudflare/workers-types": "^5.20260708.1"95+ },96+ "peerDependenciesMeta": {97+ "@cloudflare/workers-types": {98+ "optional": true99+ }100+ }101+ },66102 "node_modules/@cloudflare/workerd-darwin-64": {67103 "version": "1.20260708.1",68104 "resolved": "https://registry.npmjs.org/@cloudflare/workerd-darwin-64/-/workerd-darwin-64-1.20260708.1.tgz",@@ -149,9 +185,9 @@
149185 }150186 },151187 "node_modules/@cloudflare/workers-types": {152- "version": "5.20260712.1",153- "resolved": "https://registry.npmjs.org/@cloudflare/workers-types/-/workers-types-5.20260712.1.tgz",154- "integrity": "sha512-tkQ18Lz41EPXLzh4cSuIGQzztDVueuGfTcjlP4M7Qa0rfHpVBNItf3GRkd9O0JgiXs9csAwz/kVv7sjYXSoF2w==",188+ "version": "5.20260719.1",189+ "resolved": "https://registry.npmjs.org/@cloudflare/workers-types/-/workers-types-5.20260719.1.tgz",190+ "integrity": "sha512-DcGasbfUuczQilc80vhL2MPPdWcaxWkx0hN5IW9UdNDBdrRvWcal3akSJ6Ccm7e8+/OGeR+8tYrqkykA3YGSZw==",155191 "dev": true,156192 "license": "MIT OR Apache-2.0"157193 },@@ -2876,9 +2912,9 @@
28762912 }28772913 },28782914 "node_modules/wrangler": {2879- "version": "4.110.0",2880- "resolved": "https://registry.npmjs.org/wrangler/-/wrangler-4.110.0.tgz",2881- "integrity": "sha512-xZeXKYi7hxQRF5anL+v77RkufJNpF9f3Eqeyqq2QBsETpLZgh0Agj0jJ6JPtkbgn6ukZdh8OK5egsGPWIditgg==",2915+ "version": "4.112.0",2916+ "resolved": "https://registry.npmjs.org/wrangler/-/wrangler-4.112.0.tgz",2917+ "integrity": "sha512-5H+XUD0TySCv1LuktFHDIEOkboH2nTfQs+35L+USt3MtntjDTMVIJprLgQcL2WBjulOyjxpd1vyTiSTJVW5MjQ==",28822918 "dev": true,28832919 "license": "MIT OR Apache-2.0",28842920 "dependencies": {@@ -2886,10 +2922,10 @@
28862922 "@cloudflare/unenv-preset": "2.16.1",28872923 "blake3-wasm": "2.1.5",28882924 "esbuild": "0.28.1",2889- "miniflare": "4.20260708.1",2925+ "miniflare": "4.20260714.0",28902926 "path-to-regexp": "6.3.0",28912927 "unenv": "2.0.0-rc.24",2892- "workerd": "1.20260708.1"2928+ "workerd": "1.20260714.1"28932929 },28942930 "bin": {28952931 "cf-wrangler": "bin/cf-wrangler.js",@@ -2903,7 +2939,7 @@
29032939 "fsevents": "2.3.3"29042940 },29052941 "peerDependencies": {2906- "@cloudflare/workers-types": "^5.20260708.1"2942+ "@cloudflare/workers-types": "^5.20260714.1"29072943 },29082944 "peerDependenciesMeta": {29092945 "@cloudflare/workers-types": {@@ -2911,6 +2947,133 @@
29112947 }29122948 }29132949 },2950+ "node_modules/wrangler/node_modules/@cloudflare/workerd-darwin-64": {2951+ "version": "1.20260714.1",2952+ "resolved": "https://registry.npmjs.org/@cloudflare/workerd-darwin-64/-/workerd-darwin-64-1.20260714.1.tgz",2953+ "integrity": "sha512-ZWXqAN8G7Cx9hMRQuk+59ziJhR3j1F4iO+Qs8aHdfKZ3Dq5Yi/57xvkJTgCGBnW1YU/L78r8f6HEy51bwbTpNw==",2954+ "cpu": [2955+ "x64"2956+ ],2957+ "dev": true,2958+ "license": "Apache-2.0",2959+ "optional": true,2960+ "os": [2961+ "darwin"2962+ ],2963+ "engines": {2964+ "node": ">=16"2965+ }2966+ },2967+ "node_modules/wrangler/node_modules/@cloudflare/workerd-darwin-arm64": {2968+ "version": "1.20260714.1",2969+ "resolved": "https://registry.npmjs.org/@cloudflare/workerd-darwin-arm64/-/workerd-darwin-arm64-1.20260714.1.tgz",2970+ "integrity": "sha512-tueWxWC3wyCbMG6zRAxsMXX0YLgrRWbiAPYFQ2uJ7dUH8G+5E7UTWaQS9B1HdJ0bpKFW1NWxhs1o2noKVFSUYg==",2971+ "cpu": [2972+ "arm64"2973+ ],2974+ "dev": true,2975+ "license": "Apache-2.0",2976+ "optional": true,2977+ "os": [2978+ "darwin"2979+ ],2980+ "engines": {2981+ "node": ">=16"2982+ }2983+ },2984+ "node_modules/wrangler/node_modules/@cloudflare/workerd-linux-64": {2985+ "version": "1.20260714.1",2986+ "resolved": "https://registry.npmjs.org/@cloudflare/workerd-linux-64/-/workerd-linux-64-1.20260714.1.tgz",2987+ "integrity": "sha512-1VChTZRb0l0F7R4e1G5RtLKV4oFi6x+rQgxh2+yu887j3l/3TLgatuv1L8/5zhc9gKEhATTxOh0e52Rtd9dDWQ==",2988+ "cpu": [2989+ "x64"2990+ ],2991+ "dev": true,2992+ "license": "Apache-2.0",2993+ "optional": true,2994+ "os": [2995+ "linux"2996+ ],2997+ "engines": {2998+ "node": ">=16"2999+ }3000+ },3001+ "node_modules/wrangler/node_modules/@cloudflare/workerd-linux-arm64": {3002+ "version": "1.20260714.1",3003+ "resolved": "https://registry.npmjs.org/@cloudflare/workerd-linux-arm64/-/workerd-linux-arm64-1.20260714.1.tgz",3004+ "integrity": "sha512-rMm3G+NirG2UdgHIRDdF1asNC6FqgIzZzkRG+VDhhDGcVxAQwvrMT1E38BivEvHr3G04MB4AfhcOczX0+GtRkQ==",3005+ "cpu": [3006+ "arm64"3007+ ],3008+ "dev": true,3009+ "license": "Apache-2.0",3010+ "optional": true,3011+ "os": [3012+ "linux"3013+ ],3014+ "engines": {3015+ "node": ">=16"3016+ }3017+ },3018+ "node_modules/wrangler/node_modules/@cloudflare/workerd-windows-64": {3019+ "version": "1.20260714.1",3020+ "resolved": "https://registry.npmjs.org/@cloudflare/workerd-windows-64/-/workerd-windows-64-1.20260714.1.tgz",3021+ "integrity": "sha512-cGqnU3Hg2YZS/k3SAqrMp1DjpdsyFde72tWltdl6ZT9+SFz/Zrk/8gyTU1TcxC4YApXeNVH5TyU5cOGPgUJ0pg==",3022+ "cpu": [3023+ "x64"3024+ ],3025+ "dev": true,3026+ "license": "Apache-2.0",3027+ "optional": true,3028+ "os": [3029+ "win32"3030+ ],3031+ "engines": {3032+ "node": ">=16"3033+ }3034+ },3035+ "node_modules/wrangler/node_modules/miniflare": {3036+ "version": "4.20260714.0",3037+ "resolved": "https://registry.npmjs.org/miniflare/-/miniflare-4.20260714.0.tgz",3038+ "integrity": "sha512-MYlTCLdWCPqvrYY2uLwOjXwmglXuiHE3TGGkbOW4BwjUPa1r07E0iuHwrNDIs/sxK21r+o90Jx58AV2KeNdJZw==",3039+ "dev": true,3040+ "license": "MIT",3041+ "dependencies": {3042+ "@cspotcode/source-map-support": "0.8.1",3043+ "sharp": "0.34.5",3044+ "undici": "7.28.0",3045+ "workerd": "1.20260714.1",3046+ "ws": "8.21.0",3047+ "youch": "4.1.0-beta.10"3048+ },3049+ "bin": {3050+ "miniflare": "bootstrap.js"3051+ },3052+ "engines": {3053+ "node": ">=22.0.0"3054+ }3055+ },3056+ "node_modules/wrangler/node_modules/workerd": {3057+ "version": "1.20260714.1",3058+ "resolved": "https://registry.npmjs.org/workerd/-/workerd-1.20260714.1.tgz",3059+ "integrity": "sha512-oIbQzfdyl9UQUnG6XLegcSq0Mgt/7WKDbFOoqGgOWCS+/fhyGB460uKEgdAQQ9RHCO/ttcNCX/KiMIQzdoeu3Q==",3060+ "dev": true,3061+ "hasInstallScript": true,3062+ "license": "Apache-2.0",3063+ "bin": {3064+ "workerd": "bin/workerd"3065+ },3066+ "engines": {3067+ "node": ">=16"3068+ },3069+ "optionalDependencies": {3070+ "@cloudflare/workerd-darwin-64": "1.20260714.1",3071+ "@cloudflare/workerd-darwin-arm64": "1.20260714.1",3072+ "@cloudflare/workerd-linux-64": "1.20260714.1",3073+ "@cloudflare/workerd-linux-arm64": "1.20260714.1",3074+ "@cloudflare/workerd-windows-64": "1.20260714.1"3075+ }3076+ },29143077 "node_modules/ws": {29153078 "version": "8.21.0",29163079 "resolved": "https://registry.npmjs.org/ws/-/ws-8.21.0.tgz",@@ -15,12 +15,12 @@
1515 },1616 "devDependencies": {1717 "@cloudflare/vitest-pool-workers": "^0.18.4",18- "@cloudflare/workers-types": "^5.20260712.1",18+ "@cloudflare/workers-types": "^5.20260719.1",1919 "@types/node": "^26.1.1",2020 "@types/web-push": "^3.6.4",2121 "typescript": "^5.9.3",2222 "vitest": "^4.1.10",23- "wrangler": "^4.110.0"23+ "wrangler": "^4.112.0"2424 },2525 "dependencies": {2626 "web-push": "^3.6.7"@@ -5,9 +5,24 @@ CREATE TABLE IF NOT EXISTS vaults (
55 slot INTEGER PRIMARY KEY CHECK (slot BETWEEN 0 AND 15),66 name TEXT NOT NULL UNIQUE,77 description TEXT,8+ default_ttl INTEGER CHECK (default_ttl IS NULL OR default_ttl BETWEEN 0 AND 3650),89 created_at INTEGER NOT NULL910 );1011 12+INSERT OR IGNORE INTO vaults (slot, name, description, default_ttl, created_at)13+VALUES (0, 'personal', 'Personal and temporary shares', 14, unixepoch());14+INSERT OR IGNORE INTO vaults (slot, name, description, default_ttl, created_at)15+VALUES (15, 'public', 'Durable public app origins', 0, unixepoch());16+17+CREATE TABLE IF NOT EXISTS vault_defaults (18+ lifecycle TEXT PRIMARY KEY CHECK (lifecycle IN ('temporary', 'permanent')),19+ vault_slot INTEGER NOT NULL REFERENCES vaults(slot)20+);21+INSERT OR IGNORE INTO vault_defaults (lifecycle, vault_slot)22+SELECT 'temporary', slot FROM vaults WHERE slot = 0 AND name = 'personal';23+INSERT OR IGNORE INTO vault_defaults (lifecycle, vault_slot)24+SELECT 'permanent', slot FROM vaults WHERE slot = 15 AND name = 'public';25+1126 CREATE TABLE IF NOT EXISTS sites (1227 address INTEGER PRIMARY KEY CHECK (address BETWEEN 0 AND 65535),1328 vault_slot INTEGER NOT NULL REFERENCES vaults(slot),@@ -17,11 +32,23 @@ CREATE TABLE IF NOT EXISTS sites (
1732 updated_at INTEGER NOT NULL,1833 expires_at INTEGER, -- unix seconds; NULL = permanent1934 password_hash TEXT, -- PBKDF2 password verifier; NULL = public35+ content_security_policy TEXT, -- app build CSP mirrored as a response header2036 auth_version INTEGER NOT NULL DEFAULT 1, -- increment to revoke unlock cookies2137 UNIQUE (vault_slot, alias)2238 );2339 CREATE INDEX IF NOT EXISTS idx_sites_expiry ON sites(expires_at) WHERE expires_at IS NOT NULL;2440 41+-- App addresses are permanent identity tombstones. Content may disappear from42+-- sites, but a reservation row is never deleted or returned to the allocator.43+CREATE TABLE IF NOT EXISTS app_reservations (44+ address INTEGER PRIMARY KEY CHECK (address BETWEEN 0 AND 65535),45+ vault_slot INTEGER NOT NULL REFERENCES vaults(slot),46+ alias TEXT NOT NULL,47+ created_at INTEGER NOT NULL,48+ retired_at INTEGER,49+ UNIQUE (vault_slot, alias)50+);51+2552 CREATE TABLE IF NOT EXISTS pushes (2653 address INTEGER NOT NULL REFERENCES sites(address) ON DELETE CASCADE,2754 seq INTEGER NOT NULL,@@ -177,8 +177,9 @@ Click the verification link Cloudflare sends, then configure:
177177 178178 Apply `migrations/0002_security_alerts.sql`,179179 `migrations/0003_security_notification_outbox.sql`,180-`migrations/0004_vault_descriptions.sql`, and181-`migrations/0005_notifications.sql` before deploying an upgraded Worker. After180+`migrations/0004_vault_descriptions.sql`, `migrations/0005_notifications.sql`,181+`migrations/0006_notification_pairing_window.sql`, and182+`migrations/0007_vault_defaults_and_app_origins.sql` before deploying an upgraded Worker. After182183 deployment, send a delivery test through the owner-authenticated endpoint:183184 184185 ```sh@@ -225,6 +226,7 @@ npx wrangler d1 execute nzip --remote --file migrations/0003_security_notificati
225226 npx wrangler d1 execute nzip --remote --file migrations/0004_vault_descriptions.sql226227 npx wrangler d1 execute nzip --remote --file migrations/0005_notifications.sql227228 npx wrangler d1 execute nzip --remote --file migrations/0006_notification_pairing_window.sql229+npx wrangler d1 execute nzip --remote --file migrations/0007_vault_defaults_and_app_origins.sql228230 ```229231 230232 ## Owner notifications@@ -259,6 +261,9 @@ npx wrangler d1 execute nzip --remote \
259261 npx wrangler d1 execute nzip --remote \260262 --config wrangler.local.jsonc \261263 --file migrations/0006_notification_pairing_window.sql264+npx wrangler d1 execute nzip --remote \265+ --config wrangler.local.jsonc \266+ --file migrations/0007_vault_defaults_and_app_origins.sql262267 npx wrangler deploy --dry-run --config wrangler.local.jsonc263268 npx wrangler deploy --config wrangler.local.jsonc264269 ```@@ -2,9 +2,10 @@ import type { Env } from "./env.ts";
22 import { json } from "./env.ts";33 import { ApiError, apiErrorResponse } from "./api/errors.ts";44 import { handleBlobPut, handleCommit, handlePrepare } from "./api/push.ts";5+import { handleAppInit } from "./api/apps.ts";56 import { handleSite, handleSiteList } from "./api/sites.ts";67 import { handleStatus } from "./api/status.ts";7-import { handleVault, handleVaults } from "./api/vaults.ts";8+import { handleDefaultVault, handleVault, handleVaults } from "./api/vaults.ts";89 import { sendAlertTest } from "./security_alerts.ts";910 import { handleNotifySend } from "./notify.ts";1011 import {@@ -64,6 +65,9 @@ export async function api(
6465 if (parts[1] === "push" && parts[2] === "prepare" && method === "POST") {6566 return await handlePrepare(request, env);6667 }68+ if (parts[1] === "apps" && parts.length === 2 && method === "POST") {69+ return await handleAppInit(request, env);70+ }6771 if (parts[1] === "push" && parts[2] === "commit" && method === "POST") {6872 return await handleCommit(request, env, ctx);6973 }@@ -88,6 +92,11 @@ export async function api(
8892 if (parts[1] === "vaults" && parts.length === 3 && method === "PATCH") {8993 return await handleVault(request, env, parts[2]);9094 }95+ if (96+ parts[1] === "default-vaults" && parts.length === 3 && method === "PUT"97+ ) {98+ return await handleDefaultVault(request, env, parts[2]);99+ }91100 if (parts[1] === "sites" && parts.length === 2 && method === "GET") {92101 return await handleSiteList(url, env);93102 }@@ -0,0 +1,29 @@
1+import { isValidName } from "../../../shared/mod.ts";2+import type { AppInitRequest, AppReservationInfo } from "../../../shared/mod.ts";3+import {4+ appReservationToInfo,5+ getAppReservationByAlias,6+ getVaultByName,7+ reserveAppOrigin,8+} from "../db.ts";9+import { type Env, json } from "../env.ts";10+import { ApiError, readJson } from "./errors.ts";11+12+/** Reserve an app hostname permanently before its first build exists. */13+export async function handleAppInit(14+ request: Request,15+ env: Env,16+): Promise<Response> {17+ const body = await readJson<AppInitRequest>(request);18+ const target = body.target;19+ if (!target || !isValidName(target.vault) || !isValidName(target.alias)) {20+ throw new ApiError(400, "app target must be vault:alias using valid names");21+ }22+ const vault = await getVaultByName(env, target.vault);23+ if (!vault) throw new ApiError(404, `unknown vault: ${target.vault}`);24+25+ const existed = await getAppReservationByAlias(env, vault.slot, target.alias);26+ const reservation = existed ?? await reserveAppOrigin(env, vault, target.alias);27+ const info = await appReservationToInfo(env, reservation, vault.name);28+ return json<AppReservationInfo>(info, existed ? 200 : 201);29+}@@ -1,21 +1,68 @@
1-import { parseManifest, parseTarget } from "../../../shared/mod.ts";2-import type { Manifest } from "../../../shared/mod.ts";1+import { GLOBAL_DEFAULT_TTL, parseManifest, parseTarget } from "../../../shared/mod.ts";2+import type { Manifest, Ttl, TtlSource } from "../../../shared/mod.ts";33 import { resolveTarget, type SiteRow } from "../db.ts";44 import type { Env } from "../env.ts";55 import { hashPassword } from "../password.ts";66 import { ApiError, clientInput } from "./errors.ts";77 88 export const HEX64 = /^[0-9a-f]{64}$/;9-109 export function ttlToExpiry(11- ttl: number | "forever" | undefined,10+ ttl: Ttl,11+ now = Math.floor(Date.now() / 1000),1212 ): number | null {1313 if (ttl === "forever") return null;14- const days = ttl ?? 14;15- if (!Number.isFinite(days) || days <= 0 || days > 3650) {14+ if (!Number.isFinite(ttl) || ttl <= 0 || ttl > 3650) {1615 throw new ApiError(400, 'ttl must be 1-3650 days or "forever"');1716 }18- return Math.floor(Date.now() / 1000) + Math.round(days * 86400);17+ return now + Math.round(ttl * 86400);18+}19+20+export function storedDefaultTtl(value: number | null): Ttl | null {21+ return value === null ? null : value === 0 ? "forever" : value;22+}23+24+export interface ResolvedTtl {25+ expiresAt: number | null;26+ ttl: Ttl;27+ ttlSource: TtlSource;28+}29+30+/** Apply explicit → existing-site → vault → global retention precedence. */31+export function resolveCommitTtl(32+ explicit: Ttl | undefined,33+ existingExpiresAt: number | null | undefined,34+ vaultDefault: number | null,35+ now = Math.floor(Date.now() / 1000),36+): ResolvedTtl {37+ if (explicit !== undefined) {38+ return {39+ expiresAt: ttlToExpiry(explicit, now),40+ ttl: explicit,41+ ttlSource: "explicit",42+ };43+ }44+ if (existingExpiresAt !== undefined) {45+ return {46+ expiresAt: existingExpiresAt,47+ ttl: existingExpiresAt === null48+ ? "forever"49+ : Math.max(0, Math.ceil((existingExpiresAt - now) / 86400)),50+ ttlSource: "existing-site",51+ };52+ }53+ const inherited = storedDefaultTtl(vaultDefault);54+ if (inherited !== null) {55+ return {56+ expiresAt: ttlToExpiry(inherited, now),57+ ttl: inherited,58+ ttlSource: "vault",59+ };60+ }61+ return {62+ expiresAt: ttlToExpiry(GLOBAL_DEFAULT_TTL, now),63+ ttl: GLOBAL_DEFAULT_TTL,64+ ttlSource: "global",65+ };1966 }2067 2168 export async function passwordHashFor(@@ -34,9 +81,7 @@ export async function resolvePathTarget(
3481 env: Env,3582 segment: string,3683 ): Promise<SiteRow | null> {37- const parsed = await clientInput(() =>38- parseTarget(decodeURIComponent(segment))39- );84+ const parsed = await clientInput(() => parseTarget(decodeURIComponent(segment)));4085 if (parsed.kind === "address") {4186 return await resolveTarget(env, { address: parsed.address });4287 }@@ -19,6 +19,8 @@ import { purgeSiteCache } from "../cache.ts";
1919 import {2020 allocateAddress,2121 commitSite,22+ getAppReservationByAddress,23+ getAppReservationByAlias,2224 getSiteByAddress,2325 getVaultByName,2426 getVaultBySlot,@@ -26,7 +28,7 @@ import {
2628 } from "../db.ts";2729 import { type Env, json, siteUrl } from "../env.ts";2830 import { ApiError, clientInput, readJson } from "./errors.ts";29-import { HEX64, passwordHashFor, ttlToExpiry } from "./common.ts";31+import { HEX64, passwordHashFor, resolveCommitTtl } from "./common.ts";3032 3133 const HEAD_BATCH_SIZE = 6;3234 @@ -109,40 +111,62 @@ export async function handleCommit(
109111 let alias: string | null = null;110112 let isNew: boolean;111113 let existingSite: Awaited<ReturnType<typeof getSiteByAddress>> = null;114+ let reservation: Awaited<ReturnType<typeof getAppReservationByAddress>> = null;115+ let vault: Awaited<ReturnType<typeof getVaultBySlot>>;112116 if ("address" in target) {113117 existingSite = await getSiteByAddress(env, target.address);114118 const slot = vaultSlotOf(target.address);115- if (!(await getVaultBySlot(env, slot))) {119+ vault = await getVaultBySlot(env, slot);120+ if (!vault) {116121 throw new ApiError(117122 404,118123 `vault slot 0x${slot.toString(16)} not registered`,119124 );120125 }126+ reservation = await getAppReservationByAddress(env, target.address);121127 address = target.address;122- alias = existingSite?.alias ?? null;128+ alias = existingSite?.alias ?? reservation?.alias ?? null;123129 isNew = !existingSite;124130 } else {125- const vault = await getVaultByName(env, target.vault);131+ vault = await getVaultByName(env, target.vault);126132 if (!vault) throw new ApiError(404, `unknown vault: ${target.vault}`);127133 alias = target.alias ?? null;128134 if (alias !== null && !isValidName(alias)) {129135 throw new ApiError(400, `invalid alias: ${alias}`);130136 }131- existingSite = alias === null132- ? null133- : await resolveTarget(env, { vault: target.vault, alias });137+ existingSite = alias === null ? null : await resolveTarget(env, { vault: target.vault, alias });134138 if (existingSite) {135139 address = existingSite.address;136140 isNew = false;137141 } else {138- address = await allocateAddress(env, vault.slot);142+ reservation = alias === null ? null : await getAppReservationByAlias(env, vault.slot, alias);143+ address = reservation?.address ?? await allocateAddress(env, vault.slot);139144 isNew = true;140145 }146+ reservation ??= await getAppReservationByAddress(env, address);141147 }142148 143- const expiresAt = body.ttl === undefined && existingSite144- ? existingSite.expires_at145- : ttlToExpiry(body.ttl);149+ if (body.app !== undefined && reservation === null) {150+ throw new ApiError(409, "app deployments require an initialized app reservation");151+ }152+ if (reservation !== null && body.app === undefined) {153+ throw new ApiError(409, "reserved app origins must be deployed with nzip app deploy");154+ }155+ const contentSecurityPolicy = body.app === undefined156+ ? undefined157+ : body.app.contentSecurityPolicy ?? null;158+ if (159+ typeof contentSecurityPolicy === "string" &&160+ (contentSecurityPolicy.length > 8192 || /[\r\n\0]/.test(contentSecurityPolicy))161+ ) {162+ throw new ApiError(400, "content security policy must be one line and at most 8192 characters");163+ }164+165+ const resolvedTtl = resolveCommitTtl(166+ body.ttl,167+ existingSite?.expires_at,168+ vault.default_ttl,169+ );146170 const protectedSite = passwordHash === undefined && existingSite147171 ? existingSite.password_hash !== null148172 : passwordHash !== null && passwordHash !== undefined;@@ -155,8 +179,9 @@ export async function handleCommit(
155179 vaultSlot: vaultSlotOf(address),156180 alias,157181 manifestHash: hash,158- expiresAt,182+ expiresAt: resolvedTtl.expiresAt,159183 passwordHash,184+ contentSecurityPolicy,160185 isNew,161186 });162187 const addressString = formatAddress(address);@@ -166,7 +191,9 @@ export async function handleCommit(
166191 url: siteUrl(env, addressString),167192 alias,168193 manifestHash: hash,169- expiresAt,194+ expiresAt: resolvedTtl.expiresAt,195+ ttl: resolvedTtl.ttl,196+ ttlSource: resolvedTtl.ttlSource,170197 protected: protectedSite,171198 seq,172199 });@@ -1,9 +1,5 @@
11 import { formatAddress } from "../../../shared/mod.ts";2-import type {3- PatchSiteRequest,4- RevertRequest,5- SourceResponse,6-} from "../../../shared/mod.ts";2+import type { PatchSiteRequest, RevertRequest, SourceResponse } from "../../../shared/mod.ts";73 import { purgeSiteCache } from "../cache.ts";84 import {95 commitSite,@@ -19,6 +15,7 @@ import { type Env, json, siteUrl } from "../env.ts";
1915 import {2016 HEX64,2117 passwordHashFor,18+ resolveCommitTtl,2219 resolvePathTarget,2320 sourceManifest,2421 ttlToExpiry,@@ -165,12 +162,15 @@ async function handleRevert(
165162 });166163 const address = formatAddress(site.address);167164 await purgeSiteCache(ctx, address);165+ const resolvedTtl = resolveCommitTtl(undefined, site.expires_at, null);168166 return json({169167 address,170168 url: siteUrl(env, address),171169 alias: site.alias,172170 manifestHash: target.manifestHash,173171 expiresAt: site.expires_at,172+ ttl: resolvedTtl.ttl,173+ ttlSource: resolvedTtl.ttlSource,174174 protected: site.password_hash !== null,175175 revertedTo: target.seq,176176 seq,@@ -1,6 +1,6 @@
1-import { VERSION } from "../../../shared/mod.ts";1+import { GLOBAL_DEFAULT_TTL, VERSION } from "../../../shared/mod.ts";22 import type { StatusResponse } from "../../../shared/mod.ts";3-import { listSites, listVaults } from "../db.ts";3+import { defaultVaults, listSites, listVaults } from "../db.ts";44 import { type Env, json } from "../env.ts";55 66 export async function handleStatus(env: Env): Promise<Response> {@@ -10,10 +10,10 @@ export async function handleStatus(env: Env): Promise<Response> {
1010 return json<StatusResponse>({1111 ok: true,1212 version: VERSION,13+ defaultVaults: await defaultVaults(env),14+ globalDefaultTtl: GLOBAL_DEFAULT_TTL,1315 vaults,1416 siteCount: sites.length,15- expiringSoon: sites.filter((site) =>16- site.expires_at !== null && site.expires_at < soon17- ).length,17+ expiringSoon: sites.filter((site) => site.expires_at !== null && site.expires_at < soon).length,1818 });1919 }@@ -1,5 +1,13 @@
11 import { isValidName, VAULT_SLOTS } from "../../../shared/mod.ts";2-import { createVault, listVaults, updateVault, vaultRowToInfo } from "../db.ts";2+import type { Ttl, VaultLifecycle } from "../../../shared/mod.ts";3+import {4+ createVault,5+ defaultVaults,6+ listVaults,7+ setDefaultVault,8+ updateVault,9+ vaultRowToInfo,10+} from "../db.ts";311 import { type Env, json } from "../env.ts";412 import { ApiError, readJson } from "./errors.ts";513 @@ -12,7 +20,10 @@ function descriptionValue(value: unknown): string | null {
1220 }1321 const description = value.trim();1422 if (description.length > DESCRIPTION_MAX_LENGTH) {15- throw new ApiError(400, `description must be at most ${DESCRIPTION_MAX_LENGTH} characters`);23+ throw new ApiError(24+ 400,25+ `description must be at most ${DESCRIPTION_MAX_LENGTH} characters`,26+ );1627 }1728 // Descriptions are rendered raw into CLI tables; keep them single-line.1829 if (/[\x00-\x1f\x7f-\x9f]/.test(description)) {@@ -21,20 +32,68 @@ function descriptionValue(value: unknown): string | null {
2132 return description || null;2233 }2334 35+function defaultTtlValue(value: unknown): Ttl | null {36+ if (value === null || value === "forever") return value;37+ if (38+ typeof value !== "number" || !Number.isFinite(value) || value <= 0 ||39+ value > 365040+ ) {41+ throw new ApiError(42+ 400,43+ 'defaultTtl must be null, 1-3650 days, or "forever"',44+ );45+ }46+ return value;47+}48+49+function lifecycleValue(value: unknown): VaultLifecycle | null {50+ if (value === null || value === "temporary" || value === "permanent") {51+ return value;52+ }53+ throw new ApiError(54+ 400,55+ 'defaultFor must be null, "temporary", or "permanent"',56+ );57+}58+2459 export async function handleVaults(2560 request: Request,2661 env: Env,2762 ): Promise<Response> {2863 if (request.method === "GET") return json(await listVaults(env));29- const body = await readJson<{ name: string; slot?: number; description?: unknown }>(request);64+ const body = await readJson<{65+ name: string;66+ slot?: number;67+ description?: unknown;68+ defaultTtl?: unknown;69+ defaultFor?: unknown;70+ }>(request);3071 if (!isValidName(body.name)) throw new ApiError(400, "invalid vault name");3172 if (body.slot !== undefined && (body.slot < 0 || body.slot >= VAULT_SLOTS)) {3273 throw new ApiError(400, "slot must be 0-15");3374 }34- const description = body.description === undefined ? null : descriptionValue(body.description);75+ const description = body.description === undefined76+ ? null77+ : descriptionValue(body.description);78+ const defaultTtl = body.defaultTtl === undefined79+ ? null80+ : defaultTtlValue(body.defaultTtl);81+ const defaultFor = body.defaultFor === undefined82+ ? null83+ : lifecycleValue(body.defaultFor);3584 try {36- const vault = await createVault(env, body.name, body.slot, description);37- return json(vaultRowToInfo(vault, 0), 201);85+ const vault = await createVault(86+ env,87+ body.name,88+ body.slot,89+ description,90+ defaultTtl,91+ defaultFor,92+ );93+ return json(94+ vaultRowToInfo(vault, 0, defaultFor === null ? [] : [defaultFor]),95+ 201,96+ );3897 } catch (cause) {3998 const message = cause instanceof Error ? cause.message : "vault conflict";4099 throw new ApiError(409, message, { cause });@@ -53,16 +112,38 @@ export async function handleVault(
53112 } catch {54113 throw new ApiError(400, "invalid vault name");55114 }56- const body = await readJson<{ name?: unknown; description?: unknown }>(request);57- if (body.name === undefined && body.description === undefined) {58- throw new ApiError(400, "provide name and/or description");115+ const body = await readJson<{116+ name?: unknown;117+ description?: unknown;118+ defaultTtl?: unknown;119+ defaultFor?: unknown;120+ }>(request);121+ if (122+ body.name === undefined && body.description === undefined &&123+ body.defaultTtl === undefined && body.defaultFor === undefined124+ ) {125+ throw new ApiError(126+ 400,127+ "provide name, description, defaultTtl, and/or defaultFor",128+ );59129 }60- if (body.name !== undefined && (typeof body.name !== "string" || !isValidName(body.name))) {130+ if (131+ body.name !== undefined &&132+ (typeof body.name !== "string" || !isValidName(body.name))133+ ) {61134 throw new ApiError(400, "invalid vault name");62135 }63136 const patch = {64137 ...(typeof body.name === "string" ? { name: body.name } : {}),65- ...(body.description !== undefined ? { description: descriptionValue(body.description) } : {}),138+ ...(body.description !== undefined139+ ? { description: descriptionValue(body.description) }140+ : {}),141+ ...(body.defaultTtl !== undefined142+ ? { defaultTtl: defaultTtlValue(body.defaultTtl) }143+ : {}),144+ ...(body.defaultFor !== undefined145+ ? { defaultFor: lifecycleValue(body.defaultFor) }146+ : {}),66147 };67148 try {68149 const vault = await updateVault(env, currentName, patch);@@ -74,3 +155,19 @@ export async function handleVault(
74155 throw new ApiError(409, message, { cause });75156 }76157 }158+159+export async function handleDefaultVault(160+ request: Request,161+ env: Env,162+ lifecycleSegment: string,163+): Promise<Response> {164+ const lifecycle = lifecycleValue(lifecycleSegment);165+ if (lifecycle === null) throw new ApiError(400, "lifecycle is required");166+ const body = await readJson<{ name?: unknown }>(request);167+ if (typeof body.name !== "string" || !isValidName(body.name)) {168+ throw new ApiError(400, "valid vault name required");169+ }170+ const vault = await setDefaultVault(env, lifecycle, body.name);171+ if (!vault) throw new ApiError(404, `unknown vault: ${body.name}`);172+ return json({ defaultVaults: await defaultVaults(env), vault });173+}@@ -1,11 +1,21 @@
11 import {22 formatAddress,3+ GLOBAL_DEFAULT_TTL,34 SITES_PER_VAULT,45 type Target,56 VAULT_SLOTS,67 vaultSlotOf,78 } from "../../shared/mod.ts";8-import type { PushInfo, SiteDetail, SiteInfo, VaultInfo } from "../../shared/mod.ts";9+import type {10+ AppReservationInfo,11+ DefaultVaults,12+ PushInfo,13+ SiteDetail,14+ SiteInfo,15+ Ttl,16+ VaultInfo,17+ VaultLifecycle,18+} from "../../shared/mod.ts";919 import { type Env, siteUrl } from "./env.ts";1020 1121 export const HISTORY_CAP = 10;@@ -19,6 +29,7 @@ export interface SiteRow {
1929 updated_at: number;2030 expires_at: number | null;2131 password_hash: string | null;32+ content_security_policy: string | null;2233 auth_version: number;2334 }2435 @@ -26,9 +37,18 @@ export interface VaultRow {
2637 slot: number;2738 name: string;2839 description: string | null;40+ default_ttl: number | null;2941 created_at: number;3042 }3143 44+export interface AppReservationRow {45+ address: number;46+ vault_slot: number;47+ alias: string;48+ created_at: number;49+ retired_at: number | null;50+}51+3252 export async function getSiteByAddress(3353 env: Env,3454 address: number,@@ -53,6 +73,25 @@ export async function getVaultBySlot(
5373 .first<VaultRow>();5474 }5575 76+export async function getAppReservationByAddress(77+ env: Env,78+ address: number,79+): Promise<AppReservationRow | null> {80+ return await env.DB.prepare(81+ "SELECT * FROM app_reservations WHERE address = ?",82+ ).bind(address).first<AppReservationRow>();83+}84+85+export async function getAppReservationByAlias(86+ env: Env,87+ vaultSlot: number,88+ alias: string,89+): Promise<AppReservationRow | null> {90+ return await env.DB.prepare(91+ "SELECT * FROM app_reservations WHERE vault_slot = ? AND alias = ?",92+ ).bind(vaultSlot, alias).first<AppReservationRow>();93+}94+5695 /** Resolve an API target to an existing site row, or null if it doesn't exist. */5796 export async function resolveTarget(5897 env: Env,@@ -73,9 +112,11 @@ export async function resolveTarget(
73112 */74113 export async function allocateAddress(env: Env, slot: number): Promise<number> {75114 const taken = await env.DB.prepare(76- "SELECT address FROM sites WHERE vault_slot = ?",115+ `SELECT address FROM sites WHERE vault_slot = ?116+ UNION117+ SELECT address FROM app_reservations WHERE vault_slot = ?`,77118 )78- .bind(slot).all<{ address: number }>();119+ .bind(slot, slot).all<{ address: number }>();79120 const used = new Set(taken.results.map((r) => r.address));80121 if (used.size >= SITES_PER_VAULT) throw new Error("vault is full");81122 for (let i = 0; i < 64; i++) {@@ -99,6 +140,8 @@ export interface CommitParams {
99140 expiresAt: number | null;100141 /** undefined preserves an existing password; null explicitly clears it */101142 passwordHash?: string | null;143+ /** undefined preserves an existing CSP; null explicitly clears it */144+ contentSecurityPolicy?: string | null;102145 isNew: boolean;103146 note?: string;104147 }@@ -115,8 +158,9 @@ export async function commitSite(env: Env, p: CommitParams): Promise<number> {
115158 const siteCommit = p.isNew116159 ? env.DB.prepare(117160 `INSERT INTO sites (118- address, vault_slot, alias, current_manifest, created_at, updated_at, expires_at, password_hash119- ) VALUES (?, ?, ?, ?, ?, ?, ?, ?)`,161+ address, vault_slot, alias, current_manifest, created_at, updated_at, expires_at,162+ password_hash, content_security_policy163+ ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`,120164 ).bind(121165 p.address,122166 p.vaultSlot,@@ -126,22 +170,30 @@ export async function commitSite(env: Env, p: CommitParams): Promise<number> {
126170 now,127171 p.expiresAt,128172 p.passwordHash ?? null,173+ p.contentSecurityPolicy ?? null,129174 )130- : p.passwordHash === undefined175+ : p.passwordHash === undefined && p.contentSecurityPolicy === undefined131176 ? env.DB.prepare(132177 `UPDATE sites SET current_manifest = ?, updated_at = ?, expires_at = ?, alias = COALESCE(?, alias)133178 WHERE address = ?`,134179 ).bind(p.manifestHash, now, p.expiresAt, p.alias, p.address)135180 : env.DB.prepare(136181 `UPDATE sites SET current_manifest = ?, updated_at = ?, expires_at = ?,137- alias = COALESCE(?, alias), password_hash = ?, auth_version = auth_version + 1182+ alias = COALESCE(?, alias),183+ password_hash = CASE WHEN ? = 1 THEN ? ELSE password_hash END,184+ content_security_policy = CASE WHEN ? = 1 THEN ? ELSE content_security_policy END,185+ auth_version = auth_version + ?138186 WHERE address = ?`,139187 ).bind(140188 p.manifestHash,141189 now,142190 p.expiresAt,143191 p.alias,144- p.passwordHash,192+ p.passwordHash === undefined ? 0 : 1,193+ p.passwordHash ?? null,194+ p.contentSecurityPolicy === undefined ? 0 : 1,195+ p.contentSecurityPolicy ?? null,196+ p.passwordHash === undefined ? 0 : 1,145197 p.address,146198 );147199 @@ -195,23 +247,48 @@ export async function siteHistory(
195247 }));196248 }197249 198-export function vaultRowToInfo(row: VaultRow, siteCount: number): VaultInfo {250+export function vaultRowToInfo(251+ row: VaultRow,252+ siteCount: number,253+ defaultFor: VaultLifecycle[] = [],254+): VaultInfo {255+ const defaultTtl: Ttl | null = row.default_ttl === null256+ ? null257+ : row.default_ttl === 0258+ ? "forever"259+ : row.default_ttl;199260 return {200261 slot: row.slot,201262 name: row.name,202263 description: row.description,203264 createdAt: row.created_at,204265 siteCount,266+ defaultTtl,267+ effectiveDefaultTtl: defaultTtl ?? GLOBAL_DEFAULT_TTL,268+ defaultFor,205269 };206270 }207271 208272 export async function listVaults(env: Env): Promise<VaultInfo[]> {209- const res = await env.DB.prepare(210- `SELECT v.slot, v.name, v.description, v.created_at, COUNT(s.address) AS site_count273+ const [res, defaults] = await Promise.all([274+ env.DB.prepare(275+ `SELECT v.slot, v.name, v.description, v.default_ttl,276+ v.created_at, COUNT(s.address) AS site_count211277 FROM vaults v LEFT JOIN sites s ON s.vault_slot = v.slot212278 GROUP BY v.slot ORDER BY v.slot`,213- ).all<VaultRow & { site_count: number }>();214- return res.results.map((r) => vaultRowToInfo(r, r.site_count));279+ ).all<VaultRow & { site_count: number }>(),280+ env.DB.prepare("SELECT lifecycle, vault_slot FROM vault_defaults")281+ .all<{ lifecycle: VaultLifecycle; vault_slot: number }>(),282+ ]);283+ return res.results.map((r) =>284+ vaultRowToInfo(285+ r,286+ r.site_count,287+ defaults.results.filter((item) => item.vault_slot === r.slot).map((288+ item,289+ ) => item.lifecycle),290+ )291+ );215292 }216293 217294 export async function createVault(@@ -219,6 +296,8 @@ export async function createVault(
219296 name: string,220297 slot?: number,221298 description: string | null = null,299+ defaultTtl: Ttl | null = null,300+ defaultFor: VaultLifecycle | null = null,222301 ): Promise<VaultRow> {223302 const now = Math.floor(Date.now() / 1000);224303 let chosen = slot;@@ -235,18 +314,49 @@ export async function createVault(
235314 }236315 if (chosen === undefined) throw new Error("all 16 vault slots are taken");237316 }238- await env.DB.prepare(239- "INSERT INTO vaults (slot, name, description, created_at) VALUES (?, ?, ?, ?)",317+ const insert = env.DB.prepare(318+ `INSERT INTO vaults319+ (slot, name, description, default_ttl, created_at)320+ VALUES (?, ?, ?, ?, ?)`,240321 )241- .bind(chosen, name, description, now).run();242- return { slot: chosen, name, description, created_at: now };322+ .bind(323+ chosen,324+ name,325+ description,326+ defaultTtl === "forever" ? 0 : defaultTtl,327+ now,328+ );329+ if (defaultFor === null) await insert.run();330+ else {331+ await env.DB.batch([332+ insert,333+ env.DB.prepare(334+ `INSERT INTO vault_defaults (lifecycle, vault_slot) VALUES (?, ?)335+ ON CONFLICT(lifecycle) DO UPDATE SET vault_slot = excluded.vault_slot`,336+ ).bind(defaultFor, chosen),337+ ]);338+ }339+ return {340+ slot: chosen,341+ name,342+ description,343+ default_ttl: defaultTtl === "forever" ? 0 : defaultTtl,344+ created_at: now,345+ };243346 }244347 245348 export async function updateVault(246349 env: Env,247350 currentName: string,248- patch: { name?: string; description?: string | null },351+ patch: {352+ name?: string;353+ description?: string | null;354+ defaultTtl?: Ttl | null;355+ defaultFor?: VaultLifecycle | null;356+ },249357 ): Promise<VaultInfo | null> {358+ const current = await getVaultByName(env, currentName);359+ if (!current) return null;250360 const assignments: string[] = [];251361 const values: unknown[] = [];252362 if (patch.name !== undefined) {@@ -257,12 +367,35 @@ export async function updateVault(
257367 assignments.push("description = ?");258368 values.push(patch.description);259369 }260- if (assignments.length === 0) return null;261-262- const result = await env.DB.prepare(263- `UPDATE vaults SET ${assignments.join(", ")} WHERE name = ?`,264- ).bind(...values, currentName).run();265- if (result.meta.changes === 0) return null;370+ if (patch.defaultTtl !== undefined) {371+ assignments.push("default_ttl = ?");372+ values.push(patch.defaultTtl === "forever" ? 0 : patch.defaultTtl);373+ }374+ const statements: D1PreparedStatement[] = [];375+ if (assignments.length > 0) {376+ statements.push(377+ env.DB.prepare(378+ `UPDATE vaults SET ${assignments.join(", ")} WHERE name = ?`,379+ )380+ .bind(...values, currentName),381+ );382+ }383+ if (patch.defaultFor === null) {384+ statements.push(385+ env.DB.prepare("DELETE FROM vault_defaults WHERE vault_slot = ?").bind(386+ current.slot,387+ ),388+ );389+ } else if (patch.defaultFor !== undefined) {390+ statements.push(391+ env.DB.prepare(392+ `INSERT INTO vault_defaults (lifecycle, vault_slot) VALUES (?, ?)393+ ON CONFLICT(lifecycle) DO UPDATE SET vault_slot = excluded.vault_slot`,394+ ).bind(patch.defaultFor, current.slot),395+ );396+ }397+ if (statements.length === 0) return null;398+ await env.DB.batch(statements);266399 267400 const updatedName = patch.name ?? currentName;268401 const row = await getVaultByName(env, updatedName);@@ -270,7 +403,95 @@ export async function updateVault(
270403 const count = await env.DB.prepare(271404 "SELECT COUNT(*) AS n FROM sites WHERE vault_slot = ?",272405 ).bind(row.slot).first<{ n: number }>();273- return vaultRowToInfo(row, count?.n ?? 0);406+ const defaults = await env.DB.prepare(407+ "SELECT lifecycle FROM vault_defaults WHERE vault_slot = ? ORDER BY lifecycle",408+ ).bind(row.slot).all<{ lifecycle: VaultLifecycle }>();409+ return vaultRowToInfo(410+ row,411+ count?.n ?? 0,412+ defaults.results.map((r) => r.lifecycle),413+ );414+}415+416+export async function defaultVaults(env: Env): Promise<DefaultVaults> {417+ const rows = await env.DB.prepare(418+ `SELECT v.name, d.lifecycle419+ FROM vault_defaults d JOIN vaults v ON v.slot = d.vault_slot`,420+ ).all<{ name: string; lifecycle: VaultLifecycle }>();421+ const result: DefaultVaults = { temporary: null, permanent: null };422+ for (const row of rows.results) result[row.lifecycle] = row.name;423+ return result;424+}425+426+export async function setDefaultVault(427+ env: Env,428+ lifecycle: VaultLifecycle,429+ name: string,430+): Promise<VaultInfo | null> {431+ const vault = await getVaultByName(env, name);432+ if (!vault) return null;433+ await env.DB.prepare(434+ `INSERT INTO vault_defaults (lifecycle, vault_slot) VALUES (?, ?)435+ ON CONFLICT(lifecycle) DO UPDATE SET vault_slot = excluded.vault_slot`,436+ ).bind(lifecycle, vault.slot).run();437+ const count = await env.DB.prepare(438+ "SELECT COUNT(*) AS n FROM sites WHERE vault_slot = ?",439+ ).bind(vault.slot).first<{ n: number }>();440+ const defaults = await env.DB.prepare(441+ "SELECT lifecycle FROM vault_defaults WHERE vault_slot = ? ORDER BY lifecycle",442+ ).bind(vault.slot).all<{ lifecycle: VaultLifecycle }>();443+ return vaultRowToInfo(444+ vault,445+ count?.n ?? 0,446+ defaults.results.map((r) => r.lifecycle),447+ );448+}449+450+export async function reserveAppOrigin(451+ env: Env,452+ vault: VaultRow,453+ alias: string,454+): Promise<AppReservationRow> {455+ const existing = await getAppReservationByAlias(env, vault.slot, alias);456+ if (existing) return existing;457+458+ const site = await resolveTarget(env, { vault: vault.name, alias });459+ const address = site?.address ?? await allocateAddress(env, vault.slot);460+ const now = Math.floor(Date.now() / 1000);461+ try {462+ await env.DB.prepare(463+ `INSERT INTO app_reservations464+ (address, vault_slot, alias, created_at, retired_at)465+ VALUES (?, ?, ?, ?, NULL)`,466+ ).bind(address, vault.slot, alias, now).run();467+ } catch (cause) {468+ const raced = await getAppReservationByAlias(env, vault.slot, alias);469+ if (raced) return raced;470+ throw cause;471+ }472+ return {473+ address,474+ vault_slot: vault.slot,475+ alias,476+ created_at: now,477+ retired_at: null,478+ };479+}480+481+export async function appReservationToInfo(482+ env: Env,483+ row: AppReservationRow,484+ vaultName: string,485+): Promise<AppReservationInfo> {486+ const address = formatAddress(row.address);487+ return {488+ address,489+ vault: vaultName,490+ alias: row.alias,491+ url: siteUrl(env, address),492+ createdAt: row.created_at,493+ deployed: (await getSiteByAddress(env, row.address)) !== null,494+ };274495 }275496 276497 export function siteRowToInfo(@@ -1,32 +1,40 @@
11 import { parseAddress, parseManifest } from "../../shared/mod.ts";2-import { getSiteByAddress, type SiteRow } from "./db.ts";2+import { getAppReservationByAddress, getSiteByAddress, type SiteRow } from "./db.ts";33 import { siteCacheTag } from "./cache.ts";44 import type { Env } from "./env.ts";55 import { siteUrl } from "./env.ts";66 import { FAVICON_PNG } from "./favicon.ts";7-import {8- hasValidUnlockCookie,9- makeUnlockCookie,10- verifyPassword,11-} from "./password.ts";7+import { hasValidUnlockCookie, makeUnlockCookie, verifyPassword } from "./password.ts";128 import { notifyLandingPage } from "./notify_ui.ts";139 14-const GONE_PAGE =15- `<!doctype html><meta charset="utf-8"><title>expired — nzip</title>10+const GONE_PAGE = `<!doctype html><meta charset="utf-8"><title>expired — nzip</title>1611 <style>body{background:#121110;color:#8a8172;font-family:ui-monospace,monospace;display:grid;place-items:center;height:100vh;margin:0}div{text-align:center}b{color:#ffb347;font-size:32px;display:block;margin-bottom:8px}</style>1712 <div><b>410</b>this share expired</div>`;1813 19-const NOT_FOUND_PAGE =20- `<!doctype html><meta charset="utf-8"><title>not found — nzip</title>14+const NOT_FOUND_PAGE = `<!doctype html><meta charset="utf-8"><title>not found — nzip</title>2115 <style>body{background:#121110;color:#8a8172;font-family:ui-monospace,monospace;display:grid;place-items:center;height:100vh;margin:0}div{text-align:center}b{color:#ffb347;font-size:32px;display:block;margin-bottom:8px}</style>2216 <div><b>404</b>nothing here</div>`;2317 18+const NOT_DEPLOYED_PAGE =19+ `<!doctype html><meta charset="utf-8"><meta name="robots" content="noindex"><title>not deployed — nzip</title>20+<style>body{background:#121110;color:#8a8172;font-family:ui-monospace,monospace;display:grid;place-items:center;height:100vh;margin:0}div{text-align:center}b{color:#ffb347;font-size:32px;display:block;margin-bottom:8px}</style>21+<div><b>reserved</b>this app is not deployed yet</div>`;22+2423 const MAX_UNLOCK_BODY_BYTES = 4096;2524 const MAX_PASSWORD_LENGTH = 256;2625 const ARTIFACT_SECURITY_HEADERS = {2726 "origin-agent-cluster": "?1",2827 } as const;2928 29+function artifactSecurityHeaders(site?: SiteRow): HeadersInit {30+ return {31+ ...ARTIFACT_SECURITY_HEADERS,32+ ...(site?.content_security_policy33+ ? { "content-security-policy": site.content_security_policy }34+ : {}),35+ };36+}37+3038 function htmlResponse(3139 body: string,3240 status: number,@@ -108,13 +116,14 @@ async function handleUnlock(
108116 site: SiteRow,109117 address: string,110118 ): Promise<Response> {119+ const securityHeaders = artifactSecurityHeaders(site);111120 const fallbackTarget = "/";112121 const contentLength = req.headers.get("content-length");113122 if (contentLength === null) {114123 return htmlResponse(115124 unlockForm(address, fallbackTarget, "request size required"),116125 411,117- ARTIFACT_SECURITY_HEADERS,126+ securityHeaders,118127 );119128 }120129 const declaredBytes = Number(contentLength);@@ -122,14 +131,14 @@ async function handleUnlock(
122131 return htmlResponse(123132 unlockForm(address, fallbackTarget, "invalid request size"),124133 400,125- ARTIFACT_SECURITY_HEADERS,134+ securityHeaders,126135 );127136 }128137 if (declaredBytes > MAX_UNLOCK_BODY_BYTES) {129138 return htmlResponse(130139 unlockForm(address, fallbackTarget, "request too large"),131140 413,132- ARTIFACT_SECURITY_HEADERS,141+ securityHeaders,133142 );134143 }135144 const bytes = new Uint8Array(await req.arrayBuffer());@@ -137,14 +146,14 @@ async function handleUnlock(
137146 return htmlResponse(138147 unlockForm(address, fallbackTarget, "request too large"),139148 413,140- ARTIFACT_SECURITY_HEADERS,149+ securityHeaders,141150 );142151 }143152 if (bytes.length !== declaredBytes) {144153 return htmlResponse(145154 unlockForm(address, fallbackTarget, "request size mismatch"),146155 400,147- ARTIFACT_SECURITY_HEADERS,156+ securityHeaders,148157 );149158 }150159 const contentType = req.headers.get("content-type")?.split(";", 1)[0].trim()@@ -153,7 +162,7 @@ async function handleUnlock(
153162 return htmlResponse(154163 unlockForm(address, fallbackTarget, "unsupported form"),155164 415,156- ARTIFACT_SECURITY_HEADERS,165+ securityHeaders,157166 );158167 }159168 const form = new URLSearchParams(new TextDecoder().decode(bytes));@@ -163,7 +172,7 @@ async function handleUnlock(
163172 return htmlResponse(164173 unlockForm(address, returnTarget, "password too long"),165174 400,166- ARTIFACT_SECURITY_HEADERS,175+ securityHeaders,167176 );168177 }169178 if (@@ -173,7 +182,7 @@ async function handleUnlock(
173182 return htmlResponse(174183 unlockForm(address, returnTarget, "wrong password"),175184 401,176- ARTIFACT_SECURITY_HEADERS,185+ securityHeaders,177186 );178187 }179188 return new Response(null, {@@ -181,7 +190,7 @@ async function handleUnlock(
181190 headers: {182191 location: returnTarget,183192 "set-cookie": await makeUnlockCookie(env, address, site.auth_version),184- ...ARTIFACT_SECURITY_HEADERS,193+ ...securityHeaders,185194 },186195 });187196 }@@ -225,8 +234,11 @@ async function serveControlOrigin(
225234 if (!m) return htmlResponse(NOT_FOUND_PAGE, 404);226235 const addressStr = m[1];227236 228- const site = await getSiteByAddress(env, parseAddress(addressStr));229- if (!site) return htmlResponse(NOT_FOUND_PAGE, 404);237+ const address = parseAddress(addressStr);238+ const site = await getSiteByAddress(env, address);239+ if (!site && !(await getAppReservationByAddress(env, address))) {240+ return htmlResponse(NOT_FOUND_PAGE, 404);241+ }230242 return publicSiteRedirect(231243 env,232244 m[2] ?? "/",@@ -243,13 +255,18 @@ async function serveSiteOrigin(
243255 addressStr: string,244256 ): Promise<Response> {245257 const path = url.pathname;246- const site = await getSiteByAddress(env, parseAddress(addressStr));258+ const address = parseAddress(addressStr);259+ const site = await getSiteByAddress(env, address);247260 if (!site) {248- return htmlResponse(NOT_FOUND_PAGE, 404, ARTIFACT_SECURITY_HEADERS);261+ const reservation = await getAppReservationByAddress(env, address);262+ return reservation263+ ? htmlResponse(NOT_DEPLOYED_PAGE, 200, ARTIFACT_SECURITY_HEADERS)264+ : htmlResponse(NOT_FOUND_PAGE, 404, ARTIFACT_SECURITY_HEADERS);249265 }266+ const securityHeaders = artifactSecurityHeaders(site);250267 const now = Math.floor(Date.now() / 1000);251268 if (site.expires_at !== null && site.expires_at < now) {252- return htmlResponse(GONE_PAGE, 410, ARTIFACT_SECURITY_HEADERS);269+ return htmlResponse(GONE_PAGE, 410, securityHeaders);253270 }254271 255272 // Password gate.@@ -263,7 +280,7 @@ async function serveSiteOrigin(
263280 return htmlResponse(264281 unlockForm(addressStr, `${url.pathname}${url.search}`),265282 401,266- ARTIFACT_SECURITY_HEADERS,283+ securityHeaders,267284 );268285 }269286 @@ -271,7 +288,7 @@ async function serveSiteOrigin(
271288 `manifest/${site.current_manifest}`,272289 );273290 if (!manifestObj) {274- return htmlResponse(NOT_FOUND_PAGE, 404, ARTIFACT_SECURITY_HEADERS);291+ return htmlResponse(NOT_FOUND_PAGE, 404, securityHeaders);275292 }276293 const manifest = parseManifest(277294 new Uint8Array(await manifestObj.arrayBuffer()),@@ -290,7 +307,7 @@ async function serveSiteOrigin(
290307 try {291308 assetPath = decodeURIComponent(path.slice(1));292309 } catch {293- return htmlResponse(NOT_FOUND_PAGE, 404, ARTIFACT_SECURITY_HEADERS);310+ return htmlResponse(NOT_FOUND_PAGE, 404, securityHeaders);294311 }295312 explicitIndexPath = /(^|\/)index\.html$/.test(assetPath);296313 if (assetPath === "" || assetPath.endsWith("/")) assetPath += "index.html";@@ -315,9 +332,7 @@ async function serveSiteOrigin(
315332 // emit same-page and anchor links as `index.html` or `index.html#section`;316333 // browsers preserve the fragment while following this redirect.317334 if (explicitIndexPath && entry) {318- const canonicalPath = assetPath === "index.html"319- ? "/"320- : path.slice(0, -"index.html".length);335+ const canonicalPath = assetPath === "index.html" ? "/" : path.slice(0, -"index.html".length);321336 return publicSiteRedirect(env, canonicalPath, url.search, addressStr);322337 }323338 @@ -331,26 +346,24 @@ async function serveSiteOrigin(
331346 return publicSiteRedirect(env, `${path}/`, url.search, addressStr);332347 }333348 if (!entry) {334- return htmlResponse(NOT_FOUND_PAGE, 404, ARTIFACT_SECURITY_HEADERS);349+ return htmlResponse(NOT_FOUND_PAGE, 404, securityHeaders);335350 }336351 337352 const etag = `"${entry.h}"`;338353 const protectedSite = site.password_hash !== null;339- const cacheControl = protectedSite340- ? "private, no-store"341- : "public, max-age=60";354+ const cacheControl = protectedSite ? "private, no-store" : "public, max-age=60";342355 // Never validate a previously cached protected response with 304: doing so343356 // would let the browser reuse its stored body after the site was locked.344357 if (!protectedSite && req.headers.get("if-none-match") === etag) {345358 return new Response(null, {346359 status: 304,347- headers: { etag, ...publicSiteHeaders(addressStr) },360+ headers: { etag, ...publicSiteHeaders(addressStr), ...securityHeaders },348361 });349362 }350363 351364 const blob = await env.CONTENT.get(`blob/${entry.h}`);352365 if (!blob) {353- return htmlResponse(NOT_FOUND_PAGE, 404, ARTIFACT_SECURITY_HEADERS);366+ return htmlResponse(NOT_FOUND_PAGE, 404, securityHeaders);354367 }355368 356369 return new Response(blob.body, {@@ -362,7 +375,7 @@ async function serveSiteOrigin(
362375 // survive the password check in a browser or intermediary cache.363376 "cache-control": cacheControl,364377 ...(protectedSite ? {} : { "cache-tag": siteCacheTag(addressStr) }),365- ...ARTIFACT_SECURITY_HEADERS,378+ ...securityHeaders,366379 },367380 });368381 }@@ -0,0 +1,21 @@
1+import assert from "node:assert/strict";2+import test from "node:test";3+4+import { allocateAddress } from "../src/db.ts";5+6+test("app reservations count as permanently occupied allocator slots", async () => {7+ const addresses = Array.from({ length: 4096 }, (_, id) => ({ address: (15 << 12) | id }));8+ const env = {9+ DB: {10+ prepare() {11+ return {12+ bind() {13+ return { all: async () => ({ results: addresses }) };14+ },15+ };16+ },17+ },18+ };19+20+ await assert.rejects(() => allocateAddress(env, 15), /vault is full/);21+});@@ -224,7 +224,27 @@ describe("Worker runtime", () => {
224224 });225225 226226 expect(response.status).toBe(200);227- expect((await response.json<{ version: string }>()).version).toBe("0.8.0");227+ expect(await response.json()).toMatchObject({228+ version: "0.9.0",229+ defaultVaults: { temporary: "personal", permanent: "public" },230+ globalDefaultTtl: 14,231+ vaults: expect.arrayContaining([232+ expect.objectContaining({233+ slot: 0,234+ name: "personal",235+ defaultTtl: 14,236+ effectiveDefaultTtl: 14,237+ defaultFor: ["temporary"],238+ }),239+ expect.objectContaining({240+ slot: 15,241+ name: "public",242+ defaultTtl: "forever",243+ effectiveDefaultTtl: "forever",244+ defaultFor: ["permanent"],245+ }),246+ ]),247+ });228248 });229249 230250 it("preserves TTL and password settings when repushing without policy flags", async () => {@@ -283,6 +303,7 @@ describe("Worker runtime", () => {
283303 address: "effe",284304 url: "https://effe.demo.dev/",285305 expiresAt,306+ ttlSource: "existing-site",286307 protected: true,287308 });288309 @@ -314,6 +335,139 @@ describe("Worker runtime", () => {
314335 });315336 });316337 338+ it("resolves new-site TTL from explicit, vault, and global policy layers", async () => {339+ const headers = {340+ authorization: "Bearer runtime-test-token",341+ "content-type": "application/json",342+ };343+ const emptyHash =344+ "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855";345+ await env.CONTENT.put(`blob/${emptyHash}`, new Uint8Array());346+ await env.DB.prepare(347+ `INSERT INTO vaults348+ (slot, name, description, default_ttl, created_at)349+ VALUES (13, 'inherited', NULL, NULL, ?)`,350+ ).bind(Math.floor(Date.now() / 1000)).run();351+ const manifest = {352+ v: 1,353+ files: {354+ "index.html": { h: emptyHash, s: 0, ct: "text/html; charset=utf-8" },355+ },356+ };357+ const commit = async (target: unknown, ttl?: number) => {358+ const response = await SELF.fetch(359+ "https://share.demo.dev/api/push/commit",360+ {361+ method: "POST",362+ headers,363+ body: JSON.stringify({364+ manifest,365+ target,366+ ...(ttl === undefined ? {} : { ttl }),367+ }),368+ },369+ );370+ expect(response.status).toBe(200);371+ return await response.json<Record<string, unknown>>();372+ };373+374+ expect(await commit({ vault: "personal", alias: "vault-policy" }))375+ .toMatchObject({376+ ttl: 14,377+ ttlSource: "vault",378+ });379+ expect(await commit({ vault: "inherited", alias: "global-policy" }))380+ .toMatchObject({381+ ttl: 14,382+ ttlSource: "global",383+ });384+ expect(await commit({ vault: "inherited", alias: "explicit-policy" }, 30))385+ .toMatchObject({386+ ttl: 30,387+ ttlSource: "explicit",388+ });389+ });390+391+ it("reserves app origins idempotently, mirrors lofi CSP, and keeps tombstones", async () => {392+ const headers = {393+ authorization: "Bearer runtime-test-token",394+ "content-type": "application/json",395+ };396+ const init = async () => {397+ const response = await SELF.fetch("https://share.demo.dev/api/apps", {398+ method: "POST",399+ headers,400+ body: JSON.stringify({401+ target: { vault: "public", alias: "runtime-app" },402+ }),403+ });404+ expect([200, 201]).toContain(response.status);405+ return await response.json<{406+ address: string;407+ url: string;408+ deployed: boolean;409+ }>();410+ };411+412+ const first = await init();413+ const second = await init();414+ expect(second.address).toBe(first.address);415+ expect(first.deployed).toBe(false);416+417+ const placeholder = await SELF.fetch(first.url);418+ expect(placeholder.status).toBe(200);419+ expect(await placeholder.text()).toContain("not deployed yet");420+421+ const emptyHash =422+ "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855";423+ await env.CONTENT.put(`blob/${emptyHash}`, new Uint8Array());424+ const policy = "default-src 'self'; frame-ancestors 'none'";425+ const deployed = await SELF.fetch(426+ "https://share.demo.dev/api/push/commit",427+ {428+ method: "POST",429+ headers,430+ body: JSON.stringify({431+ manifest: {432+ v: 1,433+ files: {434+ "index.html": {435+ h: emptyHash,436+ s: 0,437+ ct: "text/html; charset=utf-8",438+ },439+ },440+ },441+ target: { vault: "public", alias: "runtime-app" },442+ app: { contentSecurityPolicy: policy },443+ }),444+ },445+ );446+ expect(deployed.status).toBe(200);447+ expect(await deployed.json()).toMatchObject({448+ address: first.address,449+ ttl: "forever",450+ ttlSource: "vault",451+ });452+453+ const hosted = await SELF.fetch(first.url);454+ expect(hosted.status).toBe(200);455+ expect(hosted.headers.get("content-security-policy")).toBe(policy);456+457+ const removed = await SELF.fetch(458+ "https://share.demo.dev/api/sites/public%3Aruntime-app",459+ { method: "DELETE", headers },460+ );461+ expect(removed.status).toBe(200);462+ const afterDelete = await SELF.fetch(first.url);463+ expect(afterDelete.status).toBe(200);464+ expect(await afterDelete.text()).toContain("not deployed yet");465+ expect(await init()).toMatchObject({466+ address: first.address,467+ deployed: false,468+ });469+ });470+317471 it("creates, lists, renames, and redescribes vaults", async () => {318472 const headers = {319473 authorization: "Bearer runtime-test-token",@@ -331,6 +485,9 @@ describe("Worker runtime", () => {
331485 expect(await created.json()).toMatchObject({332486 name: "agent-work",333487 description: "Scratch space for agent-generated review artifacts",488+ defaultTtl: null,489+ effectiveDefaultTtl: 14,490+ defaultFor: [],334491 siteCount: 0,335492 });336493 @@ -342,6 +499,7 @@ describe("Worker runtime", () => {
342499 body: JSON.stringify({343500 name: "reviews",344501 description: "Human review links; safe to share with collaborators",502+ defaultTtl: "forever",345503 }),346504 },347505 );@@ -349,16 +507,35 @@ describe("Worker runtime", () => {
349507 expect(await updated.json()).toMatchObject({350508 name: "reviews",351509 description: "Human review links; safe to share with collaborators",510+ defaultTtl: "forever",511+ effectiveDefaultTtl: "forever",352512 });353513 514+ for (const lifecycle of ["temporary", "permanent"]) {515+ const selected = await SELF.fetch(516+ `https://share.demo.dev/api/default-vaults/${lifecycle}`,517+ { method: "PUT", headers, body: JSON.stringify({ name: "reviews" }) },518+ );519+ expect(selected.status).toBe(200);520+ }521+354522 const listed = await SELF.fetch("https://share.demo.dev/api/vaults", {355523 headers,356524 });357525 expect(await listed.json()).toContainEqual(expect.objectContaining({358526 name: "reviews",359527 description: "Human review links; safe to share with collaborators",528+ defaultFor: expect.arrayContaining(["temporary", "permanent"]),360529 }));361530 531+ for (const [lifecycle, name] of [["temporary", "personal"], ["permanent", "public"]]) {532+ const restored = await SELF.fetch(533+ `https://share.demo.dev/api/default-vaults/${lifecycle}`,534+ { method: "PUT", headers, body: JSON.stringify({ name }) },535+ );536+ expect(restored.status).toBe(200);537+ }538+362539 const cleared = await SELF.fetch(363540 "https://share.demo.dev/api/vaults/reviews",364541 {