- commit
- 8a3c1cf481ec0f1a695f8e117d226554a1048784
- parent
- 84ef7ee176bb57920afffd0d8df7609c0bae2bb6
- author
- Dami <dami.etoile@gmail.com>
| M | README.md | 32 | ++++++++++++++++++++++++++++++ |
| M | cli/ | 23 | ++++++++++++++++++++++ |
| M | cli/ | 3 | +++ |
| M | skills/ | 15 | ++++++++++++++ |
| M | worker/ | 2 | +- |
| M | worker/ | 2 | +- |
| M | worker/ | 1 | + |
| M | worker/ | 12 | +++++++++++ |
@@ -59,6 +59,9 @@ Commands accept any of three target forms:
5959 change protection later.6060 - **Single-user by design.** One bearer token, stored as a Worker secret and in6161 `~/.config/nzip/config.json` (mode 0600).62+- **Owner notifications.** Pair explicitly approved phones from the quiet root63+ PWA, then send Web Push notifications from the authenticated CLI. Clicks may64+ open `/` or a manifest-pinned nzip site; arbitrary external URLs are refused.6265 - **Locates itself.** `nzip where <target>` prints the local directory this6366 machine pushed a site from. A breadcrumb registry6467 (`~/.config/nzip/paths.json`) records the source path on every push and@@ -86,6 +89,11 @@ nzip ls [vault] list sites
8689 nzip where <target> print the local dir this machine pushed from8790 nzip rm <target> [--yes] delete a site8891 nzip status server + vault overview92+nzip notify <body> [--title TEXT] [--open TARGET] [--tag TEXT]93+nzip notify test send a diagnostic notification94+nzip notify approve <code> --name NAME [--yes]95+nzip notify devices list paired notification devices96+nzip notify revoke <device-id> [--yes] revoke a notification device8997 nzip revert <target> [--to N] [--list] repoint to a previous push9098 ```9199 @@ -112,6 +120,27 @@ files—not dotfiles, `.nzipignore`, or other local project metadata excluded fr
112120 a push. The former `nzip download` command remains available as a compatibility113121 alias.114122 123+### Owner notifications124+125+Open the deployment root on a phone, tap `pair`, and approve the displayed code126+from an authenticated terminal:127+128+```sh129+nzip notify approve ABCD-1234 --name "Personal phone"130+```131+132+Wait for `paired` before installing the PWA. In the installed app, tap133+`notifications off` to request permission and attach the subscription. Send a134+message with `nzip notify "Build finished"`; add `--open work:report` to open an135+existing same-origin site when the notification is tapped. Use136+`nzip notify devices` to inspect delivery health and `nzip notify revoke` to137+remove a device.138+139+Notification titles and bodies may be visible on a lock screen. Never put140+passwords, tokens, private URLs, or sensitive personal data in them. If an141+installed app loses its pairing cookie, remove it, pair again in the browser,142+wait for `paired`, and reinstall it.143+115144 ## Architecture116145 117146 ### Overview@@ -213,6 +242,9 @@ Security alert state is deliberately separate:
213242 | `security_signals` | rate-limit confirmations, deduplicated by scanner and window |214243 | `security_incidents` | open/closed state, severity, suppression timestamps, and incident totals |215244 | `security_notifications` | durable email payloads, delivery attempts, and retry state |245+| `notification_devices` | approved claims and active Web Push subscriptions |246+| `notification_events` | bounded payloads and manifest-pinned click targets |247+| `notification_deliveries`| leased per-device attempts, retries, and terminal outcomes |216248 217249 **GC safety rule:** an R2 object is deleted only if no live site or retained218250 history entry references it _and_ it's older than 24 hours, so an in-flight push@@ -50,6 +50,11 @@ nzip ls [vault] list sites
5050 nzip where <target> print the local dir this machine pushed from5151 nzip rm <target> [--yes] delete a site5252 nzip status server + vault overview53+nzip notify <body> [--title TEXT] [--open TARGET] [--tag TEXT]54+nzip notify test send a diagnostic notification55+nzip notify approve <code> --name NAME [--yes]56+nzip notify devices list paired notification devices57+nzip notify revoke <device-id> [--yes] revoke a notification device5358 nzip revert <target> [--to N] [--list] repoint to a previous push5459 ```5560 @@ -66,5 +71,23 @@ It uses the configured bearer token, verifies file hashes, and never exposes sou
6671 page URL. Only uploaded files can be restored; ignored dotfiles and local metadata were never6772 stored. The former `nzip download` command remains available as a compatibility alias.6873 74+## Notifications75+76+Pairing starts from the deployment root. After the phone shows a code, approve it from the77+authenticated CLI:78+79+```sh80+nzip notify approve ABCD-1234 --name "Personal phone"81+nzip notify devices82+nzip notify "Report ready" --title "nzip" --open work:report83+```84+85+`--open` accepts an existing nzip target and applies the same local vault guardrails as other86+target-aware commands. It never accepts an arbitrary URL. `nzip notify test` uses the ordinary87+notification delivery path. Use `nzip notify revoke <device-id> --yes` to remove a device.88+89+Notification content may appear on a lock screen. Never include passwords, tokens, private URLs, or90+sensitive personal data in a title or body.91+6992 See the [project README](https://github.com/FelineStateMachine/nzip) for how addresses work,7093 self-hosting setup, and architecture. MIT licensed.@@ -62,6 +62,9 @@ errors go to stderr as {"ok":false,"error":…,"hint":…} with a suggested next
6262 6363 vault guard: set "allowVaults": ["home"] in config.json to restrict this install6464 to named vaults — pushes/aliases outside the list are refused (agent guardrail).65+66+notification privacy: titles and bodies may appear on a lock screen; never include67+passwords, tokens, private URLs, or sensitive personal data.6568 `;6669 6770 async function main(): Promise<void> {@@ -101,6 +101,13 @@ Prefer `--json` when consuming command output programmatically.
101101 - Review history: `nzip revert <target> --list --json`102102 - Restore history: `nzip revert <target> --to <push-number>`103103 - Delete only on explicit request: `nzip rm <target> --yes`104+- List notification devices: `nzip notify devices --json`105+- Approve a user-supplied pairing code:106+ `nzip notify approve <code> --name <name> [--yes]`107+- Send only on explicit request:108+ `nzip notify <body> [--title TEXT] [--open TARGET] [--tag TEXT]`109+- Exercise the ordinary delivery path: `nzip notify test`110+- Revoke only on explicit request: `nzip notify revoke <device-id> --yes`104111 105112 Use password protection when the content or user requires it; `nzip push` accepts the same106113 `--password <pw> | --no-password` flags to set it at publish time. Vault descriptions appear in@@ -110,6 +117,14 @@ records, but other clients keep the old name; renaming to a name outside `allowV
110117 Treat `revert`, vault renames, password changes, TTL changes, and deletion as state changes; do not111118 perform them while merely diagnosing. Never infer deletion confirmation.112119 120+Notifications are external, lock-screen-visible side effects. Do not send,121+test, approve, or revoke unless the user explicitly requests that action. Never122+put credentials, private URLs, sensitive personal data, or other secrets in a123+notification title or body. Prefer `--open <target>` for click-throughs because124+the CLI resolves an existing nzip site, applies vault guardrails, and sends only125+a same-origin path. A pairing code is short-lived but is not sufficient on its126+own; approval still uses the configured owner bearer token.127+113128 ## Sane defaults114129 115130 - Keep generated HTML in `.nzip-<purpose>/`, not a system temp directory or a tracked source@@ -289,7 +289,7 @@ function escapeHtml(value: string): string {
289289 /** Claim-independent shell. Safe to cache publicly; all state is fetched client-side. */290290 export function notifyLandingPage(env: NotifyEnv): string {291291 const host = escapeHtml(new URL(env.PUBLIC_BASE).host);292- return `<!doctype html><html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover"><meta name="theme-color" content="#1e1810"><title>${host}</title>292+ return `<!doctype html><html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1, maximum-scale=1, user-scalable=no, viewport-fit=cover"><meta name="theme-color" content="#1e1810"><title>${host}</title>293293 <style>*{box-sizing:border-box}body{background:#1e1810;color:#e7dbc5;font-family:ui-monospace,monospace;margin:0;min-height:100dvh;display:flex;flex-direction:column}main{flex:1;display:grid;place-items:center;padding:24px;text-align:center}.landing{display:grid;gap:24px;justify-items:center}.wordmark{color:#f2e8d4;font-size:clamp(40px,10vw,72px);font-weight:700;letter-spacing:-.18em;line-height:.8}.wordmark .z{color:#ff6b4a}#notify-status{color:#8a8172;font-size:12px;line-height:1.6;white-space:pre-line;min-height:3.2em;letter-spacing:.02em}footer{text-align:center;padding:14px 16px calc(14px + env(safe-area-inset-bottom));color:#6b6355;font-size:12px}a,button{color:#6b6355;font:inherit;text-decoration:underline}button{appearance:none;background:none;border:0;padding:0;cursor:pointer}a:hover,button:hover{color:#d99a5b}button:focus-visible,a:focus-visible{outline:2px solid #ffb347;outline-offset:4px}button:disabled{cursor:default;text-decoration:none}</style></head>294294 <body><main><div class="landing"><div class="wordmark" aria-label="nzip">n<span class="z">z</span>ip</div><div id="notify-status" aria-live="polite"></div></div></main><footer><a href="https://args.io/cat/nzip">args</a> · <button id="pair" type="button">pair</button></footer><script>${APP_SCRIPT}</script></body></html>`;295295 }@@ -53,7 +53,7 @@ function publicSiteRedirect(url: string, address: string): Response {
5353 }5454 5555 function unlockForm(address: string, error?: string): string {56- return `<!doctype html><meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover"><meta name="robots" content="noindex"><title>unlock — nzip</title>56+ return `<!doctype html><meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1, maximum-scale=1, user-scalable=no, viewport-fit=cover"><meta name="robots" content="noindex"><title>unlock — nzip</title>5757 <style>*{box-sizing:border-box}body{background:#121110;color:#d6cfc2;font-family:ui-monospace,monospace;display:grid;place-items:center;min-height:100vh;min-height:100dvh;margin:0;padding:max(24px,env(safe-area-inset-top)) max(16px,env(safe-area-inset-right)) max(24px,env(safe-area-inset-bottom)) max(16px,env(safe-area-inset-left))}form{width:min(100%,420px);text-align:center}b{color:#ffb347;font-size:24px;display:block;margin-bottom:20px}.field{display:flex;width:100%}input{background:#1a1815;border:1px solid #2e2a24;color:#d6cfc2;min-width:0;flex:1;padding:12px 14px;font:inherit;font-size:16px;line-height:20px;outline:none;border-radius:0}input:focus{border-color:#ffb347}button{background:#ffb347;border:0;color:#121110;min-height:46px;padding:12px 18px;font:inherit;font-weight:700;cursor:pointer;border-radius:0}.e{color:#f7768e;margin-top:12px;font-size:13px}@media(max-width:360px){.field{display:grid;gap:10px}button{width:100%}}</style>5858 <form method="post" action="/${address}/__unlock"><b>🔒 ${address}</b><div class="field"><input type="password" name="password" aria-label="password" placeholder="password" maxlength="256" autofocus autocomplete="current-password"><button>unlock</button></div>${5959 error ? `<div class="e">${error}</div>` : ""@@ -18,6 +18,7 @@ describe("Worker runtime", () => {
1818 1919 expect(response.headers.get("set-cookie")).toBeNull();2020 expect(html).toContain('id="pair"');21+ expect(html).toContain("maximum-scale=1, user-scalable=no");2122 expect(html).not.toContain('<link rel="manifest"');2223 const count = await env.DB.prepare(2324 "SELECT COUNT(*) AS count FROM notification_devices",@@ -138,6 +138,18 @@ test("unlock requires a declared request size", async () => {
138138 assert.equal(response.status, 411);139139 });140140 141+test("unlock page disables mobile zoom", async () => {142+ const url = new URL("https://n.zip/2f9b");143+ const response = await serve(144+ new Request(url),145+ envFor({ "index.html": html }, { password_hash: "hash" }),146+ url,147+ );148+149+ assert.equal(response.status, 401);150+ assert.match(await response.text(), /maximum-scale=1, user-scalable=no/);151+});152+141153 test("unlock rejects an oversized declared body before password verification", async () => {142154 const url = new URL("https://n.zip/2f9b/__unlock");143155 const response = await serve(