Rename the source recovery command to cp while retaining download as a compatibility alias. Revoke unlock cookies when password policy changes, add migration coverage, and make the committed Wrangler config the safe self-hosting template.
- commit
- c745c85560301c73a56516a4089edb24c2a5be61
- parent
- 2c2fce25e3dcfdc3a80027333905768a7169666d
- author
- Dami <dami.etoile@gmail.com>
| M | README.md | 19 | +++++++------ |
| M | cli/ | 10 | +++--- |
| R | cli/ | 10 | +++--- |
| R | cli/ | 4 | +- |
| M | cli/ | 9 | +++--- |
| A | worker/ | 2 | + |
| M | worker/ | 4 | ++- |
| M | worker/ | 1 | + |
| M | worker/ | 10 | ++++++- |
| M | worker/ | 1 | + |
| M | worker/ | 4 | ++- |
| M | worker/ | 39 | ++++++++++++++++++++------ |
| M | worker/ | 7 | +++- |
| A | worker/ | 46 | ++++++++++++++++++++++++++++++ |
| M | worker/ | 8 | +++--- |
| D | worker/ | 33 | ---------------------- |
@@ -52,7 +52,8 @@ three target forms:
5252 them, and the revert itself is recorded as a push.5353 - **Password-protectable.** `nzip push ./demo work:demo --password …` publishes the content and5454 password policy atomically, then gates the site behind an unlock form (PBKDF2 hashing, signed55- per-site cookie, 7 days). Use `nzip site` to change protection later.55+ per-site cookie, 7 days). Changing the password policy revokes existing cookies immediately. Use56+ `nzip site` to change protection later.5657 - **Single-user by design.** One bearer token, stored as a Worker secret and in5758 `~/.config/nzip/config.json` (mode 0600).5859 - **Locates itself.** `nzip where <target>` prints the local directory this machine pushed a site@@ -71,7 +72,7 @@ nzip auth [--server URL] [--token T] authenticate and save config
7172 nzip vault add <name> [--slot N] register a vault (16 slots, 0x0–0xf)7273 nzip vault ls | default <name> list vaults / set the default7374 nzip push <dir|file> [target] [--ttl …] [--password PW | --no-password]74-nzip download <target> [dir] [--overwrite] recover the current hosted bundle75+nzip cp <target> [dir] [--overwrite] copy the current hosted bundle locally7576 nzip site <target> [--ttl …] [--password PW | --no-password]7677 nzip ls [vault] list sites7778 nzip where <target> print the local dir this machine pushed from@@ -82,19 +83,19 @@ nzip revert <target> [--to N] [--list] repoint to a previous push
8283 8384 Password and TTL are committed with the content. On a new site, omitting `--password` creates an8485 unprotected site; on an existing target, omission preserves its current password. Pass85-`--no-password` to clear protection explicitly. The former `nzip share` command remains available86-as a compatibility alias for `nzip site`.86+`--no-password` to clear protection explicitly. The former `nzip share` command remains available as87+a compatibility alias for `nzip site`.8788 8889 Pushing a single `page.html` stores it as the site's `index.html`. Directory pushes skip dotfiles8990 and `node_modules`, and honor a `.nzipignore` (one glob per line). Single-file sites serve directly9091 at the bare address (`/2a3f`); multi-file bundles redirect to `/2a3f/` so relative asset URLs9192 resolve.9293 93-`nzip download work:demo ./recovered-demo` recovers the exact current bundle from the authenticated94-server when the original local directory is unavailable. It refuses non-empty destinations unless94+`nzip cp work:demo ./recovered-demo` recovers the exact current bundle from the authenticated server95+when the original local directory is unavailable. It refuses non-empty destinations unless9596 `--overwrite` is passed, and verifies every downloaded file against the hosted manifest. It can only9697 restore uploaded files—not dotfiles, `.nzipignore`, or other local project metadata excluded from a97-push.98+push. The former `nzip download` command remains available as a compatibility alias.9899 99100 ## Architecture100101 @@ -121,7 +122,7 @@ revalidation and a 60-second cache. Metadata lives in three tables:
121122 | table | keys |122123 | -------- | ------------------------------------------------------------------------ |123124 | `vaults` | slot (0–15), name |124-| `sites` | address (0–65535), vault, alias, current manifest, expiry, password hash |125+| `sites` | address (0–65535), vault, alias, current manifest, expiry, password hash/version |125126 | `pushes` | per-site history (seq, manifest, note), capped at 10, powers `revert` |126127 127128 **GC safety rule:** an R2 object is deleted only if no live site or retained history entry@@ -173,7 +174,7 @@ See [`worker/setup.md`](worker/setup.md) for the full checklist. In short:
173174 ```sh174175 cd worker175176 npx wrangler login176-cp wrangler.local.example.jsonc wrangler.local.jsonc177+cp wrangler.jsonc wrangler.local.jsonc177178 # edit wrangler.local.jsonc:178179 # - routes[0].pattern: your hostname, such as share.example.com179180 # - vars.PUBLIC_BASE: https://<that hostname>@@ -41,7 +41,7 @@ nzip auth [--server URL] [--token T] authenticate and save config
4141 nzip vault add <name> [--slot N] register a vault (16 slots, 0x0–0xf)4242 nzip vault ls | default <name> list vaults / set the default4343 nzip push <dir|file> [target] [--ttl …] [--password PW | --no-password]44-nzip download <target> [dir] [--overwrite]44+nzip cp <target> [dir] [--overwrite]4545 nzip site <target> [--ttl …] [--password PW | --no-password]4646 nzip ls [vault] list sites4747 nzip where <target> print the local dir this machine pushed from@@ -55,10 +55,10 @@ unprotected site; on an existing target, omission preserves its current password
5555 `--no-password` to clear protection explicitly. The former `nzip share` command remains available as5656 a compatibility alias for `nzip site`.5757 58-`nzip download work:demo ./recovered-demo` reconstructs the current hosted bundle into an empty59-directory. It uses the configured bearer token, verifies file hashes, and never exposes source via60-the public page URL. Only uploaded files can be restored; ignored dotfiles and local metadata were61-never stored.58+`nzip cp work:demo ./recovered-demo` reconstructs the current hosted bundle into an empty directory.59+It uses the configured bearer token, verifies file hashes, and never exposes source via the public60+page URL. Only uploaded files can be restored; ignored dotfiles and local metadata were never61+stored. The former `nzip download` command remains available as a compatibility alias.6262 6363 See the [project README](https://github.com/FelineStateMachine/nzip) for how addresses work,6464 self-hosting setup, and architecture. MIT licensed.@@ -14,7 +14,7 @@ interface SourceApi {
1414 downloadSourceBlob(target: string, hash: string): ReturnType<ApiClient["downloadSourceBlob"]>;1515 }1616 17-export interface DownloadResult {17+export interface CopyResult {1818 address: string;1919 manifestHash: string;2020 destination: string;@@ -23,7 +23,7 @@ export interface DownloadResult {
2323 }2424 2525 function targetOrFail(raw: string | undefined, config: Config): string {26- if (!raw) fail("usage: nzip download <target> [dir] [--overwrite]");26+ if (!raw) fail("usage: nzip cp <target> [dir] [--overwrite]");2727 try {2828 return resolveCliTarget(raw, config);2929 } catch (e) {@@ -69,7 +69,7 @@ async function writeFile(path: string, bytes: Uint8Array, overwrite: boolean): P
6969 }7070 }7171 72-export async function cmdDownload(72+export async function cmdCp(7373 config: Config,7474 raw: string | undefined,7575 destinationRaw: string | undefined,@@ -82,7 +82,7 @@ export async function cmdDownload(
8282 emit(8383 () =>8484 console.log(85- `${green("✓")} downloaded ${bold(result.address)} → ${85+ `${green("✓")} copied ${bold(result.address)} → ${8686 cyan(result.destination)8787 } (${result.files} files, ${formatBytes(result.bytes)})`,8888 ),@@ -96,7 +96,7 @@ export async function downloadSource(
9696 target: string,9797 destinationRaw: string | undefined,9898 overwrite: boolean,99-): Promise<DownloadResult> {99+): Promise<CopyResult> {100100 const source = await api.source(target);101101 const destination = destinationRaw ?? `${source.address}-source`;102102 await prepareDestination(destination, overwrite);@@ -1,5 +1,5 @@
11 import { type Manifest, sha256hex, type SourceResponse } from "@nzip/shared";2-import { downloadSource } from "./download.ts";2+import { downloadSource } from "./cp.ts";33 44 function assertEquals(actual: unknown, expected: unknown): void {55 if (JSON.stringify(actual) !== JSON.stringify(expected)) {@@ -7,7 +7,7 @@ function assertEquals(actual: unknown, expected: unknown): void {
77 }88 }99 10-Deno.test("downloadSource reconstructs each manifest file and reports the total", async () => {10+Deno.test("cp reconstructs each manifest file and reports the total", async () => {1111 const files = new Map<string, Uint8Array>([1212 ["index.html", new TextEncoder().encode("<h1>recovered</h1>\n")],1313 ["assets/app.js", new TextEncoder().encode("console.log('ok')\n")],@@ -20,7 +20,7 @@
2020 import { parseArgs } from "@std/cli/parse-args";2121 import { cmdAuth } from "./commands/auth.ts";2222 import { cmdPush } from "./commands/push.ts";23-import { cmdDownload } from "./commands/download.ts";23+import { cmdCp } from "./commands/cp.ts";2424 import { cmdLs, cmdRevert, cmdRm, cmdSite, cmdStatus } from "./commands/sites.ts";2525 import { cmdVault } from "./commands/vault.ts";2626 import { cmdWhere } from "./commands/where.ts";@@ -35,7 +35,7 @@ usage:
3535 nzip vault ls list vaults3636 nzip vault default <name> set default vault3737 nzip push <dir|file> [target] [--ttl ...] [--password PW | --no-password]38- nzip download <target> [dir] [--overwrite]38+ nzip cp <target> [dir] [--overwrite] copy a hosted bundle locally3939 nzip site <target> [--ttl ...] [--password PW | --no-password]4040 inspect or update ttl/password4141 nzip share <target> [...] deprecated alias for nzip site@@ -84,8 +84,9 @@ async function main(): Promise<void> {
8484 args.password,8585 args["no-password"],8686 );87- case "download":88- return await cmdDownload(config, rest[0], rest[1], args.overwrite);87+ case "cp":88+ case "download": // compatibility alias for the former command name89+ return await cmdCp(config, rest[0], rest[1], args.overwrite);8990 case "site":9091 case "share": // compatibility alias for the former site-management command9192 return await cmdSite(config, rest[0], args.ttl, args.password, args["no-password"]);@@ -1,12 +1,14 @@
11 {22 "name": "nzip-worker",33 "private": true,4+ "type": "module",45 "scripts": {56 "dev": "wrangler dev",67 "dev:local": "wrangler dev --config wrangler.local.jsonc",78 "deploy": "wrangler deploy",89 "deploy:local": "wrangler deploy --config wrangler.local.jsonc",9- "check": "tsc --noEmit"10+ "check": "tsc --noEmit",11+ "test": "node --experimental-strip-types --test test/*.test.mjs"1012 },1113 "devDependencies": {1214 "@cloudflare/workers-types": "^5.20260708.1",@@ -16,6 +16,7 @@ CREATE TABLE IF NOT EXISTS sites (
1616 updated_at INTEGER NOT NULL,1717 expires_at INTEGER, -- unix seconds; NULL = permanent1818 password_hash TEXT, -- reserved for password shares; NULL = public19+ auth_version INTEGER NOT NULL DEFAULT 1, -- increment to revoke unlock cookies1920 UNIQUE (vault_slot, alias)2021 );2122 CREATE INDEX IF NOT EXISTS idx_sites_expiry ON sites(expires_at) WHERE expires_at IS NOT NULL;@@ -9,7 +9,7 @@ cd worker
99 npx wrangler login1010 1111 # 2. private Wrangler config12-cp wrangler.local.example.jsonc wrangler.local.jsonc12+cp wrangler.jsonc wrangler.local.jsonc1313 # Edit wrangler.local.jsonc now:1414 # - routes[0].pattern: your hostname, such as share.example.com1515 # - vars.PUBLIC_BASE: https://<that hostname>@@ -34,6 +34,14 @@ npx wrangler deploy --config wrangler.local.jsonc
3434 `routes[0].pattern` and `vars.PUBLIC_BASE` are required user-provided values. `PUBLIC_BASE` is the3535 origin printed in share URLs and the server URL passed to `nzip auth`.3636 37+When upgrading an existing deployment created before `auth_version` was added, apply its migration38+before deploying the new Worker:39+40+```sh41+cd worker42+npx wrangler d1 execute nzip --remote --file migrations/0001_auth_version.sql43+```44+3745 > **Cron gotcha:** deploying the `triggers` block fails with a 403 (API error `10063`) until the3846 > account has a workers.dev subdomain registered — even if the Worker only serves a custom domain.3947 > Open the Workers dashboard once to auto-create it, or `PUT /accounts/{id}/workers/subdomain` with@@ -296,6 +296,7 @@ export async function api(req: Request, env: Env, url: URL): Promise<Response> {
296296 if (passwordHash !== undefined) {297297 updates.push("password_hash = ?");298298 values.push(passwordHash);299+ updates.push("auth_version = auth_version + 1");299300 }300301 if (updates.length > 0) {301302 updates.push("updated_at = ?");@@ -19,6 +19,7 @@ export interface SiteRow {
1919 updated_at: number;2020 expires_at: number | null;2121 password_hash: string | null;22+ auth_version: number;2223 }2324 2425 export interface VaultRow {@@ -111,7 +112,8 @@ export async function commitSite(env: Env, p: CommitParams): Promise<number> {
111112 ).bind(p.manifestHash, now, p.expiresAt, p.alias, p.address)112113 : env.DB.prepare(113114 `UPDATE sites SET current_manifest = ?, updated_at = ?, expires_at = ?,114- alias = COALESCE(?, alias), password_hash = ? WHERE address = ?`,115+ alias = COALESCE(?, alias), password_hash = ?, auth_version = auth_version + 1116+ WHERE address = ?`,115117 ).bind(p.manifestHash, now, p.expiresAt, p.alias, p.passwordHash, p.address);116118 117119 const statements = [@@ -1,10 +1,12 @@
11 // Password hashing + unlock-cookie signing for protected shares.22 3-import type { Env } from "./env.ts";4-53 const PBKDF2_ITERATIONS = 100_000;64 const COOKIE_TTL_S = 7 * 24 * 3600;75 6+interface UnlockSecrets {7+ NZIP_TOKEN: string;8+}9+810 const hex = (bytes: ArrayBuffer | Uint8Array) =>911 [...new Uint8Array(bytes as ArrayBuffer)].map((b) => b.toString(16).padStart(2, "0")).join("");1012 @@ -48,7 +50,7 @@ export async function verifyPassword(password: string, stored: string): Promise<
4850 return diff === 0;4951 }5052 51-async function hmacKey(env: Env): Promise<CryptoKey> {53+async function hmacKey(env: UnlockSecrets): Promise<CryptoKey> {5254 return await crypto.subtle.importKey(5355 "raw",5456 new TextEncoder().encode(`nzip-unlock:${env.NZIP_TOKEN}`),@@ -63,28 +65,47 @@ function cookieName(address: string): string {
6365 }6466 6567 /** Set-Cookie value granting access to one site for COOKIE_TTL_S. */66-export async function makeUnlockCookie(env: Env, address: string): Promise<string> {68+export async function makeUnlockCookie(69+ env: UnlockSecrets,70+ address: string,71+ authVersion: number,72+): Promise<string> {6773 const exp = Math.floor(Date.now() / 1000) + COOKIE_TTL_S;6874 const sig = hex(6975 await crypto.subtle.sign(7076 "HMAC",7177 await hmacKey(env),72- new TextEncoder().encode(`${address}.${exp}`),78+ new TextEncoder().encode(`${address}.${authVersion}.${exp}`),7379 ),7480 );7581 // Path without trailing slash so the cookie also matches the bare /{address} URL.76- return `${cookieName(address)}=${exp}.${sig}; Max-Age=${COOKIE_TTL_S}; Path=/${address}; HttpOnly; Secure; SameSite=Lax`;82+ return `${cookieName(address)}=${authVersion}.${exp}.${sig}; Max-Age=${COOKIE_TTL_S}; Path=/${address}; HttpOnly; Secure; SameSite=Lax`;7783 }7884 7985 export async function hasValidUnlockCookie(8086 req: Request,81- env: Env,87+ env: UnlockSecrets,8288 address: string,89+ authVersion: number,8390 ): Promise<boolean> {8491 const cookies = req.headers.get("cookie") ?? "";8592 const match = cookies.match(new RegExp(`(?:^|;\\s*)${cookieName(address)}=([^;]+)`));8693 if (!match) return false;87- const [expStr, sig] = match[1].split(".");94+ const parts = match[1].split(".");95+ let expStr: string;96+ let sig: string;97+ let payload: string;98+ if (parts.length === 2 && authVersion === 1) {99+ // Cookies issued before auth_version existed remain valid until the site's100+ // password policy first changes and increments the version.101+ [expStr, sig] = parts;102+ payload = `${address}.${expStr}`;103+ } else if (parts.length === 3 && parts[0] === String(authVersion)) {104+ [, expStr, sig] = parts;105+ payload = `${address}.${authVersion}.${expStr}`;106+ } else {107+ return false;108+ }88109 const exp = parseInt(expStr, 10);89110 if (!Number.isFinite(exp) || exp < Math.floor(Date.now() / 1000)) return false;90111 try {@@ -92,7 +113,7 @@ export async function hasValidUnlockCookie(
92113 "HMAC",93114 await hmacKey(env),94115 hexToBytes(sig) as BufferSource,95- new TextEncoder().encode(`${address}.${exp}`),116+ new TextEncoder().encode(payload),96117 );97118 } catch {98119 return false;@@ -53,7 +53,7 @@ async function handleUnlock(req: Request, env: Env, site: SiteRow, address: stri
5353 // Bare address: single-file sites serve there directly, multi-file ones5454 // bounce through the trailing-slash redirect.5555 location: `/${address}`,56- "set-cookie": await makeUnlockCookie(env, address),56+ "set-cookie": await makeUnlockCookie(env, address, site.auth_version),5757 },5858 });5959 }@@ -90,7 +90,10 @@ export async function serve(req: Request, env: Env, url: URL): Promise<Response>
9090 if (m[2] === "/__unlock" && req.method === "POST") {9191 return await handleUnlock(req, env, site, addressStr);9292 }93- if (site.password_hash && !(await hasValidUnlockCookie(req, env, addressStr))) {93+ if (94+ site.password_hash &&95+ !(await hasValidUnlockCookie(req, env, addressStr, site.auth_version))96+ ) {9497 return htmlResponse(unlockForm(addressStr), 401);9598 }9699 @@ -0,0 +1,46 @@
1+import assert from "node:assert/strict";2+import test from "node:test";3+4+import { hasValidUnlockCookie, makeUnlockCookie } from "../src/password.ts";5+6+function requestWith(cookie) {7+ return new Request("https://n.zip/12d8", { headers: { cookie: cookie.split(";", 1)[0] } });8+}9+10+const hex = (bytes) =>11+ [...new Uint8Array(bytes)].map((byte) => byte.toString(16).padStart(2, "0")).join("");12+13+async function legacyCookie(env, address) {14+ const exp = Math.floor(Date.now() / 1000) + 3600;15+ const key = await crypto.subtle.importKey(16+ "raw",17+ new TextEncoder().encode(`nzip-unlock:${env.NZIP_TOKEN}`),18+ { name: "HMAC", hash: "SHA-256" },19+ false,20+ ["sign"],21+ );22+ const signature = await crypto.subtle.sign(23+ "HMAC",24+ key,25+ new TextEncoder().encode(`${address}.${exp}`),26+ );27+ return `nzip_a${address}=${exp}.${hex(signature)}`;28+}29+30+test("unlock cookies are scoped to the current auth version", async () => {31+ const env = { NZIP_TOKEN: "test-owner-token" };32+ const cookie = await makeUnlockCookie(env, "12d8", 3);33+ const req = requestWith(cookie);34+35+ assert.equal(await hasValidUnlockCookie(req, env, "12d8", 3), true);36+ assert.equal(await hasValidUnlockCookie(req, env, "12d8", 4), false);37+ assert.equal(await hasValidUnlockCookie(req, env, "ffff", 3), false);38+});39+40+test("legacy unlock cookies survive migration until the auth version changes", async () => {41+ const env = { NZIP_TOKEN: "test-owner-token" };42+ const req = requestWith(await legacyCookie(env, "12d8"));43+44+ assert.equal(await hasValidUnlockCookie(req, env, "12d8", 1), true);45+ assert.equal(await hasValidUnlockCookie(req, env, "12d8", 2), false);46+});@@ -3,7 +3,7 @@
33 "name": "nzip",44 "main": "src/index.ts",55 "compatibility_date": "2026-06-01",6- "routes": [{ "pattern": "n.zip", "custom_domain": true }],6+ "routes": [{ "pattern": "share.example.com", "custom_domain": true }],77 "r2_buckets": [88 {99 "binding": "CONTENT",@@ -14,7 +14,7 @@
1414 {1515 "binding": "DB",1616 "database_name": "nzip",17- "database_id": "1e65d5b5-80f4-4737-bce9-5892cfdc9cd7"17+ "database_id": "00000000-0000-0000-0000-000000000000"1818 }1919 ],2020 "ratelimits": [@@ -27,7 +27,7 @@
2727 ]2828 },2929 "vars": {30- "PUBLIC_BASE": "https://n.zip"30+ "PUBLIC_BASE": "https://share.example.com"3131 }32- // Secrets (wrangler secret put --config wrangler.local.jsonc): NZIP_TOKEN32+ // Secrets (wrangler secret put --config wrangler.jsonc): NZIP_TOKEN3333 }@@ -1,33 +0,0 @@
1-{2- "$schema": "node_modules/wrangler/config-schema.json",3- "name": "nzip",4- "main": "src/index.ts",5- "compatibility_date": "2026-06-01",6- "routes": [{ "pattern": "share.example.com", "custom_domain": true }],7- "r2_buckets": [8- {9- "binding": "CONTENT",10- "bucket_name": "nzip-content"11- }12- ],13- "d1_databases": [14- {15- "binding": "DB",16- "database_name": "nzip",17- "database_id": "00000000-0000-0000-0000-000000000000"18- }19- ],20- "ratelimits": [21- { "name": "RL_ENUM", "namespace_id": "1001", "simple": { "limit": 30, "period": 60 } },22- { "name": "RL_UNLOCK", "namespace_id": "1002", "simple": { "limit": 5, "period": 60 } }23- ],24- "triggers": {25- "crons": [26- "0 4 * * *"27- ]28- },29- "vars": {30- "PUBLIC_BASE": "https://share.example.com"31- }32- // Secrets (wrangler secret put --config wrangler.local.jsonc): NZIP_TOKEN33-}