Reuse unique source breadcrumbs for untargeted pushes, validate raw addresses against authenticated vault metadata, split content pushes from site policy changes, and release the CLI/shared packages as v0.10.0.
- commit
- 701fedd83b0fef008054b3dc694daad8305e6a4d
- parent
- e4af3d79ae92d3b4c06eac942b78c2c00377e07f
- author
- Dami <dami.etoile@gmail.com>
| M | AGENTS.md | 5 | +++ |
| M | README.md | 17 | ++++++--- |
| M | cli/ | 10 | ++++- |
| M | cli/ | 1 | + |
| M | cli/ | 12 | ++++-- |
| M | cli/ | 4 | +- |
| M | cli/ | 42 | ++++++++++++++++++---- |
| M | cli/ | 21 | +++++++---- |
| M | cli/ | 4 | +- |
| M | cli/ | 36 | ++++++++++++------- |
| M | cli/ | 13 | ++++++- |
| M | cli/ | 2 | +- |
| M | cli/ | 37 | +++++++++++++++++--- |
| M | cli/ | 27 | +++++++++++--- |
| M | cli/ | 11 | +----- |
| M | cli/ | 57 | ++++++++++++++++++++++++++---- |
| A | cli/ | 33 | +++++++++++++++++ |
| M | cli/ | 8 | +++-- |
| M | package-lock.json | 1 | + |
| M | package.json | 2 | + |
| M | shared/ | 2 | +- |
| M | shared/ | 2 | +- |
| M | skills/ | 15 | +++++++- |
| M | worker/ | 2 | +- |
@@ -1,3 +1,8 @@
11 # Project notes22 33 This is a Deno project. Use Deno tasks and Deno-compatible tooling for CLI and shared-code work.4+5+When revising HTML that was already shared, retain its existing URL. Untargeted `nzip site push`6+reuses a unique breadcrumb for the same logical source; pass the prior target explicitly when the7+source has multiple matches. Use `--new` only when a separate share was requested. Use `site push`8+for content and `site policy` for TTL/password changes.@@ -77,9 +77,10 @@ also the site hostname; see the parent-domain caveat in [SECURITY.md](SECURITY.m
7777 - **Owner notifications.** Explicitly approved phones receive Web Push notifications with bounded,7878 same-origin click targets.7979 - **Machine-local breadcrumbs.** `nzip site where` locates the source directory recorded during a80- push.81-- **Vault guardrails.** Optional `allowVaults` configuration refuses disallowed vaults and raw82- addresses before upload.80+ push. An untargeted push from the same logical source updates its unique existing site; a81+ directory and its `index.html` count as the same source. Pass `--new` to create another address.82+- **Vault guardrails.** Optional `allowVaults` configuration refuses disallowed vaults before83+ upload. Raw addresses are mapped to their authenticated vault slot before the same check.8384 - **Observable abuse controls.** Enumeration is rate-limited and evaluated through bounded,8485 privacy-preserving telemetry.8586 @@ -99,10 +100,10 @@ nzip
99100 │ ├─ ls list vaults100101 │ └─ default <temporary|permanent> <name>101102 ├─ site102-│ ├─ push <dir|file> [target] [--ttl …] [--password PW | --no-password]103+│ ├─ push <dir|file> [target] [--new] [--ttl …] [--password PW | --no-password]103104 │ ├─ cp <target> [dir] [--overwrite] copy a hosted bundle104105 │ ├─ show <target> show site details105-│ ├─ update <target> [--ttl …] [--password PW | --no-password]106+│ ├─ policy <target> [--ttl …] [--password PW | --no-password]106107 │ ├─ ls [vault] list sites107108 │ ├─ where <target> print this machine's source directory108109 │ ├─ rm <target> [--yes] delete a site@@ -124,6 +125,12 @@ sites there may still be password-protected. `status --json` and `vault ls --jso
124125 reports `ttl` and `ttlSource`. Directory pushes skip dotfiles and `node_modules` and honor a125126 `.nzipignore` file containing one glob per line.126127 128+Content revisions use `site push`; access and expiry changes use `site policy`. With no explicit129+target, `site push` reuses a unique unexpired local breadcrumb for the same logical source. It stops130+when multiple sites match instead of guessing. Pass the intended target to resolve the ambiguity,131+which rebinds later untargeted pushes to that site, or pass `--new` only when a separate URL is132+desired.133+127134 ## Host a lofi app128135 129136 Reserve the production origin before configuring credentials or building:@@ -55,10 +55,10 @@ nzip
5555 │ ├─ ls list vaults5656 │ └─ default <temporary|permanent> <name>5757 ├─ site58-│ ├─ push <dir|file> [target] [--ttl …] [--password PW | --no-password]58+│ ├─ push <dir|file> [target] [--new] [--ttl …] [--password PW | --no-password]5959 │ ├─ cp <target> [dir] [--overwrite] copy a hosted bundle6060 │ ├─ show <target> show site details61-│ ├─ update <target> [--ttl …] [--password PW | --no-password]61+│ ├─ policy <target> [--ttl …] [--password PW | --no-password]6262 │ ├─ ls [vault] list sites6363 │ ├─ where <target> print this machine's source directory6464 │ ├─ rm <target> [--yes] delete a site@@ -83,6 +83,12 @@ as the temporary 14-day default and `public` in slot `f` as the permanent foreve
8383 overwriting occupied slots on upgrade. `public` does not mean unprotected. Pass `--no-password` to8484 clear protection explicitly. Push JSON includes the resolved `ttl` and `ttlSource`.8585 86+Use `site push` for content revisions and `site policy` for TTL or password changes. An untargeted87+push reuses a unique unexpired local breadcrumb for the same logical source; a directory and its88+`index.html` are treated as one source. Multiple matches stop with candidate targets instead of89+guessing; an explicit targeted push rebinds the source for later untargeted revisions. Pass `--new`90+only when a separate URL is intended.91+8692 For a lofi PWA, run `nzip app init <alias>` before editing its deployed credential origins. The8793 command reserves the permanent default vault's final URL and writes a token-free `nzip.app.json`.8894 After adding the printed origin to `src/app.ts` `credentialOrigins`, run `nzip app deploy`. It@@ -192,6 +192,7 @@ async function cmdAppDeploy(config: Config): Promise<void> {
192192 undefined,193193 undefined,194194 false,195+ false,195196 typeof buildInfo.csp === "string" ? { contentSecurityPolicy: buildInfo.csp } : {},196197 );197198 }@@ -2,7 +2,7 @@
22 33 import { dirname, join } from "@std/path";44 import { sha256hex, validatePath } from "@nzip/shared";5-import { ApiClient, resolveCliTarget } from "../lib/api.ts";5+import { ApiClient, resolveCliTargetWithStatus } from "../lib/api.ts";66 import { formatBytes } from "../lib/bundle.ts";77 import type { Config } from "../lib/config.ts";88 import { bold, cyan, emit, fail, green } from "../lib/fmt.ts";@@ -25,10 +25,14 @@ export interface CopyResult {
2525 bytes: number;2626 }2727 28-function targetOrFail(raw: string | undefined, config: Config): string {28+async function targetOrFail(29+ raw: string | undefined,30+ config: Config,31+ api: ApiClient,32+): Promise<string> {2933 if (!raw) fail("usage: nzip site cp <target> [dir] [--overwrite]");3034 try {31- return resolveCliTarget(raw, config);35+ return await resolveCliTargetWithStatus(raw, config, api);3236 } catch (e) {3337 return fail((e as Error).message);3438 }@@ -93,8 +97,8 @@ export async function cmdCp(
9397 destinationRaw: string | undefined,9498 overwrite: boolean,9599 ): Promise<void> {96- const target = targetOrFail(raw, config);97100 const api = new ApiClient(config);101+ const target = await targetOrFail(raw, config, api);98102 const result = await downloadSource(api, target, destinationRaw, overwrite);99103 100104 emit(@@ -1,5 +1,5 @@
11 import type { NotifyApprovalPreview, NotifyDeviceInfo, NotifyRequest } from "@nzip/shared";2-import { ApiClient, resolveCliTarget } from "../lib/api.ts";2+import { ApiClient, resolveCliTargetWithStatus } from "../lib/api.ts";33 import type { Config } from "../lib/config.ts";44 import { ago, bold, cyan, dim, emit, fail, green, table } from "../lib/fmt.ts";55 @@ -167,7 +167,7 @@ export async function cmdNotify(
167167 if (options.title !== undefined) event.title = options.title;168168 if (options.tag !== undefined) event.tag = options.tag;169169 if (options.open !== undefined) {170- const target = resolveCliTarget(options.open, config);170+ const target = await resolveCliTargetWithStatus(options.open, config, api);171171 const site = await api.siteDetail(target);172172 event.path = `/${site.address}`;173173 }@@ -1,7 +1,7 @@
11 import { ApiClient, commitTargetFor } from "../lib/api.ts";22 import { buildBundle, formatBytes } from "../lib/bundle.ts";33 import type { Config } from "../lib/config.ts";4-import { recordPush } from "../lib/paths.ts";4+import { lookupBySource, recordPush } from "../lib/paths.ts";55 import { amber, bold, cyan, dim, emit, fail, green, parseTtl, ttlLeft } from "../lib/fmt.ts";66 77 const UPLOAD_CONCURRENCY = 6;@@ -22,26 +22,53 @@ export async function cmdPush(
2222 ttlRaw: string | undefined,2323 password: string | undefined,2424 noPassword: boolean,25+ newSite: boolean,2526 app?: { contentSecurityPolicy?: string },2627 ): Promise<void> {2728 if (!path) {2829 fail(29- "usage: nzip site push <dir|file> [target] [--ttl 14d|forever] [--password PW | --no-password]",30+ "usage: nzip site push <dir|file> [target] [--new] [--ttl 14d|forever] [--password PW | --no-password]",3031 );3132 }3233 if (password !== undefined && noPassword) {3334 fail("choose either --password or --no-password, not both");3435 }36+ if (newSite && targetRaw !== undefined) {37+ fail("--new cannot be combined with an explicit target");38+ }3539 const api = new ApiClient(config);3640 41+ let effectiveTargetRaw = targetRaw;42+ if (effectiveTargetRaw === undefined && !newSite) {43+ const matches = await lookupBySource(path);44+ if (matches.length === 1) {45+ effectiveTargetRaw = matches[0].address;46+ } else if (matches.length > 1) {47+ fail(48+ `multiple existing sites were pushed from this source: ${49+ matches.map((entry) => entry.address).join(", ")50+ }`,51+ "pass the intended target explicitly, or pass --new to create a separate site",52+ );53+ }54+ }55+3756 // Resolve (and vault-guard) the target up front — before any bundling or3857 // 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(":"))) {58+ const rawAddress = effectiveTargetRaw !== undefined &&59+ /^[0-9a-f]{4}$/.test(effectiveTargetRaw);60+ const needsStatus = effectiveTargetRaw === undefined ||61+ (effectiveTargetRaw !== undefined && !rawAddress && !effectiveTargetRaw.includes(":")) ||62+ (rawAddress && config.allowVaults !== undefined);63+ const status = needsStatus ? await api.status() : undefined;64+ const targetConfig = (() => {65+ if (66+ effectiveTargetRaw !== undefined &&67+ (/^[0-9a-f]{4}$/.test(effectiveTargetRaw) || effectiveTargetRaw.includes(":"))68+ ) {4169 return config;4270 }43- const status = await api.status();44- const defaultVault = status.defaultVaults?.temporary ?? config.defaultVault;71+ const defaultVault = status?.defaultVaults?.temporary ?? config.defaultVault;4572 if (!defaultVault) {4673 fail("server has no temporary default vault — specify vault:alias or configure one");4774 }@@ -49,7 +76,7 @@ export async function cmdPush(
4976 })();5077 const target = (() => {5178 try {52- return commitTargetFor(targetRaw, targetConfig);79+ return commitTargetFor(effectiveTargetRaw, targetConfig, status?.vaults);5380 } catch (e) {5481 return fail((e as Error).message);5582 }@@ -119,6 +146,7 @@ export async function cmdPush(
119146 path,120147 url: res.url,121148 expiresAt: res.expiresAt,149+ rebindSource: targetRaw !== undefined,122150 }).catch(() => {});123151 124152 const label = res.alias ? `${"vault" in target ? target.vault : "?"}:${res.alias}` : res.address;@@ -2,14 +2,14 @@ import type { Config } from "../lib/config.ts";
22 import { fail } from "../lib/fmt.ts";33 import { cmdCp } from "./cp.ts";44 import { cmdPush } from "./push.ts";5-import { cmdLs, cmdRevert, cmdRm, cmdSiteShow, cmdSiteUpdate } from "./sites.ts";5+import { cmdLs, cmdRevert, cmdRm, cmdSitePolicy, cmdSiteShow } from "./sites.ts";66 import { cmdWhere } from "./where.ts";77 88 export type SiteInvocation =99 | { kind: "push"; source?: string; target?: string }1010 | { kind: "cp"; target?: string; dir?: string }1111 | { kind: "show"; target?: string }12- | { kind: "update"; target?: string }12+ | { kind: "policy"; target?: string }1313 | { kind: "ls"; vault?: string }1414 | { kind: "where"; target?: string }1515 | { kind: "rm"; target?: string }@@ -17,7 +17,7 @@ export type SiteInvocation =
1717 1818 export function parseSiteInvocation(rest: string[]): SiteInvocation {1919 const [action, first, second, ...extra] = rest;20- if (!action) fail("usage: nzip site <push|cp|show|update|ls|where|rm|revert> ...");20+ if (!action) fail("usage: nzip site <push|cp|show|policy|ls|where|rm|revert> ...");2121 if (extra.length > 0) fail(`too many arguments for nzip site ${action}`);2222 switch (action) {2323 case "push":@@ -27,9 +27,14 @@ export function parseSiteInvocation(rest: string[]): SiteInvocation {
2727 case "show":2828 if (second !== undefined) fail("usage: nzip site show <target>");2929 return { kind: "show", target: first };30+ case "policy":31+ if (second !== undefined) fail("usage: nzip site policy <target> [options]");32+ return { kind: "policy", target: first };3033 case "update":31- if (second !== undefined) fail("usage: nzip site update <target> [options]");32- return { kind: "update", target: first };34+ return fail(35+ "site update was replaced by site policy",36+ "publish content with `nzip site push <dir|file> <target>`; change TTL/password with `nzip site policy <target> ...`",37+ );3338 case "ls":3439 if (second !== undefined) fail("usage: nzip site ls [vault]");3540 return { kind: "ls", vault: first };@@ -54,6 +59,7 @@ export async function cmdSiteGroup(
5459 ttl?: string;5560 password?: string;5661 noPassword: boolean;62+ newSite: boolean;5763 overwrite: boolean;5864 yes: boolean;5965 toSeq?: number;@@ -70,13 +76,14 @@ export async function cmdSiteGroup(
7076 options.ttl,7177 options.password,7278 options.noPassword,79+ options.newSite,7380 );7481 case "cp":7582 return await cmdCp(config, invocation.target, invocation.dir, options.overwrite);7683 case "show":7784 return await cmdSiteShow(config, invocation.target);78- case "update":79- return await cmdSiteUpdate(85+ case "policy":86+ return await cmdSitePolicy(8087 config,8188 invocation.target,8289 options.ttl,@@ -21,8 +21,8 @@ Deno.test("site groups the complete target lifecycle", () => {
2121 kind: "show",2222 target: "work:demo",2323 });24- assertEquals(parseSiteInvocation(["update", "work:demo"]), {25- kind: "update",24+ assertEquals(parseSiteInvocation(["policy", "work:demo"]), {25+ kind: "policy",2626 target: "work:demo",2727 });2828 assertEquals(parseSiteInvocation(["ls", "work"]), { kind: "ls", vault: "work" });@@ -1,7 +1,7 @@
11 // Site lifecycle operations plus the global deployment status command.22 33 import type { SiteDetail } from "@nzip/shared";4-import { ApiClient, resolveCliTarget } from "../lib/api.ts";4+import { ApiClient, resolveCliTargetWithStatus } from "../lib/api.ts";55 import type { Config } from "../lib/config.ts";66 import { forget, reconcile } from "../lib/paths.ts";77 import {@@ -18,14 +18,15 @@ import {
1818 ttlLeft,1919 } from "../lib/fmt.ts";2020 21-function targetOrFail(21+async function targetOrFail(2222 raw: string | undefined,2323 config: Config,24+ api: ApiClient,2425 usage: string,25-): string {26+): Promise<string> {2627 if (!raw) fail(usage);2728 try {28- return resolveCliTarget(raw, config);29+ return await resolveCliTargetWithStatus(raw, config, api);2930 } catch (e) {3031 return fail((e as Error).message);3132 }@@ -48,21 +49,24 @@ export async function cmdSiteShow(
4849 config: Config,4950 raw: string | undefined,5051 ): Promise<void> {51- const target = targetOrFail(raw, config, "usage: nzip site show <target>");52- emitSiteDetail(await new ApiClient(config).siteDetail(target));52+ const api = new ApiClient(config);53+ const target = await targetOrFail(raw, config, api, "usage: nzip site show <target>");54+ emitSiteDetail(await api.siteDetail(target));5355 }5456 55-export async function cmdSiteUpdate(57+export async function cmdSitePolicy(5658 config: Config,5759 raw: string | undefined,5860 ttlRaw: string | undefined,5961 password: string | undefined,6062 noPassword: boolean,6163 ): Promise<void> {62- const target = targetOrFail(64+ const api = new ApiClient(config);65+ const target = await targetOrFail(6366 raw,6467 config,65- "usage: nzip site update <target> [--ttl 14d|forever] [--password PW | --no-password]",68+ api,69+ "usage: nzip site policy <target> [--ttl 14d|forever] [--password PW | --no-password]",6670 );6771 if (password !== undefined && noPassword) {6872 fail("choose either --password or --no-password, not both");@@ -72,9 +76,12 @@ export async function cmdSiteUpdate(
7276 if (noPassword) patch.password = null;7377 else if (password !== undefined) patch.password = password;7478 if (Object.keys(patch).length === 0) {75- fail("site update requires --ttl, --password, or --no-password");79+ fail(80+ "site policy requires --ttl, --password, or --no-password",81+ "to publish revised content, run: nzip site push <dir|file> <target>",82+ );7683 }77- emitSiteDetail(await new ApiClient(config).patchSite(target, patch));84+ emitSiteDetail(await api.patchSite(target, patch));7885 }7986 8087 export async function cmdLs(@@ -110,8 +117,8 @@ export async function cmdRm(
110117 raw: string | undefined,111118 yes: boolean,112119 ): Promise<void> {113- const target = targetOrFail(raw, config, "usage: nzip site rm <target> [--yes]");114120 const api = new ApiClient(config);121+ const target = await targetOrFail(raw, config, api, "usage: nzip site rm <target> [--yes]");115122 const site = await api.siteDetail(target);116123 const name = site.alias ? `${site.vault}:${site.alias}` : site.address;117124 if (!yes) {@@ -180,12 +187,13 @@ export async function cmdRevert(
180187 toSeq: number | undefined,181188 list: boolean,182189 ): Promise<void> {183- const target = targetOrFail(190+ const api = new ApiClient(config);191+ const target = await targetOrFail(184192 raw,185193 config,194+ api,186195 "usage: nzip site revert <target> [--to N] [--list]",187196 );188- const api = new ApiClient(config);189197 190198 if (list) {191199 const site = await api.siteDetail(target);@@ -3,7 +3,7 @@
33 // so it stays fast enough for `cd "$(nzip site where personal:plan)"`.44 55 import { resolveCliTarget } from "../lib/api.ts";6-import { assertRawAddressAllowed, type Config } from "../lib/config.ts";6+import { assertVaultAllowed, type Config } from "../lib/config.ts";77 import { lookup } from "../lib/paths.ts";88 import { amber, dim, emit, fail } from "../lib/fmt.ts";99 @@ -16,7 +16,6 @@ export async function cmdWhere(
1616 const q = (() => {1717 try {1818 if (/^[0-9a-f]{4}$/.test(raw)) {19- assertRawAddressAllowed(config);2019 return { address: raw };2120 }2221 const [vault, alias] = resolveCliTarget(raw, config).split(":");@@ -33,6 +32,16 @@ export async function cmdWhere(
3332 "it was pushed elsewhere (or before tracking existed) — push it again from its directory to record the path",3433 );3534 }35+ if (q.address && config.allowVaults) {36+ if (!entry.vault) {37+ fail(`cannot validate the vault for locally tracked address ${q.address}`);38+ }39+ try {40+ assertVaultAllowed(entry.vault, config);41+ } catch (e) {42+ return fail((e as Error).message);43+ }44+ }3645 3746 let exists = false;3847 try {@@ -22,7 +22,8 @@ import type {
2222 VaultInfo,2323 VaultLifecycle,2424 } from "@nzip/shared";25-import { assertRawAddressAllowed, assertVaultAllowed, type Config } from "./config.ts";25+import { parseAddress, vaultSlotOf } from "@nzip/shared";26+import { assertVaultAllowed, type Config } from "./config.ts";2627 2728 export class ApiClient {2829 constructor(private config: Config) {}@@ -243,9 +244,24 @@ export class ApiClient {
243244 * Resolve a CLI target string to (API path target, commit body target).244245 * A bare alias picks up the default vault from config.245246 */246-export function resolveCliTarget(raw: string, config: Config): string {247+export function resolveCliTarget(248+ raw: string,249+ config: Config,250+ vaults: Pick<VaultInfo, "slot" | "name">[] = [],251+): string {247252 if (/^[0-9a-f]{4}$/.test(raw)) {248- assertRawAddressAllowed(config);253+ if (config.allowVaults) {254+ const slot = vaultSlotOf(parseAddress(raw));255+ const vault = vaults.find((candidate) => candidate.slot === slot);256+ if (!vault) {257+ throw new Error(258+ `cannot validate address ${raw}: authenticated status did not identify vault slot 0x${259+ slot.toString(16)260+ }`,261+ );262+ }263+ assertVaultAllowed(vault.name, config);264+ }249265 return raw;250266 }251267 if (raw.includes(":")) {@@ -266,6 +282,7 @@ export function resolveCliTarget(raw: string, config: Config): string {
266282 export function commitTargetFor(267283 raw: string | undefined,268284 config: Config,285+ vaults: Pick<VaultInfo, "slot" | "name">[] = [],269286 ): Target {270287 if (raw === undefined) {271288 const vault = config.defaultVault;@@ -274,10 +291,20 @@ export function commitTargetFor(
274291 return { vault };275292 }276293 if (/^[0-9a-f]{4}$/.test(raw)) {277- assertRawAddressAllowed(config);294+ resolveCliTarget(raw, config, vaults);278295 return { address: parseInt(raw, 16) };279296 }280- const resolved = resolveCliTarget(raw, config);297+ const resolved = resolveCliTarget(raw, config, vaults);281298 const [vault, alias] = resolved.split(":");282299 return { vault, alias };283300 }301+302+/** Resolve a target, fetching vault-slot metadata only when a raw address needs guarding. */303+export async function resolveCliTargetWithStatus(304+ raw: string,305+ config: Config,306+ api: Pick<ApiClient, "status">,307+): Promise<string> {308+ const vaults = /^[0-9a-f]{4}$/.test(raw) && config.allowVaults ? (await api.status()).vaults : [];309+ return resolveCliTarget(raw, config, vaults);310+}@@ -32,20 +32,35 @@ Deno.test("raw addresses remain available without allowVaults", () => {
3232 assertEquals(commitTargetFor("2a3f", unrestricted), { address: 0x2a3f });3333 });3434 35-Deno.test("resolveCliTarget refuses a raw address when allowVaults is set", () => {35+Deno.test("resolveCliTarget accepts a raw address in an allowed authenticated vault", () => {36+ assertEquals(37+ resolveCliTarget("2a3f", { ...restricted, allowVaults: ["home"] }, [38+ { slot: 2, name: "home" },39+ ]),40+ "2a3f",41+ );42+});43+44+Deno.test("resolveCliTarget refuses a raw address in a disallowed authenticated vault", () => {3645 assertThrows(37- () => resolveCliTarget("2a3f", restricted),38- "raw address targets are not allowed by this config because allowVaults is set",46+ () => resolveCliTarget("2a3f", restricted, [{ slot: 2, name: "work" }]),47+ 'vault "work" is not allowed',3948 );4049 });4150 42-Deno.test("commitTargetFor refuses a raw address when allowVaults is set", () => {51+Deno.test("resolveCliTarget refuses a raw address without authenticated slot metadata", () => {4352 assertThrows(44- () => commitTargetFor("2a3f", restricted),45- "raw address targets are not allowed by this config because allowVaults is set",53+ () => resolveCliTarget("2a3f", restricted),54+ "authenticated status did not identify vault slot 0x2",4655 );4756 });4857 58+Deno.test("commitTargetFor accepts a validated raw address when allowVaults is set", () => {59+ assertEquals(commitTargetFor("2a3f", restricted, [{ slot: 2, name: "home" }]), {60+ address: 0x2a3f,61+ });62+});63+4964 Deno.test("allowVaults still accepts an allowed named target", () => {5065 assertEquals(resolveCliTarget("home:demo", restricted), "home:demo");5166 assertEquals(commitTargetFor("home:demo", restricted), {@@ -8,7 +8,8 @@ export interface Config {
88 /**99 * Optional allow-list of vault names this config may target. When present,1010 * any push/site/rm/revert/default that names a vault outside the list, or11- * uses a raw address whose vault cannot be validated, is refused — so a11+ * uses a raw address whose vault cannot be validated against authenticated12+ * vault metadata, is refused — so a1213 * restricted agent can't drop content into a vault that sits adjacent to1314 * things you share professionally. Absent = no limit; empty array = no1415 * target is permitted.@@ -27,14 +28,6 @@ export function assertVaultAllowed(vault: string, config: Config): void {
2728 }2829 }2930 30-/** Throw when a raw address would bypass a configured vault allow-list. */31-export function assertRawAddressAllowed(config: Config): void {32- if (!config.allowVaults) return; // unset → unrestricted33- throw new Error(34- "raw address targets are not allowed by this config because allowVaults is set; use an allowed vault alias",35- );36-}37-3831 export function configDir(): string {3932 const xdg = Deno.env.get("XDG_CONFIG_HOME");4033 const home = Deno.env.get("HOME") ?? ".";@@ -5,7 +5,7 @@
55 // Kept clean by three forces: expired entries are dropped on every write,66 // `nzip site rm` deletes its entry, and `nzip site ls` reconciles against the live set.77 8-import { join, resolve } from "@std/path";8+import { basename, dirname, join, resolve } from "@std/path";99 import { configDir } from "./config.ts";1010 1111 export interface PathEntry {@@ -29,6 +29,30 @@ function nowSeconds(): number {
2929 return Math.floor(Date.now() / 1000);3030 }3131 32+/**33+ * Canonical identity for a pushed source. A directory and its entry-point34+ * index.html are the same logical site, while other single-file pushes retain35+ * their own file identity.36+ */37+export function sourceRoot(path: string): string {38+ let absolute: string;39+ try {40+ absolute = Deno.realPathSync(path);41+ } catch {42+ absolute = resolve(path);43+ }44+45+ try {46+ const stat = Deno.statSync(absolute);47+ if (stat.isFile && basename(absolute).toLowerCase() === "index.html") {48+ return dirname(absolute);49+ }50+ } catch {51+ // Preserve a stable best-effort identity when the source has moved.52+ }53+ return absolute;54+}55+3256 async function load(): Promise<Registry> {3357 try {3458 const parsed = JSON.parse(await Deno.readTextFile(pathsFile())) as Registry;@@ -61,8 +85,17 @@ export async function recordPush(entry: {
6185 path: string;6286 url: string;6387 expiresAt: number | null;88+ rebindSource?: boolean;6489 }): Promise<void> {6590 const reg = pruneExpired(await load());91+ if (entry.rebindSource) {92+ const root = sourceRoot(entry.path);93+ for (const [addr, existing] of Object.entries(reg)) {94+ if (addr !== entry.address && sourceRoot(existing.path) === root) {95+ delete reg[addr];96+ }97+ }98+ }6699 // An alias points at exactly one address at a time; clear any stale67100 // entry that still claims this vault:alias under a different address.68101 if (entry.vault && entry.alias) {@@ -75,12 +108,13 @@ export async function recordPush(entry: {
75108 }76109 }77110 }78- let abs: string;79- try {80- abs = Deno.realPathSync(entry.path); // canonical absolute path; file exists now81- } catch {82- abs = resolve(entry.path);83- }111+ const abs = (() => {112+ try {113+ return Deno.realPathSync(entry.path); // canonical absolute path; file exists now114+ } catch {115+ return resolve(entry.path);116+ }117+ })();84118 reg[entry.address] = {85119 address: entry.address,86120 vault: entry.vault,@@ -107,6 +141,15 @@ export async function lookup(
107141 return null;108142 }109143 144+/** Find every unexpired local breadcrumb produced from the same logical source. */145+export async function lookupBySource(path: string): Promise<PathEntry[]> {146+ const wanted = sourceRoot(path);147+ const reg = pruneExpired(await load());148+ return Object.values(reg)149+ .filter((entry) => sourceRoot(entry.path) === wanted)150+ .sort((a, b) => b.pushedAt - a.pushedAt);151+}152+110153 /** Rewrite breadcrumbs after `nzip vault update --name` so `nzip site where` keeps working. */111154 export async function renameVault(112155 oldName: string,@@ -0,0 +1,33 @@
1+import { dirname, join } from "@std/path";2+import { sourceRoot } from "./paths.ts";3+4+function assertEquals(actual: unknown, expected: unknown): void {5+ if (actual !== expected) throw new Error(`expected ${expected}, got ${actual}`);6+}7+8+Deno.test("sourceRoot treats a directory and its index.html as one logical source", async () => {9+ const root = await Deno.makeTempDir();10+ try {11+ const index = join(root, "index.html");12+ await Deno.writeTextFile(index, "<!doctype html>");13+14+ assertEquals(sourceRoot(root), Deno.realPathSync(root));15+ assertEquals(sourceRoot(index), Deno.realPathSync(root));16+ } finally {17+ await Deno.remove(root, { recursive: true });18+ }19+});20+21+Deno.test("sourceRoot preserves another single-file source identity", async () => {22+ const root = await Deno.makeTempDir();23+ try {24+ const page = join(root, "report.html");25+ await Deno.writeTextFile(page, "<!doctype html>");26+ assertEquals(sourceRoot(page), Deno.realPathSync(page));27+ if (sourceRoot(page) === dirname(Deno.realPathSync(page))) {28+ throw new Error("non-index file unexpectedly collapsed to its parent directory");29+ }30+ } finally {31+ await Deno.remove(root, { recursive: true });32+ }33+});@@ -46,10 +46,10 @@ commands:
4646 │ ├─ ls list vaults4747 │ └─ default <temporary|permanent> <name>4848 ├─ site49- │ ├─ push <dir|file> [target] [--ttl ...] [--password PW | --no-password]49+ │ ├─ push <dir|file> [target] [--new] publish content; reuse source by default5050 │ ├─ cp <target> [dir] [--overwrite] copy a hosted bundle5151 │ ├─ show <target> show site details52- │ ├─ update <target> [--ttl ...] [--password PW | --no-password]52+ │ ├─ policy <target> [--ttl ...] [--password PW | --no-password]5353 │ ├─ ls [vault] list sites5454 │ ├─ where <target> print this machine's source directory5555 │ ├─ rm <target> [--yes] delete a site@@ -68,7 +68,7 @@ agent mode: add --json to any command for one-line JSON on stdout;
6868 errors go to stderr as {"ok":false,"error":…,"hint":…} with a suggested next step.6969 7070 vault guard: set "allowVaults": ["home"] in config.json to restrict this install71-to named vaults — pushes/aliases outside the list are refused (agent guardrail).71+to named vaults — raw addresses are authenticated and mapped to a vault before use.7272 7373 notification privacy: titles and bodies may appear on a lock screen; never include7474 passwords, tokens, private URLs, or sensitive personal data.@@ -112,6 +112,7 @@ export async function main(argv = Deno.args): Promise<void> {
112112 "no-description",113113 "all",114114 "no-default-for",115+ "new",115116 ],116117 alias: { h: "help", V: "version", y: "yes" },117118 });@@ -144,6 +145,7 @@ export async function main(argv = Deno.args): Promise<void> {
144145 ttl: args.ttl,145146 password: args.password,146147 noPassword: args["no-password"],148+ newSite: args.new,147149 overwrite: args.overwrite,148150 yes: args.yes,149151 toSeq,@@ -87,6 +87,12 @@ permissions when a sandbox blocks a check instead of reporting nzip as broken.
8787 plainly needs it. Do not use `forever` by default. Vault defaults may vary, so always pass8888 `--ttl 1d` for these short-lived artifacts rather than relying on omission.8989 90+ When revising an artifact, rerun the push from the same artifact root or pass the previously91+ returned target explicitly. An untargeted push reuses a unique local breadcrumb for that logical92+ source, treating a directory and its `index.html` as the same site. If multiple sites match, it93+ stops and lists candidates; select the intended existing target to rebind later revisions.94+ Never pass `--new` unless the user asked for a separate URL.95+9096 4. Use an explicit `<vault>:<alias>` target when the destination matters, picking a vault from9197 `nzip vault ls --json`:9298 @@ -117,10 +123,10 @@ nzip
117123 │ ├─ ls118124 │ └─ default <temporary|permanent> <name>119125 ├─ site120-│ ├─ push <dir|file> [target] [--ttl DAYS] [--password PW | --no-password]126+│ ├─ push <dir|file> [target] [--new] [--ttl DAYS] [--password PW | --no-password]121127 │ ├─ cp <target> [dir] [--overwrite]122128 │ ├─ show <target>123-│ ├─ update <target> [--ttl DAYS] [--password PW | --no-password]129+│ ├─ policy <target> [--ttl DAYS] [--password PW | --no-password]124130 │ ├─ ls [vault]125131 │ ├─ where <target>126132 │ ├─ rm <target> [--yes]@@ -144,6 +150,9 @@ renaming to a name outside `allowVaults` is refused.
144150 For new sites, TTL precedence is explicit flag, existing-site expiry, vault default, then the145151 global 14-day fallback. Read `ttl` and `ttlSource` from JSON results instead of inferring them.146152 `public` describes durable retention, not access policy; it may still be password-protected.153+Use `site push` to publish revised content and `site policy` only to change TTL or password. An154+unaliased address is still a stable revision target; aliases are optional handles, not a requirement155+for retaining the same URL.147156 Treat `revert`, vault renames, password changes, TTL changes, and deletion as state changes; do not148157 perform them while merely diagnosing. Never infer deletion confirmation.149158 @@ -200,6 +209,8 @@ application concern.
200209 - Prefer finite TTLs; use `forever` only when explicitly requested.201210 - For durable lofi apps, use `app init`/`app deploy`; do not emulate the reservation with `site push`.202211 - Prefer descriptive aliases and an explicit vault when identity or audience matters.212+- Preserve a previously returned URL across revisions; use `--new` only for an intentionally213+ separate share.203214 - Preserve the local artifact directory for iteration and `nzip site where`; do not commit it.204215 - Use complete, self-contained HTML and relative asset paths.205216 - Use read-only status, list, and inspect operations before mutations when destination state is@@ -225,7 +225,7 @@ describe("Worker runtime", () => {
225225 226226 expect(response.status).toBe(200);227227 expect(await response.json()).toMatchObject({228- version: "0.9.0",228+ version: "0.10.0",229229 defaultVaults: { temporary: "personal", permanent: "public" },230230 globalDefaultTtl: 14,231231 vaults: expect.arrayContaining([