From 2c13c301b4ada5ada0935248b3bf1ddba045da05 Mon Sep 17 00:00:00 2001 From: Han Ngo Date: Thu, 1 Oct 2026 16:58:49 +0700 Subject: [PATCH 01/55] docs: spec for one hostname per tenant with optional cloud storage A tenant is one hostname with one shared list. Local storage on the origin machine stays the default; R2 is optional per tenant and per link. Covers the Worker pass-through, Share Bar row badges, and both migrations. --- docs/specs/SPEC-008-one-host-per-tenant.md | 515 +++++++++++++++++++++ 1 file changed, 515 insertions(+) create mode 100644 docs/specs/SPEC-008-one-host-per-tenant.md diff --git a/docs/specs/SPEC-008-one-host-per-tenant.md b/docs/specs/SPEC-008-one-host-per-tenant.md new file mode 100644 index 0000000..c31fb0f --- /dev/null +++ b/docs/specs/SPEC-008-one-host-per-tenant.md @@ -0,0 +1,515 @@ +# SPEC-008: one hostname per tenant, cloud storage optional per link + +Status: DRAFT +Lane: full (an API and data contract between the CLI, the Worker, and Share Bar; a new public route in front of live links; a migration of two production hostnames) +Depth: research (outside: a route Worker passing requests through to a tunnel on its own hostname, Access order against a route Worker, custom-domain rebind, Access destination edits; TASK-1 samples each before the task that consumes it) | blind-spot (failure: the Worker passes a request to the tunnel that a cloud record or a gate should have stopped, or the migration leaves a link dead) +References: SPEC-004 (Access gate), SPEC-005 (profiles), SPEC-006 (Share Bar across profiles), SPEC-007 (R2 backend), ADR-0002, ADR-0003, ADR-0005, ADR-0006, ADR-0007; `bin/share` `cmd_add`, `cmd_add_r2`, `rand_id`, `rows`, `share_url`, `cmd_ls`, `cmd_state`, `cmd_state_r2`, `cmd_profiles_json`, `cmd_setup`, `cmd_setup_r2`, `cmd_teardown`, `worker_js`, `r2_call`, `r2_snapshot`, `r2_healthz`; `mac/Sources/ShareBarCore/Snapshot.swift`, `MenuModel.swift`; `mac/Sources/ShareBar/StatusItemController.swift`. +Supersedes: SPEC-007 DEC-006 (a second hostname `f.d.foundation` beside `s.d.foundation`); SPEC-007 `## Out of scope` items "Share Bar reading an r2 default profile" (now in scope through the Keychain token only) and "migrating a tunnel profile's shares into a bucket" (now `share migrate` between machines; tunnel to bucket stays out). + +## Problem + +A tenant today can be two hostnames. Dwarves publishes machine-local links at `s.d.foundation` (the `dfoundation` tunnel profile on the Mac Mini) and cloud links at `f.d.foundation` (the `files` r2 profile, bucket `share-dfoundation`). People must know which hostname holds which link, and Share Bar shows two sections for one team. The personal tenant `s.han.ws` is served from the MacBook Air, so every personal link answers 530 while the Air sleeps. + +The operator direction (Han, firm): + +- A tenant is ONE hostname with ONE shared list. +- Storage is local by default: the origin machine's files through the tenant's tunnel, exactly as share works today. +- R2 is optional per tenant. It is off until the tenant admin enables it. Once on, each link picks local or cloud (`--cloud`), with a tenant default the admin sets. +- Exactly one always-on machine per tenant is the tunnel origin. The Mini is the origin for both tenants. +- Storage shows as a small indicator in the app, never as a separate subdomain. Share Bar rows also show the file type and the link type. + +## Context (research, 2026-10-01) + +Read-only probes from the Mini with the Toolkit API token (names, codes, and counts only; no id, token, or account id quoted). Commands: `## Grounding`. + +| Item | State | +|---|---| +| Mini profiles | `default` not set up; `dfoundation` serving `s.d.foundation` (tunnel `share-s-d-foundation`, `hosts=Mac-mini`, port 8791, one gated snapshot `a68960`); `files` serving `f.d.foundation` (`backend=r2`, bucket `share-dfoundation`, one gated cloud share `ba6377`) | +| `d.foundation` zone | `s.d.foundation` is a proxied CNAME to the tunnel; `f.d.foundation` is the Worker custom domain of `share-f-d-foundation` (an AAAA record the domain manages); one existing route Worker on the zone (`chat.d.foundation/dw-chat/*`), so route Workers work on this plan | +| Dwarves Access apps named `share ...` | one on `f.d.foundation/ba6377{,/*}`, one on `s.d.foundation/a68960{,/*}` | +| The guide link | `https://f.d.foundation/ba6377/support-ticket-guide/` answers 302 to the Dwarves Access login with `kid` equal to the app's AUD (gated cloud share) | +| `han.ws` zone (account "Han Ngo") | `s.han.ws` is a proxied CNAME to tunnel `air-share`, healthy with 4 connections (the Air is online now); R2 enabled, no share bucket; Analytics Engine answers; one route Worker exists (`radar.han.ws/*`); no Access app named `share ...`, so no personal gated share exists | +| Reachability | the Air reaches the Mini over ssh; the Mini cannot reach the Air | + +## Picture + +The request path for one tenant hostname: + +``` + visitor https://s.d.foundation// + https://f.d.foundation/ -- Worker answers 301 --> https://s.d.foundation/ + | + v + Cloudflare edge: TLS; per gated share an Access app on / and //* + | (one app per link, either storage, as today) + v + +-- R2 off (s.han.ws) -----------+ +-- R2 on (s.d.foundation) ---------------------------+ + | no Worker; the CNAME sends the | | Worker share- on route /* | + | request to the tunnel (today) | | 400 on %2F %5C %2E, low escapes, // | + +---------------+----------------+ | /healthz: own answer plus a probe of the tunnel | + | | GET m/ from the bucket | + | | cloud record -> R2 bytes (SPEC-007 checks, the | + | | Access JWT for a gated record) | + | | machine record, no record, R2 error | + | | -> fetch(request): same host, the route is | + | | skipped, the request goes to the origin | + | +--------------------------+--------------------------+ + v v + tunnel (one per tenant) --> cloudflared on the origin (the Mini) --> caddy --> pub/ or a live port +``` + +Who writes what: + +``` + origin (Mini) member (any other machine, R2 on only) + share add f -> pub/ share add f -> o/./ + m/ (cloud) + share add --cloud f -> o/ + m/ share add --local f, add 3000 -> refused: not the origin + share add 3000 -> live row + every local add, R2 on: m/ = {"v":2,"storage":"machine",...} (reserves the id, feeds the list) + \___________________ bucket share- ___________________/ + m/ records = the one shared list +``` + +## Design + +### Approaches considered + +| Question | Options | Chosen, with the reason | +|---|---|---| +| Default storage | R2 for snapshots, the tunnel for live and `--local`; the tunnel for everything, R2 per link | the tunnel (operator direction, 2026-10-01). A tenant with R2 off is today's share, byte for byte, with zero new infrastructure. R2 adds survival while the origin is off and publishing from other machines, and the tenant pays for it only when the admin turns it on. | +| How cloud and machine links share one hostname | two hostnames (today); a Worker in front that routes per id; a tunnel in front that proxies cloud ids to R2 | a Worker in front, only on tenants with R2 on. The edge already terminates TLS and runs Access there; a Worker can read the bucket through its binding with no token. A tunnel in front would need the origin up to serve cloud links, which defeats them. | +| How the Worker reaches the tunnel | (A) a route `/*` over the existing proxied CNAME, `fetch(request)` passes through to the origin; (B) a second, internal origin hostname behind an Access service token, the Worker fetching it with the token's secret; (C) the Worker as custom domain and the tunnel on another name | (A). The tunnel and its CNAME stay exactly as they are, so deleting the route is the whole rollback. No new hostname, DNS record, Access app, or service-token secret exists (the Worker keeps holding no credential, SPEC-007's rule). (B) costs a hostname, a secret with an expiry, and a second ingress rule, and its token would open every machine link to anyone holding it. (C) would change every existing link. TASK-1(a) proves the pass-through. | +| Order inside the Worker | the tunnel first, R2 on 404; R2 first, the tunnel otherwise | R2 first (operator direction): one bucket read decides, the origin is never asked about a cloud link, and cloud links answer while the origin is off. | +| The shared list across machines | merge only on the origin (its index plus the bucket); a pointer record `m/` for every machine link | pointer records, written only when R2 is on. A member's `ls` and Share Bar see the origin's links too, and one namespace means a cloud add can never take an id the origin holds (`If-None-Match: *` on one key). With R2 off there is one publisher, so its index is the list. | +| The old hostname `f.d.foundation` | keep both; a Cloudflare redirect rule plus a placeholder DNS record; the tenant Worker answers 301 for an alias host | the tenant Worker takes the alias's custom domain and answers 301 to the same path on the tenant host. One Worker per tenant; the redirect lives in code the suite tests and the CLI deploys. | +| Moving the Air's shares to the Mini | publish through R2 and back; rsync plus a hand-edited index; `share migrate --to `, the receiver's own `share import` writing each share | `migrate` over ssh: the Air can reach the Mini, R2 is off on `s.han.ws`, and the receiving CLI validates and writes its own index (the only writer, as ADR-0003 keeps the app out of share's files). Ids and paths stay, so every link stays. | + +### Tenant, origin, member + +- A tenant is one hostname. Its origin is the one machine in the tunnel profile's `hosts=` (already a single machine: Cloudflare load-balances between connectors, so two serving machines give random 404s). +- R2 off: the origin is the only publisher. Other machines have no profile for the tenant; `share setup --quick` stays available to them as today. +- R2 on: the origin profile keeps its tunnel and gains `bucket=`, `r2_endpoint=`, and `storage_default=`. Any other machine is a member: a SPEC-007 r2 profile joined with a publisher token (`share --profile

setup --backend r2 --bucket `). A member publishes cloud links only. +- `docs/setup.md` onboarding says so plainly: a teammate can publish to a tenant only when its R2 is on. + +### Enabling R2 on a tenant (admin, on the origin) + +`CLOUDFLARE_API_TOKEN= share [--profile

] setup --r2 --bucket [--storage-default local|cloud] [--alias ]` + +It runs on the origin only (`host_ok`, else `run this on the origin, `), on a tunnel profile already set up for ``. SPEC-007's setup steps hold where this table does not replace them. Every read and every refusal comes before the first write. + +| Step | Call | Refuse or stop when | +|---|---|---| +| 1 | profile checks | not a named tunnel profile for ``; quick mode; `--alias` equal to `` or not a hostname | +| 2 | zone, account, role | the token cannot read `workers/scripts//settings` (only an admin enables R2) | +| 3 | SPEC-007 steps 4 and 5 on the bucket and the Worker `share-` | as SPEC-007, with one change: a marker naming `--alias` instead of `` is accepted (the fold) | +| 4 | with `--alias`: `GET workers/domains?hostname=` | the alias's custom domain names a service that is not `share-`, or that Worker's bindings are not `HOST == ` and `BUCKET == ` | +| 5 | `GET zones//workers/routes` | a route on `/*` or a wider pattern naming another script | +| 6 | the bucket snapshot (every `m/` record) and the local index | a local row's id holds a cloud record (a collision; named, nothing written) | +| 7 | with `--alias`: every gated cloud record's Access app (`GET access/apps/`) | an app whose name is not `share `, or no Apps Edit scope | +| 8 | deploy the Worker (bindings below), subdomain and previews off with the read-back (SPEC-007 step 10) | read-back not `false,false` | +| 9 | pointer records: `PUT m/` with `If-None-Match: *` for every local row | a 412 on an id step 6 saw free: die, the route is not attached yet | +| 10 | with `--alias`: each step-7 app gains `/` and `//*` (PUT keeps every field and the AUD; TASK-1(g)); the gate probe on the new destinations passes three rounds (SPEC-004) | gate timeout: the added destinations are removed, die | +| 11 | marker `PUT share.json` with `If-Match`: `{"v":1,"host":"","aliases":[""]}` | 412 | +| 12 | `POST zones//workers/routes {pattern:"/*", script:}` | an API error | +| 13 | with `--alias`: `PUT workers/domains {hostname:, service:}` (TASK-1(f): rebind in place, or DELETE then PUT with the gap measured) | an API error | +| 14 | live checks: `/healthz` three times in a row with this CLI's `X-Share-Worker` pair and `X-Share-Tunnel: 1`; a probe file through the tunnel leg; with `--alias`: `https:///healthz` answers 301 to `https:///healthz` | timeout: die naming the leg; the route stays (the tunnel leg serves through it), and `--no-r2` is the named rollback | +| 15 | with `--alias`: each step-7 app drops its `` destinations; the local profile whose `hostname` is `` hands its `r2-own` lines to this profile | | +| 16 | write `bucket=`, `r2_endpoint=`, `storage_default=` (default `local`), `aliases=` into the config | | + +Worker bindings on a tenant: SPEC-007's set plus `PASS="1"` (pass misses to the origin) and `ALIASES` (comma list). An r2-only profile (SPEC-007, custom domain) keeps `PASS=""` and today's answers. + +`share [--profile

] setup --no-r2` (admin token, on the origin) is the rollback: delete the route, then drop the four config keys. The tunnel serves the hostname alone again. Pointer records, cloud records, the bucket, and the Worker stay; `teardown --yes --purge` on a member, or the purge path on the origin, removes them as SPEC-007 says. + +### Per-link storage on the origin + +| `add` form | R2 off | R2 on, `storage_default=local` | R2 on, `storage_default=cloud` | +|---|---|---|---| +| `add ` | local | local | cloud | +| `add --cloud ` | refused: `R2 is off for ; the tenant admin enables it with ' setup --r2 --bucket '` | cloud | cloud | +| `add --local ` | local | local | local | +| `add `, `--host` | local (live, own host) | local | local | +| `--cloud` with a port or `--host` | refused: `live links and --host stay on the origin's tunnel` | refused | refused | +| `--cloud` with `--local` | refused (usage) | refused | refused | + +A cloud add on the origin runs SPEC-007's `cmd_add_r2` path unchanged. A member runs it for every add; `--local` and `` on a member die with ` serves local links from ; this machine publishes cloud links only`, where `` is the `by` of the newest machine record, else `the origin`. + +With R2 on, a local add on the origin also writes the id's pointer record: + +1. `rand_id` checks the local index, `pub/`, and `access-pending` as today, then the bucket as SPEC-007's r2 branch does (`GET m/`, the `o/.` prefix scan). +2. After the stage is built and before `stage_publish` (and, for a gated add, before the Access app): `PUT m/` with `If-None-Match: *` and `{"v":2,"id","storage":"machine","name","by","added","expires","opts","type"}`. `opts` keeps `noindex`, `live`, and `access_rule=`; it never carries `access=` or `host=`. A 412 picks a fresh id; any other failure dies before anything is served, naming R2 (a tenant with R2 on keeps one namespace, so a local add fails closed). +3. `rm` and `prune` of a local row delete its pointer after the local removal. Without a resolvable token (the login service's hourly prune runs with `SHARE_API_TOKEN_OFF=1`) the pointer stays; it is harmless (the Worker passes the id to the tunnel, which answers 404), and the next interactive `ls` or `prune` on the origin, or any member's `prune` once `expires` is past, deletes it. +4. Reconcile on the origin's interactive `ls` and `prune`: a machine record whose `by` is this machine, with no local row, older than 10 minutes, is deleted (an add that died after step 2); a local row with no pointer gets one (`If-None-Match: *`; a 412 means a cloud record holds the id: printed as ` is shadowed by a cloud link; rm one of them`, never auto-deleted). + +Record versions: cloud records stay `v:1` and gain an optional `type` field that `v:1` readers ignore, so a v0.8.0 CLI and Worker keep reading every cloud record. Machine records are `v:2` (`WORKER_RECORD_V` becomes 2): a v0.8.0 CLI drops them from `ls` and its orphan sweep skips with a warning, and a v0.8.0 Worker answers 404 for them, which is correct for a Worker that cannot pass through. + +### The Worker (WORKER_VERSION 3) + +Checks run in this order. Rows marked "as SPEC-007" are unchanged. + +| # | Request | Answer | +|---|---|---| +| 1 | Host is in `ALIASES` | GET or HEAD: 301 to `https://`; other methods: 405 | +| 2 | Host is not exactly `HOST` | 404 | +| 3 | raw path holds `%2F`, `%5C`, `%2E`, a low escape, or `//` | 400 (as SPEC-007; now also in front of the tunnel leg) | +| 4 | `/healthz`, `PASS` empty | as SPEC-007 | +| 5 | `/healthz`, `PASS` set | the Worker fetches `https:///healthz` (the route is skipped, TASK-1(a)) with a 3 s timeout. A `200 ok` sets `X-Share-Tunnel: 1` and answers `200 ok`; anything else sets `X-Share-Tunnel: 0` and answers `503 tunnel down`. Both carry `X-Share-Worker` and `X-Share-Gate` | +| 6 | first segment is not 6 lowercase hex | `PASS` set: pass through (step 9); else 404 | +| 7 | `GET m/` returns a record with no `storage` or `storage == "cloud"` | SPEC-007 rows: version, id, prefix, and expiry checks (404 on any), the JWT for a gated record (404), then GET or HEAD served from R2 (other methods 405), hits counted | +| 8 | a `v:2` record whose `storage` is `machine`; no record; or the bucket read throws | `PASS` set: pass through; else 404 | +| 9 | pass through | `fetch(request)`, any method, the body streamed, the original headers (the Access JWT and cookie included). The origin's answer goes back unchanged (a WebSocket upgrade included, TASK-1(d)), except a 502, 503 that is not the origin's own, 520 to 527, or 530 (TASK-1(e) pins the set): `503`, `Retry-After: 60`, `Cache-Control: no-store`, `X-Robots-Tag: noindex, nofollow`, body `the machine serving this link is offline; try again later`. No hit is counted | + +A pass-through never serves R2 bytes, and a cloud record never reaches the tunnel. A gated machine link keeps its gate at the edge: Access runs before a route Worker (TASK-1(b)) and the Worker forwards the visitor's credentials (TASK-1(c)). An expired or malformed cloud record answers 404 and is never passed through, so a stale cloud id cannot fall back to a same-id file on the origin. Caddy keeps its own `@encsep` rule, so the origin refuses encoded separators even with the route deleted. + +Every s.d.foundation request now runs the Worker and one bucket read (two for a cloud hit). Dwarves is on the Workers paid plan; live dev-server links (many small requests) cost Worker requests in the included 10 million. + +### One shared list + +`ls`, `state`, and `profiles --json` read one row set per profile: + +| Profile | Rows | +|---|---| +| origin, R2 off | the local index, as today | +| origin, R2 on | the local index, plus every bucket record that is not a machine record whose id is in the local index (the local row wins) | +| member | every bucket record | + +Each row carries: + +| Field | Values | Source | +|---|---|---| +| `storage` | `machine` or `cloud` | the row's origin | +| `kind` | `snapshot` or `live` (the link type, unchanged) | the index opts, the record `opts` | +| `type` | `pdf`, `image`, `video`, `audio`, `folder`, `site`, `markdown`, `archive`, `text`, `other` | the table below | +| `by` | the machine that published it: the origin's own `this_host` for local rows, the import's `by=` for migrated rows, the record's `by` for bucket rows | | +| `access` | the rule or `null` (unchanged) | | + +`share ls` prints one tag before each link: `cloud`, `machine`, or `live` (a live row is always machine), then the type and `by=`. + +File type, first match wins: + +| `type` | Rule | +|---|---| +| `site` | a live link; a folder holding `index.html` at its root; a single `.html` or `.htm` file | +| `folder` | any other folder | +| `markdown` | `.md`, `.markdown` | +| `pdf` | `.pdf` | +| `image` | `.png .jpg .jpeg .gif .webp .svg .heic .ico .avif` | +| `video` | `.mp4 .mov .webm .m4v .mkv` | +| `audio` | `.mp3 .wav .m4a .aac .flac .ogg` | +| `archive` | `.zip .tar .gz .tgz .bz2 .xz .7z .rar .dmg` | +| `text` | `.txt .csv .json .yaml .yml .toml .xml .log .sh .py .js .mjs .ts .go .rs .rb .swift .java .c .h .css .sql` | +| `other` | anything else | + +Local rows are typed at read time from `pub/` (`-d`, `-f`). New cloud records and pointers store `type` at add time. A `v:1` cloud record without `type` is typed by its name's extension, and a name with no extension counts as `folder`. + +`state` (schema stays 1; every addition is a new key): `shares[].storage`, `shares[].type`, `shares[].by`; top-level `r2` (`true` when the profile reads a bucket) and, on an origin with R2 on, `storage_default`. When bucket rows cannot be read, `state` still prints the local rows and adds `cloud_error` (the reason line), so the menu never loses machine links to an R2 problem. + +Token rule for listings: `state` and `profiles --json` read the bucket only with a token from the Keychain item or the 600 file. A profile whose token source is `api_token_cmd` (it may prompt) or the environment gets `cloud_error: "the menu reads cloud links only with a stored token: api-token"`. `ls` uses the full resolution, as SPEC-007. `cmd_profiles` stops setting `SHARE_STATE_BRIEF` for r2 profiles, so member sections list rows too (one `m/` listing plus one parallel record read per profile per refresh; at Class A and Class B list prices and a 60 s poll, under $1 a month at 500 records). + +### Share Bar + +One section per profile, as SPEC-006. After the migration the Mini shows `default · s.han.ws` and `dfoundation · s.d.foundation`; the `files` section is gone. Each row: + +``` + [type icon] support-ticket-guide 30d left [storage] [link type] [lock] +``` + +- `NSMenuItem.image` is the file-type symbol (template). +- The trailing column, after the expiry text, holds three text attachments: the storage badge, the link-type marker, and `lock.fill` on a gated row (SPEC-006's lock moves from `image` into this group). +- The accessibility title is `, , , , ` plus `, login required` on a gated row. +- A section whose state has `cloud_error` gets one disabled line `Cloud links not listed: `. + +SF Symbols (every one ships on macOS 13, the package minimum; a unit test resolves each with `NSImage(systemSymbolName:)` and the app falls back to no image when one fails): + +| Role | Value | Symbol | Accessibility word | +|---|---|---|---| +| type | `pdf` | `doc.richtext` | PDF | +| type | `image` | `photo` | image | +| type | `video` | `film` | video | +| type | `audio` | `waveform` | audio | +| type | `folder` | `folder` | folder | +| type | `site` | `globe` | site | +| type | `markdown` | `doc.plaintext` | Markdown | +| type | `archive` | `archivebox` | archive | +| type | `text` | `doc.text` | text | +| type | `other` (or an unknown value) | `doc` | file | +| storage | `machine` | `desktopcomputer` | on this tenant's machine | +| storage | `cloud` | `cloud` | in the cloud | +| link | `snapshot` | `doc.on.doc` | snapshot | +| link | `live` | `dot.radiowaves.right` | live server | +| access | gated | `lock.fill` | login required | + +A row with no `storage` (an older CLI) shows no storage badge; a row with no `type` uses `doc`. `ShareBarCore` holds the mapping as plain strings (`RowGlyphs.type(_:)`, `.storage(_:)`, `.link(_:)`), so it is unit-tested without AppKit. + +The publish dialog gains a `Storage` popup only for a profile whose state has `r2: true` and is the origin (`serves_here`): `On ` or `In the cloud`, preselected from `storage_default`; the argv gains `--cloud` or `--local`. A member profile shows `In the cloud` as a disabled label. + +### Moving a tenant's origin: `share migrate` + +`share [--profile

] migrate --to [--remote-profile ] [--remote-bin ] [--yes]`, run on the current origin while it is online. It needs `CLOUDFLARE_API_TOKEN` (Tunnel Edit, DNS Edit, Zone Read on the tenant's zone) and an ssh login on the target as the user who will run share there. + +``` + Air (old origin) Mini (new origin), over ssh + 1 preflight: named tunnel profile, this host in remote: import --probe -> "share-import 1"; + hosts, no --host row, token resolves --profile state: not_setup, or set up + for the same hostname + 2 per snapshot row: --profile import + tar -C pub/ -cf - . | ssh -------------------> + (stdin: the tar; validates, stages, publishes) + 3 the switch: the token on ssh stdin --------------> CLOUDFLARE_API_TOKEN from stdin; + --profile setup --force + (new tunnel share-, CNAME repointed, + service, live check) + 4 verify each moved link through https:/// (200, or the Access 302 for a gated one) + 5 retire: moved rows -> index.migrated, pub/ -> migrated/ (nothing deleted), + then the teardown path: service, tunnel air-share deleted, DNS left alone (it no longer + points at this tunnel), tokens forgotten +``` + +- `import` is a new verb (one usage line: `share import ... used by migrate over ssh`). It refuses: an id that is not 6 lowercase hex, or that exists in the local index, `pub/`, or `access-pending`; a name that is empty, starts with `.`, holds `/` or a control byte; opts other than `noindex`, `access=`, `access_rule=` (a live or `host=` row never arrives); a `by` outside the hostname character set. It extracts the tar into a fresh stage with `tar -xf - -C ` and refuses the whole import when the stage holds anything other than regular files and directories, a dotfile, or a path that left the stage (TASK-1(j) pins the tar flags on bsdtar and GNU tar). It then publishes as `stage_publish` does and appends the row with the original added date and expiry, `by=` added to opts, and `src` set to `:`. It reloads caddy when the profile is serving and works on a `not_setup` profile (pub and index need no setup). It never creates an Access app; the gated row keeps its `access=`, because the app's destinations (`/`) do not change with the machine. +- `refresh` of a row whose `src` starts with `:` for another host dies with ` was moved from ; re-add it from a source on this machine`. +- Live rows and their ports are listed as `not moved: live link (port ); after the move run ' add ' on the new origin`. `migrate` refuses while a `--host` row exists (` has its own hostname on this tunnel; remove it first: rm `). +- Stop points: a failure in steps 1 and 2 changes nothing on this machine and leaves imported copies on the target, unserved unless its profile serves; a rerun skips ids the target already holds with the same name and added date. A failure in step 3 leaves this machine serving; the DNS record may already point at the new tunnel, and `migrate` then prints the rollback, ` setup --force` on this machine (it reuses `air-share` and repoints the CNAME). A failure in step 4 stops before step 5 and names each link that did not answer. +- `--yes` skips the one confirmation (`Move links of to and retire this machine as its origin? [y/N]`). +- Test seam: `SHARE_MIGRATE_SSH` replaces `ssh -o BatchMode=yes ` with a local command (for example `env HOME=

`), so the suite and the rehearsal run both sides on one machine. + +What visitors see: before the run, the Air serves (530 whenever it sleeps, as today). During step 3, between the CNAME change and the Mini's connector coming up, a few seconds of 530 (TASK-1(i) measures it in the rehearsal). After it, the Mini serves every moved link at the same URL. Live links from the Air end at step 5. + +### Tokens + +SPEC-007's model holds: an admin token from the environment for setup work, never stored; one bucket-scoped publisher token per machine, stored with `share api-token`, with an `expires_on`. + +| Role | Scopes | Source | +|---|---|---| +| admin, `setup --r2`, `--no-r2` | SPEC-007's minimal admin set (Workers Scripts Edit on the account, DNS Read and Zone Read on the zone, Workers R2 Storage Edit), plus Workers Routes Edit on the zone (the route), plus Access Apps and Policies Edit and Access Organizations Read (the alias fold's destination edits and the `TEAM` binding) | `op://Toolkit/cf-api-token/credential` when TASK-1(h) shows it holds every scope on the tenant's account; else a run-scoped token minted through `op://Toolkit/cf-tokens-admin` (`POST /user/tokens`, `expires_on` one day) | +| `migrate` switch | Cloudflare Tunnel Edit, DNS Edit, Zone Read on the tenant's zone | `op://Toolkit/cf-api-token/credential` when TASK-1(i) shows it edits tunnels and DNS on the personal account; else a run-scoped token as above | +| origin publisher (Mini, `dfoundation`) | Workers R2 Storage Bucket Item Write on `share-dfoundation` only, plus SPEC-004's Access scopes (gated adds), plus Zone Read | minted once through `cf-tokens-admin`, `expires_on` set, stored with `share --profile dfoundation api-token`; `r2_publisher_check` still refuses an admin or account-wide R2 token | +| member publisher | as SPEC-007 | as SPEC-007 (DEC-007 there: one per teammate) | + +No token reaches argv, a file, a Worker binding, or an object. `migrate` sends the switch token over ssh stdin, read on the target by `IFS= read -r` into the environment of the one `setup` process. + +## Migration + +Both runs follow a rehearsal on throwaway hostnames (`## Test plan`, R1 and R2). Each step names its rollback. + +### Dwarves (`s.d.foundation`, R2 on, `files` folded in) + +| # | Step | Check | Rollback | +|---|---|---|---| +| D1 | upgrade share on the Mini to the release | `share --profile dfoundation status` unchanged | `brew` pin of the previous version | +| D2 | mint the origin publisher token (table above); `share --profile dfoundation api-token` stores it | `api-token --check` passes the bucket and Access lines | revoke the token | +| D3 | `CLOUDFLARE_API_TOKEN= share --profile dfoundation setup s.d.foundation --r2 --bucket share-dfoundation --alias f.d.foundation` (storage default `local`) | the setup's live checks; `https://f.d.foundation/ba6377/support-ticket-guide/` answers 301 to `https://s.d.foundation/ba6377/support-ticket-guide/`, which answers 302 to the Access login with `kid` equal to the app's AUD; `a68960` still gated; `share --profile dfoundation ls` shows both rows, `ba6377` as `cloud`, `a68960` as `machine` | below | +| D4 | `share --profile files teardown --yes` (r2 teardown is local: config, token, `r2-own` go; the bucket and every share stay) | `share profiles` lists `default` and `dfoundation`; Share Bar shows one Dwarves section | `share --profile files setup f.d.foundation --backend r2 --bucket share-dfoundation` (join) | +| D5 | move any external monitor of `f.d.foundation/healthz` to `s.d.foundation/healthz` | vps-mon shows the check green | revert the catalog line | +| D6 | after seven days with no rollback: delete the Worker script `share-f-d-foundation` (it has no domain since D3) | `GET workers/scripts/share-f-d-foundation` answers 404 | redeployable from the v0.8.0 `bin/share` | + +Rollback of D3, in order, all with the admin token, rehearsed in R1: rebind `f.d.foundation` to `share-f-d-foundation` (`PUT workers/domains`); restore the marker host `f.d.foundation` (`If-Match`); restore the `f.d.foundation/ba6377{,/*}` destinations on its app; `share --profile dfoundation setup s.d.foundation --no-r2` (the route goes; `s.d.foundation` is the plain tunnel again). The old Worker still serves every `v:1` cloud record, and it answers 404 for the `v:2` pointers it cannot pass through, which is correct on `f.d.foundation`. Hits recorded before D3 stay in dataset `share_f_d_foundation`; `share hits` after D3 reads `share_s_d_foundation` only. + +### Personal (`s.han.ws`, R2 off, origin moves from the Air to the Mini) + +| # | Step | Check | Rollback | +|---|---|---|---| +| P1 | upgrade share on the Air and on the Mini | `share import --probe` on the Mini prints `share-import 1` | `brew` pin | +| P2 | on the Air: `CLOUDFLARE_API_TOKEN= share migrate --to tieubao@ --remote-profile default` | its step 4 (every moved link answers through `https://s.han.ws/`); on the Mini `share ls` shows them with `by=`; `share profiles` on the Mini lists `default · s.han.ws · serving` | `share setup s.han.ws --force` on the Air before step 5 (printed by `migrate`); after step 5 the Air's copies sit in `~/share/migrated/` and its rows in `~/share/index.migrated` | +| P3 | Share Bar on the Mini | two sections, `default · s.han.ws · Serving` and `dfoundation · s.d.foundation · Serving` | | + +The Mini's default profile is free today (`not_setup`), so `s.han.ws` lands there on port 8787 under the user that runs the `dfoundation` profile (`tieubao`). The new tunnel is `share-s-han-ws`; `air-share` is deleted in P2's step 5. R2 stays off; Han can enable it later with `setup --r2` on the Mini, which needs a bucket on the personal account (R2 is enabled there today, with no share bucket). After P2 the Air has no `s.han.ws` profile; it publishes personal links only after R2 is on (as a member) or by ssh to the Mini. + +## Byte identity + +A tunnel profile with no `bucket=` (every R2-off tenant) keeps every file, output line, and `state` key it has today, with three additions: the `ls` tag column (`machine` or `live`, the type, `by=`), and `state`'s new keys `storage`, `type`, `by`, `r2: false`. An r2 member profile keeps SPEC-007's behavior plus the same listing fields and its rows in `profiles --json`. Row 1 of the coverage matrix pins both. + +## Files + +- `bin/share`: `--cloud`, `--local`, `storage_default`; `setup --r2`, `--no-r2`, `--alias`; pointer records and the reconcile; the merged rows and the new fields in `ls`, `state`, `profiles --json`; `import`, `migrate`; Worker v3 in `worker_js`; usage lines and the skill rows. +- `tests/share.sh`: sections under `SHARE_R2_DRY=1` and `SHARE_MIGRATE_SSH`. +- `tests/worker.mjs`: pass-through, alias, and healthz cases with a stubbed origin `fetch`. +- `tests/e2e-tenant.sh` (new): live legs L1 to L10 and rehearsal R1. `tests/e2e-migrate.sh` (new): rehearsal R2. +- `mac/Sources/ShareBarCore/Snapshot.swift` (`Share.storage`, `.type`, `.by`; `Snapshot.r2`, `.storageDefault`, `.cloudError`), `RowGlyphs.swift` (new), `MenuModel.swift`, `Publish.swift`; `mac/Sources/ShareBar/StatusItemController.swift`; tests under `mac/Tests/ShareBarCoreTests/`. +- `README.md`, `docs/how-it-works.md`, `docs/setup.md` (tenant, origin, member, the onboarding rule), ADR-0008 (one hostname per tenant), `docs/verification/one-host-per-tenant.md`. + +## Failure modes + +| Failure | Detection | Behavior | +|---|---|---| +| the origin is off, R2 on | the Worker's pass-through gets 530 | cloud links serve; machine and live links answer the 503 offline page; `/healthz` answers 503 with `X-Share-Tunnel: 0` | +| the origin is off, R2 off | none at the edge | Cloudflare 530, as today | +| R2 or the bucket binding fails | the bucket read throws | the request passes through: machine links serve, cloud ids reach Caddy and answer 404, gated cloud ids are gated at the edge first; never served unchecked | +| a cloud record and a machine link share an id | `If-None-Match: *` on `m/`; the reconcile | a cloud add picks another id; a pre-existing collision (an origin on v0.8.0 while R2 was on) is printed by the origin's `ls` and setup step 6; the Worker serves the cloud record | +| a pointer write fails on a local add | `r2_call` code | the add dies before `stage_publish`; nothing is served | +| a pointer outlives its link | the reconcile, prune | deleted after 10 minutes by the origin, or after expiry by any member; until then the id passes through and answers 404 | +| a request the route Worker passes through re-enters the Worker | TASK-1(a) | the spike blocks the build: the pass-through design changes to (B) before TASK-3 | +| Access does not run before a route Worker | TASK-1(b) | the spike blocks the build; a gated machine link must never reach the origin unauthenticated | +| a forged cloud record over a machine id (a publisher with bucket write) | none; same trust as SPEC-007 DEC-011 | the cloud bytes answer, inside the edge's Access app for that path when one exists; publishers are named teammates | +| an alias rebind leaves `f.d.foundation` without a Worker | step 13's API answer; step 14's 301 check | die naming the rollback; the old Worker stays deployed | +| the gate probe on the added destinations times out | step 10 | the added destinations are removed; nothing else has changed | +| `migrate` loses ssh mid-copy | the pipe's exit code | stop; the target holds the copies already imported, unserved unless its profile serves; a rerun skips them | +| the switch fails after the CNAME moved | the remote setup's exit | `migrate` prints the one rollback command; the Air's tunnel and copies are intact until step 5 | +| a moved link does not answer | step 4 | stop before step 5, naming each link | +| a tar entry escapes the stage, or a symlink arrives | `import`'s stage scan | the import of that id dies; nothing is published for it | +| an old share CLI on the origin while R2 is on | none | it serves and adds local links without pointers; the reconcile on the next upgraded `ls` writes them and names any collision | +| the token source is a command and the menu polls | the listing token rule | local rows plus `cloud_error`; no prompt during a poll | + +## Task Breakdown + +- [ ] TASK-1: spike on throwaway hostnames and tokens; answers in `docs/implementation-notes/one-host-per-tenant.md`. (a) A route Worker's `fetch(request)` and a `fetch` of `https:///healthz` reach the tunnel origin without running the Worker again. (b) Access answers an unauthenticated request to a gated path with its 302 before the route Worker runs, and an admitted request reaches the Worker with `Cf-Access-Jwt-Assertion`. (c) The pass-through of an admitted request reaches the origin (Access does not refuse the subrequest, or admits it on the forwarded cookie). (d) A POST and a WebSocket upgrade pass through to a live dev server. (e) The status codes a pass-through sees when the origin connector is down. (f) Rebinding an existing custom domain to another Worker: in place, or DELETE then PUT, and the gap. (g) `PUT access/apps/` with added destinations keeps the AUD. (h) Whether `op://Toolkit/cf-api-token` holds Workers Routes Edit and Access Apps Edit on the Dwarves account. (i) Whether it edits tunnels and DNS on the personal account, and the 530 gap of a CNAME switch. (j) bsdtar and GNU tar flags that refuse absolute and `..` members, and how each reports a symlink. AC: each item answered or the design amended before the task that consumes it; (a) or (b) failing blocks TASK-3. +- [ ] TASK-2: per-link storage on the origin: config keys, `--cloud`, `--local`, the refusals, `rand_id` across both legs, pointer write, delete, and reconcile. Depends on TASK-1(a)(b). AC: rows 2 to 6. +- [ ] TASK-3: Worker v3 (`ALIASES`, `PASS`, two-leg `/healthz`, the offline page, the method rule, `WORKER_RECORD_V` 2) and `tests/worker.mjs`. Depends on TASK-1(a)(b)(c)(d)(e). AC: rows 7 to 10. +- [ ] TASK-4: `setup --r2` and `--no-r2` (steps 1 to 9, 12, 14, 16). Depends on TASK-2, TASK-3, TASK-1(h). AC: rows 11, 12, 13. +- [ ] TASK-5: `--alias` (steps 4, 7, 10, 11, 13, 15). Depends on TASK-4 and TASK-1(f)(g). AC: row 14. +- [ ] TASK-6: one shared list: merged rows, `storage`, `type`, `by`, `r2`, `storage_default`, `cloud_error`, the listing token rule, `profiles --json` rows for r2 profiles. Depends on TASK-2. AC: rows 1, 15, 16, 17. +- [ ] TASK-7: `import` and `migrate`. Depends on TASK-1(i)(j). AC: rows 18 to 21. +- [ ] TASK-8: Share Bar: decode, `RowGlyphs`, row rendering, the `cloud_error` line, the `Storage` popup. Depends on TASK-6. AC: rows 22 to 25; `swift build` passes. +- [ ] TASK-9: `tests/e2e-tenant.sh`, `tests/e2e-migrate.sh`, and their run logs. Depends on TASK-2 to TASK-8. AC: L1 to L10, R1, R2, every cleanup assertion. +- [ ] TASK-10: README, how-it-works, setup (onboarding), ADR-0008, the verification record with the negative controls. Depends on TASK-9. AC: each doc claim matches the code line it describes; each negative control has a red and a green run. +- [ ] TASK-11: production migration D1 to D5 and P1 to P3, each check recorded in the verification record. Depends on TASK-10 and a release. AC: `## After state`. D6 runs seven days later. + +## Test plan + +Local rows run in `tests/share.sh`: the r2 rows under `SHARE_R2_DRY=1` (SPEC-007's directory-backed bucket and call log), the migration rows with `SHARE_MIGRATE_SSH` pointing at a second `HOME` on the same machine. `tests/worker.mjs` drives the emitted Worker with an in-memory bucket and a stubbed global `fetch` that records each pass-through and answers from a fixture (200, 404, 530, a thrown error). Swift rows run in `swift test --package-path mac`. + +### Coverage matrix + +| # | Category | Case | Assert | +|---|---|---|---| +| 1 | compat | `origin/main`'s `bin/share` and the branch's, each under its own `HOME` with the same seams: a quick setup and a named dry setup with no `bucket=`, add file, add folder, add live, `--host`, `ls`, `state`, `profiles --json`; then an r2 member profile | every file byte-identical; stdout and stderr identical except the `ls` tag column; `state` equal after deleting the keys `storage`, `type`, `by`, `r2`; the member's `profiles --json` entry gains rows | +| 2 | storage | origin with R2 off: `add f`, `add --local f`, `add --cloud f`; R2 on with default `local`: `add f`, `add --cloud f`, `add 3000`, `add --cloud 3000`, `add --cloud --host x. f`, `add --cloud --local f`; default `cloud`: `add f`, `add --local f` | local, local, refused with the setup line; local plus pointer, cloud, live plus pointer, refused, refused, refused; cloud, local plus pointer; refusals log no PUT and write no row | +| 3 | member | an r2 member: `add f`, `add --local f`, `add 3000` | cloud; refused naming the origin's `by`; refused | +| 4 | pointer | local add with R2 on; the bucket answers 412 for the first candidate | `GET m/` and the prefix scan precede the pointer PUT; the pointer PUT carries `If-None-Match: *`, `v:2`, `storage:"machine"`, no `access=` and no `host=` in `opts`; the first candidate is skipped; the pointer PUT precedes the `pub/` rename (a watcher on `pub/`); a gated add's pointer PUT precedes `POST app` | +| 5 | pointer | the pointer PUT answers 500 three times | exit 1 naming R2; no `pub/`, no row, no app | +| 6 | reconcile | origin `ls` with: a machine record by this host with no row, aged 11 min; one aged 1 min; a local row with no pointer; a local row whose id holds a cloud record; `rm` and `prune` of local rows with and without a token | the old orphan pointer deleted, the young one kept; a pointer written for the bare row; the shadow line printed, nothing deleted; rm and prune delete the pointer with a token and leave it without one | +| 7 | Worker | `PASS` set: a cloud record; a machine record; no record; the bucket throws; a non-hex first segment; `/`; an expired cloud record; a cloud record whose prefix names another id | R2 bytes, no pass-through; pass-through x5 (machine, none, throw, non-hex, root); 404 with no pass-through x2 | +| 8 | Worker | `PASS` set, pass-through answers: 200 with a body; 404; 530; 502; a thrown fetch; a POST with a body; an `Upgrade: websocket` request | unchanged x2; 503 offline page with `Retry-After` x3; the POST forwarded with its method and body; the upgrade forwarded and its 101 returned unchanged | +| 9 | Worker | `ALIASES=f.test`: GET `https://f.test/ba6377/g/?a=1`, HEAD, POST, `/healthz`; Host `x.test`; `PASS` empty: a miss | 301 to `https:///ba6377/g/?a=1`, 301, 405, 301; 404; 404 with no `fetch` call | +| 10 | Worker | `/healthz` with `PASS` set and the origin stub answering `200 ok`, then 530, then a timeout; with `PASS` empty | 200 `X-Share-Tunnel: 1`; 503 `X-Share-Tunnel: 0` x2; SPEC-007's answer with no stub call. Every row 7 to 10 answer carries `no-store` and `noindex`, and SPEC-007's rows 17 to 19 and 28 pass unchanged on the v3 source | +| 11 | setup | dry `setup --r2` on the origin | log order: script GET < bucket reads < marker GET < routes GET < `m/` list < script PUT < subdomain POST < subdomain GET < pointer PUTs < route POST < healthz; config gains the four keys; the tunnel config and Keychain items unchanged | +| 12 | setup | refusals: on a non-origin; on quick mode; a publisher token (403 on the script read); a route on `/*` naming another script; a local id holding a cloud record; a marker naming a third host | each dies before any PUT, POST, or DELETE in the log | +| 13 | rollback | `--no-r2` after row 11 | route DELETE logged; the four keys gone; the next `add` writes no pointer; `rows()` output equal to before row 11 | +| 14 | alias | dry `--alias f.test` with a gated cloud record; the alias domain naming another service; the gate probe failing | order: app GET < script PUT < pointer PUTs < app PUT (destinations added, AUD unchanged) < probes < marker PUT `If-Match` < route POST < domain PUT < healthz < app PUT (alias destinations dropped) < config; the other service: dies before any write; probe failure: the added destinations removed, no marker PUT, no route | +| 15 | list | origin with R2 on: two local rows (one live, one gated), their pointers, two cloud records (one by another machine, one gated), a `v:1` cloud record without `type` named `guide` | `ls` shows six rows with tags `machine`, `live`, `cloud`, `cloud`, their types, and `by=`; no id twice; `state` rows carry `storage`, `type`, `by`; `guide` is `folder` | +| 16 | type | the type table, one fixture per row, plus a folder with `index.html`, a folder without, `Report.PDF`, `a.tar.gz`, no extension on a local file | each `type` as the table; the extension match ignores case | +| 17 | list | `state` with the bucket listing failing; with the token source `api_token_cmd`; `profiles --json` with an r2 member and a tunnel profile | local rows plus `cloud_error` (exit 0); no command run (a sentinel command that touches a file is never run), `cloud_error` names `api-token`; the member entry lists its rows | +| 18 | import | a tar with a regular tree; a symlink; a dotfile; a `../x` member; an absolute member; an id already in the index; opts `live`; opts `host=x`; a name with `/` | the first published with the original dates, `by=`, and `src` `:`; each other refused with no `pub/` and no row | +| 19 | migrate | two `HOME`s (A the old origin, B the target, `SHARE_MIGRATE_SSH`): A holds a snapshot, a folder, a gated snapshot, a live row | B's index holds the three snapshots with A's ids, names, expiries, and `access=`; B's `pub/` trees equal A's byte for byte; the live row is listed as not moved; B's dry setup ran with `--force` and the token from stdin (a `curl` shim shows the token in no argv); A's rows are in `index.migrated`, its trees in `migrated/`; A's teardown path ran and logged no DELETE of an Access app | +| 20 | migrate | a `--host` row on A; B's profile set up for another hostname; the copy failing on the second id; the remote setup failing; a verify probe failing | refused before any copy; refused before any copy; stop with the first id on B and A unchanged; A unchanged and the rollback line printed; stop before retire, the failing link named | +| 21 | migrate | rerun after row 20's copy failure | the first id skipped as already moved; the rest copied | +| 22 | swift | decode `profiles --json` fixtures with and without the new fields; a share with an unknown `type` | `storage`, `type`, `by`, `r2`, `storageDefault`, `cloudError` decoded; absent fields are nil; unknown type maps to `doc` | +| 23 | swift | `RowGlyphs` over every value in the SF Symbols table | the symbol names and words match the table; every symbol resolves with `NSImage(systemSymbolName:)` on the test host | +| 24 | swift | `MenuModel` rows: a gated cloud PDF, a live machine row, an older-CLI row with no `storage` | accessibility titles `, , PDF, in the cloud, snapshot, login required`; `..., site, on this tenant's machine, live server`; no storage word for the old row | +| 25 | swift | `PublishForm` on an origin with `r2: true` and `storage_default: cloud`; on a member; on a tunnel profile with `r2: false` | the `Storage` popup preselects `In the cloud` and the argv carries `--cloud`; a disabled `In the cloud` label and no flag; no popup and no flag | + +### Negative controls + +Each is a temporary one-line patch that must turn the named rows red, recorded with its red run in `docs/verification/one-host-per-tenant.md`. + +| Patch | Expected red | +|---|---| +| the Worker passes an expired cloud record through instead of 404 | row 7 | +| the Worker passes a miss through with `PASS` empty | row 9 | +| the Worker serves R2 for a `storage:"machine"` record | row 7 | +| drop the encoded-separator check before the pass-through | SPEC-007 row 18 on the v3 source | +| write the pointer after `stage_publish` | row 4 (the watcher sees `pub/` first) | +| let a pointer PUT failure warn instead of die | row 5 | +| take a member's `add --local` as cloud | row 3 | +| attach the route before the pointer backfill | row 11 | +| keep the alias destinations on the app after step 14 | row 14 | +| let `import` accept a symlink | row 18 | +| run the full teardown (with Access deletes) on the old origin before moving rows aside | row 19 | +| read the bucket in `state` through `api_token_cmd` | row 17 | +| write `bucket=` into a plain tunnel config at setup | row 1 | + +### Live legs (`tests/e2e-tenant.sh`, by hand before the release) + +Inputs: `SHARE_E2E_TENANT_HOST` and `SHARE_E2E_ALIAS_HOST` (unused names on a zone the admin token edits), `CLOUDFLARE_API_TOKEN` (admin), `SHARE_E2E_R2_PUBLISHER_TOKEN`, `SHARE_E2E_ACCESS_EMAIL`. The bucket is `share-e2e-<6 hex>`, and the script refuses a hostname or bucket without the `share-e2e-` prefix, so `s.d.foundation`, `f.d.foundation`, `s.han.ws`, and every existing bucket are out of reach. Everything runs under mktemp `HOME`s with `SHARE_TEST_PORT_BASE` ports. + +| # | Step | Assert | +|---|---|---| +| L1 | origin A: tunnel setup of the tenant host; a local file, a gated local file, a live server on a test port | all answer as today (the gated one 302 with `kid == aud`) | +| L2 | `setup --r2` on A | `/healthz` 200 with `X-Share-Tunnel: 1` and this CLI's pair; pointers exist for L1's ids; `https://..workers.dev/healthz` does not answer 200 | +| L3 | through the Worker | L1's links answer as before; the gated one still 302s unauthenticated and serves with the service token (SPEC-007 TASK-1(l) method); the live server answers a POST and a WebSocket echo | +| L4 | A `add --cloud`; member B joins and adds | both cloud links 200 with `no-store`; `ls` on A and on B show the same five rows with tags | +| L5 | stop A's tunnel (`share stop`) | cloud links 200; machine and live links 503 offline page; `/healthz` 503 with `X-Share-Tunnel: 0`; start A, all back | +| L6 | encoded paths `/x/..%2F/f`, `///f`, `/%2F/f` on a machine id and a cloud id | 400 each, from the Worker | +| L7 | `--no-r2` | the route is gone; machine links serve straight from the tunnel; cloud links 404 at Caddy | +| L8 | `setup --r2` again | back to L4's state with no new pointer PUT for existing pointers | +| L9 | B's local teardown; A `rm` of each link | A's `ls` empty; every pointer and record gone; the gated apps 404 | +| L10 | admin purge and A teardown | cleanup below | + +Rehearsals (each before its production run): + +| # | Rehearsal | Assert | +|---|---|---| +| R1 | the Dwarves fold on throwaway names: A is a tunnel origin on ``, C an r2 profile on `` with a gated cloud share; then `setup --r2 --alias ` on A; then the rollback list in `### Dwarves`; then the fold again | after the fold: `https://///` 301 to `https://///`, which 302s with `kid` equal to the original AUD and serves with the service token; after the rollback: the alias serves the share itself and the tenant is a plain tunnel; after the second fold: as after the first | +| R2 | the personal move on throwaway names: A (stand-in for the Air) sets up `` with a snapshot, a folder, a gated snapshot, a live server; `migrate --to` B on the same machine (`SHARE_MIGRATE_SSH`), then once more from the real Air to the Mini over ssh (a second throwaway name) | every snapshot link answers from B (or the Mini) at the same URL; the gated one keeps its 302 and AUD; the live link is reported as not moved; A's tunnel is deleted, its DNS record points at B's tunnel; the measured 530 gap is logged | + +Cleanup is asserted, then enforced, as SPEC-007's live leg does: after L10 and each rehearsal the script checks that no route, Worker, custom domain, DNS record, tunnel, bucket, Access app, or minted token with the throwaway names remains, and its EXIT trap runs the purges and a by-name fallback whatever happened before, printing each leftover. + +## Grounding + +Read-only probes, 2026-10-01, from the Mini, through a helper that sends the Toolkit token as `-H @<(printf ...)` and prints names, codes, and counts only: + +| Claim | Command (shape) | Excerpt | +|---|---|---| +| profiles on the Mini | `share profiles`; `share --profile

state \| jq` | `default not_setup -`, `dfoundation serving s.d.foundation`, `files serving f.d.foundation`; one gated snapshot `a68960` on `dfoundation`, one gated cloud share `ba6377` on `files` | +| config keys | `grep '^(backend\|hostname\|hosts\|port\|bucket\|tunnel_name)='` | `files`: `backend=r2`, `bucket=share-dfoundation`, `port=r2`; `dfoundation`: `tunnel_name=share-s-d-foundation`, `hosts=Mac-mini`, `port=8791` | +| hostnames live | `curl -w %{http_code} https:///healthz` | `s.d.foundation 200`, `f.d.foundation 200`, `s.han.ws 200` | +| the guide link | `curl -w '%{http_code} %{redirect_url}'` | `302 https://dwarves.cloudflareaccess.com/cdn-cgi/access/login/f.d.foundation?kid=<64 hex>...` | +| DNS | `GET zones//dns_records?name=` | `s.d.foundation CNAME proxied tunnel`, `f.d.foundation AAAA proxied` (the Worker custom domain), `s.han.ws CNAME proxied tunnel` | +| Workers on the zones | `GET zones//workers/routes`, `GET accounts//workers/domains` | `chat.d.foundation/dw-chat/* -> df-chat-ui`, `radar.han.ws/* -> price-radar`; `f.d.foundation -> share-f-d-foundation` | +| tunnels | `GET accounts//cfd_tunnel?is_deleted=false` | `share-s-d-foundation healthy, 4 connections`; `air-share healthy, 4 connections` | +| Access apps | `GET accounts//access/apps` filtered on `share ` | Dwarves: `f.d.foundation/ba6377{,/*}`, `s.d.foundation/a68960{,/*}`; personal: none | +| personal account | `GET accounts//r2/buckets`, `POST .../analytics_engine/sql` | `200`, no bucket named `share*`; `200` | +| zone plans | `GET zones?name=` | both `Free Website`; Workers paid status not probed (SPEC-007 cost section assumed the Dwarves paid plan) | + +Not sampled (TASK-1): the route pass-through, Access order against a route Worker, the subrequest under Access, WebSocket pass-through, the down-origin status codes, the custom-domain rebind, the AUD after a destination edit, the Toolkit token's route, Access, and tunnel scopes, tar member handling. + +Dry trace for one negative control: "write the pointer after `stage_publish`" moves the `r2_call PUT m/` line below the `mv` in the local add; row 4's background watcher records the first time `pub/` exists and compares it with the line number of `PUT m/` in `r2-calls.log` (the watcher appends a `SEEN pub/` line to the same log), so the order flips and the row fails mechanically. + +## Verification + +```sh +shellcheck bin/share install.sh tests/share.sh tests/e2e.sh tests/e2e-r2.sh tests/e2e-tenant.sh tests/e2e-migrate.sh demo/render.sh mac/*.sh +/bin/bash -n bin/share +bash tests/share.sh # includes tests/worker.mjs +swift test --package-path mac +bash mac/build.sh 0.0.0 +``` + +Then by hand: `tests/e2e-tenant.sh` (L1 to L10, R1) and `tests/e2e-migrate.sh` (R2); logs go to `docs/verification/one-host-per-tenant.md`. + +## After state + +- [ ] `https://f.d.foundation/ba6377/support-ticket-guide/` answers 301 to the same path on `s.d.foundation`, which keeps its Access gate. +- [ ] `share --profile dfoundation ls` on the Mini lists `a68960` (`machine`) and `ba6377` (`cloud`) in one list; the `files` profile is gone. +- [ ] `share --profile dfoundation add ./x` publishes from the Mini's disk; `add --cloud ./x` publishes to the bucket; both at `https://s.d.foundation//...`. +- [ ] With the Mini's `dfoundation` service stopped, the cloud link answers and the machine link answers the 503 offline page. +- [ ] `s.han.ws` serves from the Mini; every Air snapshot link answers at its old URL; no Worker or bucket exists for `han.ws` share. +- [ ] Share Bar on the Mini shows two sections, each row with its type icon, storage badge, and link-type marker, and the lock on gated rows. + +## Acceptance Criteria (global) + +1. A tenant with R2 off runs today's code paths: no Worker, no bucket call, files byte-identical (row 1). +2. With R2 on, a cloud record is never passed to the tunnel, a machine link is never served from R2, and an expired or malformed cloud record answers 404 (rows 7, 8). +3. A gated link stays gated on both legs: the edge app on every path, plus the Worker's JWT check for cloud records (rows 10, 14; L3; R1). +4. Every existing link keeps its URL or answers a 301 to it, through both migrations (R1, R2; `## After state`). +5. No token appears in argv, output, a file, a binding, or an object (rows 19, SPEC-007 row 16 on the new paths). +6. Every migration step has a rollback that the rehearsal ran. + +## Out of scope + +Moving a tunnel tenant's local shares into its bucket (a `--cloud` re-add covers one link); a member publishing a local link through the origin over ssh; more than one origin per tenant; `--host` and live links in R2; enabling R2 on `s.han.ws` now; a CSP sandbox on shared HTML (SPEC-007 Decisions item 6); per-link storage changes after publish. + +## Decisions for Han + +1. Personal SSH target and user for P2: the spec assumes `tieubao@` (the user that runs `dfoundation` today), so both tenants live under one account on the Mini. Proposed: accept. +2. The Mini's `s.han.ws` lands in its `default` profile (port 8787, Keychain item `share-tunnel:s.han.ws`). Proposed: accept; a named `personal` profile works the same if you prefer explicit names on the Mini. + +## Decision Log + +- DEC-001 (operator, 2026-10-01): one hostname and one shared list per tenant; storage is a per-link indicator, never a subdomain. +- DEC-002 (operator, 2026-10-01): local storage is the default; R2 is optional per tenant, off until the admin enables it, then per link (`--cloud`, `--local`) with an admin-set tenant default (`storage_default`). +- DEC-003 (operator): the Mini is the single origin for both tenants, because it is the only always-on machine; the Air's sleep is the cause of today's 530s on `s.han.ws`. +- DEC-004: the Worker reaches the tunnel by route pass-through on the same hostname; no internal hostname, no service token, and the rollback is one route delete. +- DEC-005: R2 first, then the tunnel; a cloud id never falls back to the origin. +- DEC-006: pointer records exist only with R2 on; they make the list shared on every member and the namespace collision-proof. A local add fails closed when R2 is on and the pointer cannot be written. +- DEC-007: cloud records stay `v:1` (plus an optional `type`), pointers are `v:2`, so the old Worker remains a valid rollback for every cloud link. +- DEC-008: `f.d.foundation` becomes an alias the tenant Worker answers with 301; the bucket `share-dfoundation` is adopted in place, so `ba6377` keeps its record, bytes, and Access app AUD. +- DEC-009: `share migrate` moves an origin over ssh with the receiver's `import`, keeping ids, names, dates, expiries, and gates; nothing on the old origin is deleted, only moved aside. From 64d206aeb9aa9c12265c8ae5a003f68d03ce6e6e Mon Sep 17 00:00:00 2001 From: Han Ngo Date: Thu, 1 Oct 2026 17:08:52 +0700 Subject: [PATCH 02/55] docs: fold round 1 spec review into the tenant spec Bucket rows become display data, the alias fold gets a safe partial rollback, members cannot remove machine links, healthz keeps members working while the origin is off, and the tasks split with a stop before each production migration. Warnings go to the implementation notes. --- .../one-host-per-tenant.md | 23 +++ docs/specs/SPEC-008-one-host-per-tenant.md | 169 ++++++++++++------ 2 files changed, 134 insertions(+), 58 deletions(-) create mode 100644 docs/implementation-notes/one-host-per-tenant.md diff --git a/docs/implementation-notes/one-host-per-tenant.md b/docs/implementation-notes/one-host-per-tenant.md new file mode 100644 index 0000000..6233ea6 --- /dev/null +++ b/docs/implementation-notes/one-host-per-tenant.md @@ -0,0 +1,23 @@ +# One hostname per tenant: implementation notes + +The delta from SPEC-008 for the builder. Spike answers (TASK-1a, TASK-1b, TASK-7a's tar step) land here too. + +## Validation warnings carried to the build + +Round 1 warnings the spec did not fold. Each one is a build rule unless a task's spike answer overrides it. + +| Warning | Build rule | Task | +|---|---|---| +| Every request to an R2-on tenant runs the Worker plus one bucket read; dev-server HMR multiplies both | measure live-link request volume in L3; if it matters, memo "machine or miss" per id in the Cache API for a few seconds; add a Worker-requests or Class B threshold to the vps-mon catalog | TASK-3, TASK-9a | +| The route fails closed at the Workers request limit, taking machine links down | TASK-1b(k) reads the plan; on a free plan set the route to fail open (Caddy 404s cloud ids, edge Access still gates) | TASK-1b, TASK-4 | +| A publisher token reaching its `expires_on` silently blocks local adds on an R2-on origin | `api-token --check` warns 14 days ahead; vps-mon expiry coverage for the origin token | TASK-2, TASK-11a | +| `setup --r2` holds no index lock while pointers backfill | take the index lock from step 6 through step 11, so a concurrent local add waits and then writes its pointer | TASK-4 | +| A dry trace exists for one negative control only | write the dry trace for each control into the verification record as it goes red | TASK-10 | +| A tar truncated at a member boundary can publish a partial tree | `migrate` sends a per-id file count and sha256 manifest; `import` verifies it before publishing; the rerun skip compares the manifest | TASK-7a, TASK-7b | +| A member could take a machine link's URL by deleting its pointer and writing a cloud record (accepted trust, SPEC-007 DEC-011) | `state` reports `shadowed` ids so Share Bar can show them | TASK-6 | +| The migrate switch token can be broad if TASK-1b(i) picks the Toolkit token | prefer a run-scoped one-day token for the switch | TASK-11b | +| The Worker's `//` and low-escape 400s now cover live dev-server paths too | document it in how-it-works; scope them to cloud ids only if a real dev server needs `//` | TASK-3, TASK-10 | +| `brew pin` is no downgrade | the D1 and P1 rollback is an install of the previous tag from the tap's git history | TASK-11a, TASK-11b | +| After P2 nothing watches `s.han.ws` | add `s.han.ws/healthz` to vps-mon in TASK-11b | TASK-11b | +| After P2, a local restore on the Air (rows from `index.migrated`, trees from `migrated/`) has no command | rehearse the manual restore once in R2 and write it into the verification record | TASK-9b | +| `--alias` adds permanent surface for a one-time fold | keep the alias code small and covered; it retires with the alias rollback and `--no-r2` | TASK-5 | diff --git a/docs/specs/SPEC-008-one-host-per-tenant.md b/docs/specs/SPEC-008-one-host-per-tenant.md index c31fb0f..61c0d09 100644 --- a/docs/specs/SPEC-008-one-host-per-tenant.md +++ b/docs/specs/SPEC-008-one-host-per-tenant.md @@ -2,7 +2,7 @@ Status: DRAFT Lane: full (an API and data contract between the CLI, the Worker, and Share Bar; a new public route in front of live links; a migration of two production hostnames) -Depth: research (outside: a route Worker passing requests through to a tunnel on its own hostname, Access order against a route Worker, custom-domain rebind, Access destination edits; TASK-1 samples each before the task that consumes it) | blind-spot (failure: the Worker passes a request to the tunnel that a cloud record or a gate should have stopped, or the migration leaves a link dead) +Depth: research (outside: a route Worker passing requests through to a tunnel on its own hostname, Access order against a route Worker, custom-domain rebind, Access destination edits; TASK-1a and TASK-1b sample each before the task that consumes it) | blind-spot (failure: the Worker passes a request to the tunnel that a cloud record or a gate should have stopped, or the migration leaves a link dead) References: SPEC-004 (Access gate), SPEC-005 (profiles), SPEC-006 (Share Bar across profiles), SPEC-007 (R2 backend), ADR-0002, ADR-0003, ADR-0005, ADR-0006, ADR-0007; `bin/share` `cmd_add`, `cmd_add_r2`, `rand_id`, `rows`, `share_url`, `cmd_ls`, `cmd_state`, `cmd_state_r2`, `cmd_profiles_json`, `cmd_setup`, `cmd_setup_r2`, `cmd_teardown`, `worker_js`, `r2_call`, `r2_snapshot`, `r2_healthz`; `mac/Sources/ShareBarCore/Snapshot.swift`, `MenuModel.swift`; `mac/Sources/ShareBar/StatusItemController.swift`. Supersedes: SPEC-007 DEC-006 (a second hostname `f.d.foundation` beside `s.d.foundation`); SPEC-007 `## Out of scope` items "Share Bar reading an r2 default profile" (now in scope through the Keychain token only) and "migrating a tunnel profile's shares into a bucket" (now `share migrate` between machines; tunnel to bucket stays out). @@ -78,11 +78,12 @@ Who writes what: |---|---|---| | Default storage | R2 for snapshots, the tunnel for live and `--local`; the tunnel for everything, R2 per link | the tunnel (operator direction, 2026-10-01). A tenant with R2 off is today's share, byte for byte, with zero new infrastructure. R2 adds survival while the origin is off and publishing from other machines, and the tenant pays for it only when the admin turns it on. | | How cloud and machine links share one hostname | two hostnames (today); a Worker in front that routes per id; a tunnel in front that proxies cloud ids to R2 | a Worker in front, only on tenants with R2 on. The edge already terminates TLS and runs Access there; a Worker can read the bucket through its binding with no token. A tunnel in front would need the origin up to serve cloud links, which defeats them. | -| How the Worker reaches the tunnel | (A) a route `/*` over the existing proxied CNAME, `fetch(request)` passes through to the origin; (B) a second, internal origin hostname behind an Access service token, the Worker fetching it with the token's secret; (C) the Worker as custom domain and the tunnel on another name | (A). The tunnel and its CNAME stay exactly as they are, so deleting the route is the whole rollback. No new hostname, DNS record, Access app, or service-token secret exists (the Worker keeps holding no credential, SPEC-007's rule). (B) costs a hostname, a secret with an expiry, and a second ingress rule, and its token would open every machine link to anyone holding it. (C) would change every existing link. TASK-1(a) proves the pass-through. | +| How the Worker reaches the tunnel | (A) a route `/*` over the existing proxied CNAME, `fetch(request)` passes through to the origin; (B) a second, internal origin hostname behind an Access service token, the Worker fetching it with the token's secret; (C) the Worker as custom domain and the tunnel on another name | (A). The tunnel and its CNAME stay exactly as they are, so deleting the route is the whole rollback. No new hostname, DNS record, Access app, or service-token secret exists (the Worker keeps holding no credential, SPEC-007's rule). (B) costs a hostname, a secret with an expiry, and a second ingress rule, and its token would open every machine link to anyone holding it. (C) would change every existing link. TASK-1a(a) proves the pass-through. | | Order inside the Worker | the tunnel first, R2 on 404; R2 first, the tunnel otherwise | R2 first (operator direction): one bucket read decides, the origin is never asked about a cloud link, and cloud links answer while the origin is off. | | The shared list across machines | merge only on the origin (its index plus the bucket); a pointer record `m/` for every machine link | pointer records, written only when R2 is on. A member's `ls` and Share Bar see the origin's links too, and one namespace means a cloud add can never take an id the origin holds (`If-None-Match: *` on one key). With R2 off there is one publisher, so its index is the list. | | The old hostname `f.d.foundation` | keep both; a Cloudflare redirect rule plus a placeholder DNS record; the tenant Worker answers 301 for an alias host | the tenant Worker takes the alias's custom domain and answers 301 to the same path on the tenant host. One Worker per tenant; the redirect lives in code the suite tests and the CLI deploys. | | Moving the Air's shares to the Mini | publish through R2 and back; rsync plus a hand-edited index; `share migrate --to `, the receiver's own `share import` writing each share | `migrate` over ssh: the Air can reach the Mini, R2 is off on `s.han.ws`, and the receiving CLI validates and writes its own index (the only writer, as ADR-0003 keeps the app out of share's files). Ids and paths stay, so every link stays. | +| Which tunnel the new origin runs | reuse `air-share` on the Mini (`setup --tunnel-name air-share`, no DNS change); a new tunnel `share-` with the CNAME repointed (`setup --force`) | a new tunnel. A reused tunnel has two connectors until the Air stops, so setup's own three-in-a-row live check can land on the Air and fail; and the Air's config would keep the tunnel id the Mini now runs, so any later `teardown` on the Air deletes the Mini's tunnel. A new tunnel keeps each machine's teardown confined to its own tunnel (teardown deletes a DNS record only when it points at that tunnel). The cost is the DNS Edit scope and a short gap, bounded in `### Moving a tenant's origin`. | ### Tenant, origin, member @@ -108,17 +109,19 @@ It runs on the origin only (`host_ok`, else `run this on the origin, `), | 7 | with `--alias`: every gated cloud record's Access app (`GET access/apps/`) | an app whose name is not `share `, or no Apps Edit scope | | 8 | deploy the Worker (bindings below), subdomain and previews off with the read-back (SPEC-007 step 10) | read-back not `false,false` | | 9 | pointer records: `PUT m/` with `If-None-Match: *` for every local row | a 412 on an id step 6 saw free: die, the route is not attached yet | -| 10 | with `--alias`: each step-7 app gains `/` and `//*` (PUT keeps every field and the AUD; TASK-1(g)); the gate probe on the new destinations passes three rounds (SPEC-004) | gate timeout: the added destinations are removed, die | -| 11 | marker `PUT share.json` with `If-Match`: `{"v":1,"host":"","aliases":[""]}` | 412 | -| 12 | `POST zones//workers/routes {pattern:"/*", script:}` | an API error | -| 13 | with `--alias`: `PUT workers/domains {hostname:, service:}` (TASK-1(f): rebind in place, or DELETE then PUT with the gap measured) | an API error | -| 14 | live checks: `/healthz` three times in a row with this CLI's `X-Share-Worker` pair and `X-Share-Tunnel: 1`; a probe file through the tunnel leg; with `--alias`: `https:///healthz` answers 301 to `https:///healthz` | timeout: die naming the leg; the route stays (the tunnel leg serves through it), and `--no-r2` is the named rollback | -| 15 | with `--alias`: each step-7 app drops its `` destinations; the local profile whose `hostname` is `` hands its `r2-own` lines to this profile | | -| 16 | write `bucket=`, `r2_endpoint=`, `storage_default=` (default `local`), `aliases=` into the config | | +| 10 | with `--alias`: each step-7 app gains `/` and `//*` (PUT keeps every field and the AUD; TASK-1b(g)); the gate probe on the new destinations passes three rounds (SPEC-004) | gate timeout: the added destinations are removed, die | +| 11 | write `bucket=`, `r2_endpoint=`, `storage_default=` (default `local`), and `aliases=` into the config, so every later die leaves a state `--no-r2` and a rerun can read | | +| 12 | marker `PUT share.json` with `If-Match`: `{"v":1,"host":"","aliases":[""]}` | 412 | +| 13 | `POST zones//workers/routes {pattern:"/*", script:}` | an API error | +| 14 | with `--alias`: `PUT workers/domains {hostname:, service:}` (TASK-1b(f): rebind in place, or DELETE then PUT with the gap measured) | an API error | +| 15 | live checks: `/healthz` three times in a row with this CLI's `X-Share-Worker` pair and `X-Share-Tunnel: 1`; a probe file through the tunnel leg; with `--alias`: `https:///healthz` answers 301 to `https:///healthz` | timeout: die naming the leg. Without `--alias` the named rollback is `--no-r2`. With `--alias` the die prints the alias rollback list (`## Migration`, Dwarves), never `--no-r2` alone | +| 16 | with `--alias`: each step-7 app drops its `` destinations; the local profile whose `hostname` is `` hands its `r2-own` lines to this profile | | + +A rerun of the same command after a die at any step converges: each write is idempotent (deploy compares the SHA, pointers answer 412 for an id that already holds this machine's pointer and count as done, the app PUT and the marker PUT compare before writing, the route and domain reads show an existing binding to this Worker). Worker bindings on a tenant: SPEC-007's set plus `PASS="1"` (pass misses to the origin) and `ALIASES` (comma list). An r2-only profile (SPEC-007, custom domain) keeps `PASS=""` and today's answers. -`share [--profile

] setup --no-r2` (admin token, on the origin) is the rollback: delete the route, then drop the four config keys. The tunnel serves the hostname alone again. Pointer records, cloud records, the bucket, and the Worker stay; `teardown --yes --purge` on a member, or the purge path on the origin, removes them as SPEC-007 says. +`share [--profile

] setup --no-r2` (admin token, on the origin) is the rollback for a tenant without an alias: delete the route, then drop the four config keys. It refuses while the config's `aliases=` or the marker's `aliases` is non-empty (` redirects here; run the alias rollback first: ...` with the list), because an alias whose 301 lands on a plain tunnel turns every folded cloud link into a 404. The tunnel serves the hostname alone again. Pointer records, cloud records, the bucket, and the Worker stay; `teardown --yes --purge` on a member, or the purge path on the origin, removes them as SPEC-007 says. ### Per-link storage on the origin @@ -131,16 +134,20 @@ Worker bindings on a tenant: SPEC-007's set plus `PASS="1"` (pass misses to the | `--cloud` with a port or `--host` | refused: `live links and --host stay on the origin's tunnel` | refused | refused | | `--cloud` with `--local` | refused (usage) | refused | refused | -A cloud add on the origin runs SPEC-007's `cmd_add_r2` path unchanged. A member runs it for every add; `--local` and `` on a member die with ` serves local links from ; this machine publishes cloud links only`, where `` is the `by` of the newest machine record, else `the origin`. +A cloud add on the origin runs SPEC-007's `cmd_add_r2` path unchanged. A member runs it for every add; `--local` and `` on a member die with ` serves local links from its origin; this machine publishes cloud links only`. + +`rm`, `refresh`, and `hits` dispatch on the row's storage. On the origin a machine row takes today's tunnel path (plus its pointer delete) and a cloud row takes SPEC-007's r2 path. On a member, `rm` and `refresh` of a machine row die before any call with ` is served from the tenant's origin; remove it there`, because deleting the pointer would report success while the origin keeps serving the file (gated or public), and the origin's next reconcile would write the pointer back. With R2 on, a local add on the origin also writes the id's pointer record: 1. `rand_id` checks the local index, `pub/`, and `access-pending` as today, then the bucket as SPEC-007's r2 branch does (`GET m/`, the `o/.` prefix scan). -2. After the stage is built and before `stage_publish` (and, for a gated add, before the Access app): `PUT m/` with `If-None-Match: *` and `{"v":2,"id","storage":"machine","name","by","added","expires","opts","type"}`. `opts` keeps `noindex`, `live`, and `access_rule=`; it never carries `access=` or `host=`. A 412 picks a fresh id; any other failure dies before anything is served, naming R2 (a tenant with R2 on keeps one namespace, so a local add fails closed). +2. After the stage is built and before `stage_publish` (and, for a gated add, before the Access app): `PUT m/` with `If-None-Match: *` and `{"v":2,"id","storage":"machine","name","by","added","expires","opts","type"}`. `opts` keeps only `noindex`, `live`, and `gated` (a flag, never the rule, so teammates' addresses stay out of objects every member reads). A 412 picks a fresh id; any other failure dies before anything is served, naming R2 (a tenant with R2 on keeps one namespace, so a local add fails closed; DEC-006 states the cost). 3. `rm` and `prune` of a local row delete its pointer after the local removal. Without a resolvable token (the login service's hourly prune runs with `SHARE_API_TOKEN_OFF=1`) the pointer stays; it is harmless (the Worker passes the id to the tunnel, which answers 404), and the next interactive `ls` or `prune` on the origin, or any member's `prune` once `expires` is past, deletes it. -4. Reconcile on the origin's interactive `ls` and `prune`: a machine record whose `by` is this machine, with no local row, older than 10 minutes, is deleted (an add that died after step 2); a local row with no pointer gets one (`If-None-Match: *`; a 412 means a cloud record holds the id: printed as ` is shadowed by a cloud link; rm one of them`, never auto-deleted). +4. Reconcile on the origin's interactive `ls` and `prune`, keyed on `storage == "machine"` alone (one origin per tenant, so every machine record is the origin's, migrated rows included): a machine record with no local row, whose S3 `LastModified` is over 10 minutes old, and whose id is neither in `access-pending` nor under a held add lock, is deleted (an add that died after step 2); a local row with no pointer gets one (`If-None-Match: *`; a 412 means a cloud record holds the id: printed as ` is shadowed by a cloud link; rm one of them`, never auto-deleted). -Record versions: cloud records stay `v:1` and gain an optional `type` field that `v:1` readers ignore, so a v0.8.0 CLI and Worker keep reading every cloud record. Machine records are `v:2` (`WORKER_RECORD_V` becomes 2): a v0.8.0 CLI drops them from `ls` and its orphan sweep skips with a warning, and a v0.8.0 Worker answers 404 for them, which is correct for a Worker that cannot pass through. +Bucket rows are display data. A record read from the bucket, cloud or machine, never enters `rows()`, `index.tsv`, the Caddyfile, `refresh`, or a stage path, so a forged record cannot add a reverse proxy, a site block, or a source path on the origin. The `v:2` reader applies `r2_record_row`'s checks plus: `id` equal to the key's id; `expires` matching `^[0-9]{1,11}$` and `added` matching `^[0-9]{4}-[0-9]{2}-[0-9]{2}$` before any shell arithmetic sees them; `name` and `by` free of control bytes and tabs, `by` in the hostname set; `opts` tokens only from `noindex`, `live`, `gated`; `type` from the type table; any other field ignored. A record that fails is skipped like a forged row. + +Record versions: cloud records stay `v:1` and gain an optional `type` field that `v:1` readers ignore, so a v0.8.0 CLI and Worker keep reading every cloud record. Machine records are `v:2` (`WORKER_RECORD_V` becomes 2): a v0.8.0 CLI drops them from `ls` and its orphan sweep skips with a warning, and a v0.8.0 Worker answers 404 for them, which is correct for a Worker that cannot pass through. The new CLI's orphan sweep (`r2_record_raw`) accepts a `v:2` record whose `storage` is `machine` and which has no `prefix` as readable and referencing no upload, so pointers never block the sweep; only an unreadable record or one above `v:2` still does. ### The Worker (WORKER_VERSION 3) @@ -152,13 +159,16 @@ Checks run in this order. Rows marked "as SPEC-007" are unchanged. | 2 | Host is not exactly `HOST` | 404 | | 3 | raw path holds `%2F`, `%5C`, `%2E`, a low escape, or `//` | 400 (as SPEC-007; now also in front of the tunnel leg) | | 4 | `/healthz`, `PASS` empty | as SPEC-007 | -| 5 | `/healthz`, `PASS` set | the Worker fetches `https:///healthz` (the route is skipped, TASK-1(a)) with a 3 s timeout. A `200 ok` sets `X-Share-Tunnel: 1` and answers `200 ok`; anything else sets `X-Share-Tunnel: 0` and answers `503 tunnel down`. Both carry `X-Share-Worker` and `X-Share-Gate` | +| 5 | `/healthz`, `PASS` set | the Worker fetches `https:///healthz` (the route is skipped, TASK-1a(a)) with a 3 s timeout. A `200 ok` sets `X-Share-Tunnel: 1` and answers `200 ok`; anything else sets `X-Share-Tunnel: 0` and answers `503 tunnel down`. Both carry `X-Share-Worker` and `X-Share-Gate` | | 6 | first segment is not 6 lowercase hex | `PASS` set: pass through (step 9); else 404 | -| 7 | `GET m/` returns a record with no `storage` or `storage == "cloud"` | SPEC-007 rows: version, id, prefix, and expiry checks (404 on any), the JWT for a gated record (404), then GET or HEAD served from R2 (other methods 405), hits counted | -| 8 | a `v:2` record whose `storage` is `machine`; no record; or the bucket read throws | `PASS` set: pass through; else 404 | -| 9 | pass through | `fetch(request)`, any method, the body streamed, the original headers (the Access JWT and cookie included). The origin's answer goes back unchanged (a WebSocket upgrade included, TASK-1(d)), except a 502, 503 that is not the origin's own, 520 to 527, or 530 (TASK-1(e) pins the set): `503`, `Retry-After: 60`, `Cache-Control: no-store`, `X-Robots-Tag: noindex, nofollow`, body `the machine serving this link is offline; try again later`. No hit is counted | +| 7 | `GET m/` returns a `v:1` record with no `storage` | SPEC-007 rows: version, id, prefix, and expiry checks (404 on any), the JWT for a gated record (404), then GET or HEAD served from R2 (other methods 405), hits counted | +| 8 | a `v:2` record whose `storage` is exactly `machine`; no record; or `BUCKET.get` itself throws or exceeds 2 s | `PASS` set: pass through; else 404 | +| 8b | any other record: not JSON, a `v` above 2, `v:2` with another `storage`, `v:1` with a `storage` key | 404, never passed through | +| 9 | pass through | `fetch(request)`, any method, the body streamed, the original headers (the Access JWT and cookie included). The origin's answer goes back rewrapped (`new Response(r.body, r)`, so `no-store` and `noindex` can be set on headers a `fetch` answer holds immutable), with status and body unchanged; a 101 WebSocket answer goes back untouched (TASK-1a(d)); except a 502, 503 that is not the origin's own, 520 to 527, or 530 (TASK-1a(e) pins the set): `503`, `Retry-After: 60`, `Cache-Control: no-store`, `X-Robots-Tag: noindex, nofollow`, body `the machine serving this link is offline; try again later`. No hit is counted | + +Callers of `/healthz` read the cloud leg from the headers, never from the status alone: a `503` carrying a well-formed `X-Share-Worker` pair and `X-Share-Tunnel: 0` means the Worker is up and the origin is down. `r2_healthz` sets `hz_code=200` for it and a new `hz_tunnel=0`, so a member's join (`r2_wait_healthz join`), a gated `add` on a member, and an r2 `state` (`ready: true`) keep working while the origin is off, which is the case R2 exists for. Only the origin's own `status` and `state` report the tunnel leg, as they do today. -A pass-through never serves R2 bytes, and a cloud record never reaches the tunnel. A gated machine link keeps its gate at the edge: Access runs before a route Worker (TASK-1(b)) and the Worker forwards the visitor's credentials (TASK-1(c)). An expired or malformed cloud record answers 404 and is never passed through, so a stale cloud id cannot fall back to a same-id file on the origin. Caddy keeps its own `@encsep` rule, so the origin refuses encoded separators even with the route deleted. +A pass-through never serves R2 bytes, and a cloud record never reaches the tunnel. A gated machine link keeps its gate at the edge: Access runs before a route Worker (TASK-1a(b)) and the Worker forwards the visitor's credentials (TASK-1a(c)). An expired or malformed cloud record answers 404 and is never passed through, so a stale cloud id cannot fall back to a same-id file on the origin. Caddy keeps its own `@encsep` rule, so the origin refuses encoded separators even with the route deleted. Every s.d.foundation request now runs the Worker and one bucket read (two for a cloud hit). Dwarves is on the Workers paid plan; live dev-server links (many small requests) cost Worker requests in the included 10 million. @@ -169,7 +179,7 @@ Every s.d.foundation request now runs the Worker and one bucket read (two for a | Profile | Rows | |---|---| | origin, R2 off | the local index, as today | -| origin, R2 on | the local index, plus every bucket record that is not a machine record whose id is in the local index (the local row wins) | +| origin, R2 on | the local index, plus every cloud record (machine records are the origin's own pointers; the local index is their truth, and an orphan pointer is the reconcile's, not a row) | | member | every bucket record | Each row carries: @@ -180,7 +190,7 @@ Each row carries: | `kind` | `snapshot` or `live` (the link type, unchanged) | the index opts, the record `opts` | | `type` | `pdf`, `image`, `video`, `audio`, `folder`, `site`, `markdown`, `archive`, `text`, `other` | the table below | | `by` | the machine that published it: the origin's own `this_host` for local rows, the import's `by=` for migrated rows, the record's `by` for bucket rows | | -| `access` | the rule or `null` (unchanged) | | +| `access` | the rule or `null` (unchanged); a member sees `gated` for the origin's gated machine rows, because pointers carry no rule | | `share ls` prints one tag before each link: `cloud`, `machine`, or `live` (a live row is always machine), then the type and `by=`. @@ -203,7 +213,7 @@ Local rows are typed at read time from `pub/` (`-d`, `-f`). New cloud record `state` (schema stays 1; every addition is a new key): `shares[].storage`, `shares[].type`, `shares[].by`; top-level `r2` (`true` when the profile reads a bucket) and, on an origin with R2 on, `storage_default`. When bucket rows cannot be read, `state` still prints the local rows and adds `cloud_error` (the reason line), so the menu never loses machine links to an R2 problem. -Token rule for listings: `state` and `profiles --json` read the bucket only with a token from the Keychain item or the 600 file. A profile whose token source is `api_token_cmd` (it may prompt) or the environment gets `cloud_error: "the menu reads cloud links only with a stored token: api-token"`. `ls` uses the full resolution, as SPEC-007. `cmd_profiles` stops setting `SHARE_STATE_BRIEF` for r2 profiles, so member sections list rows too (one `m/` listing plus one parallel record read per profile per refresh; at Class A and Class B list prices and a 60 s poll, under $1 a month at 500 records). +Token rule for listings: `state` and `profiles --json` read the bucket only with a token from the Keychain item or the 600 file. A profile whose token source is `api_token_cmd` (it may prompt) or the environment gets `cloud_error: "the menu reads cloud links only with a stored token: api-token"`. `ls` uses the full resolution, as SPEC-007. `profiles --json` already lists r2 rows (only the text `profiles` sets `SHARE_STATE_BRIEF`). To bound the poll's cost, `state` reads the `m/` listing (its `LastModified` per key) and fetches only the newest 25 records, the most any section shows; `ls` still reads every record. At a 60 s poll that is about 1.1 million Class B reads and 43,000 listings a month per polling Mac, roughly $0.60 at list prices, whatever the record count. ### Share Bar @@ -244,34 +254,42 @@ The publish dialog gains a `Storage` popup only for a profile whose state has `r ### Moving a tenant's origin: `share migrate` -`share [--profile

] migrate --to [--remote-profile ] [--remote-bin ] [--yes]`, run on the current origin while it is online. It needs `CLOUDFLARE_API_TOKEN` (Tunnel Edit, DNS Edit, Zone Read on the tenant's zone) and an ssh login on the target as the user who will run share there. +`share [--profile

] migrate --to [--remote-profile ] [--remote-bin ] [--yes]`, run on the current origin while it is online. It needs `CLOUDFLARE_API_TOKEN` (Tunnel Edit, DNS Edit, Zone Read on the tenant's zone) and an ssh login on the target as the user who will run share there. It moves a tenant with R2 off only: on a profile with `bucket=` it refuses (`R2 is on for ; moving its origin also moves the pointers and the publisher token, which this version does not do`). + +Every remote call is one fixed command, never a string the remote login shell (fish on Han's machines) parses: `ssh -o BatchMode=yes -- /bin/bash -c '' share-migrate `, where every argument is a checked slug or base64, the script sets `PATH` from the `--remote-bin` directory plus `/opt/homebrew/bin:/usr/local/bin:/usr/bin:/bin`, and a target starting with `-` is refused. The switch token travels on ssh stdin to a new `setup --token-stdin` flag, which reads one line into the process's own `CLOUDFLARE_API_TOKEN`. ``` Air (old origin) Mini (new origin), over ssh 1 preflight: named tunnel profile, this host in remote: import --probe -> "share-import 1"; hosts, no --host row, token resolves --profile state: not_setup, or set up - for the same hostname + for the same hostname; an empty index and pub/ + when not_setup; the profile's port pair free; + a Keychain write/read/delete round trip and + launchctl print gui/ answering 2 per snapshot row: --profile import tar -C pub/ -cf - . | ssh -------------------> (stdin: the tar; validates, stages, publishes) 3 the switch: the token on ssh stdin --------------> CLOUDFLARE_API_TOKEN from stdin; --profile setup --force + --token-stdin (new tunnel share-, CNAME repointed, service, live check) - 4 verify each moved link through https:/// (200, or the Access 302 for a gated one) + 4 verify through https:///: a nonce file that only the target holds answers 200 + three times in a row (so the Air cannot answer for it), then each moved link answers + 200, and each gated one 302 with kid equal to its app's AUD (a 200 there fails) 5 retire: moved rows -> index.migrated, pub/ -> migrated/ (nothing deleted), then the teardown path: service, tunnel air-share deleted, DNS left alone (it no longer points at this tunnel), tokens forgotten ``` -- `import` is a new verb (one usage line: `share import ... used by migrate over ssh`). It refuses: an id that is not 6 lowercase hex, or that exists in the local index, `pub/`, or `access-pending`; a name that is empty, starts with `.`, holds `/` or a control byte; opts other than `noindex`, `access=`, `access_rule=` (a live or `host=` row never arrives); a `by` outside the hostname character set. It extracts the tar into a fresh stage with `tar -xf - -C ` and refuses the whole import when the stage holds anything other than regular files and directories, a dotfile, or a path that left the stage (TASK-1(j) pins the tar flags on bsdtar and GNU tar). It then publishes as `stage_publish` does and appends the row with the original added date and expiry, `by=` added to opts, and `src` set to `:`. It reloads caddy when the profile is serving and works on a `not_setup` profile (pub and index need no setup). It never creates an Access app; the gated row keeps its `access=`, because the app's destinations (`/`) do not change with the machine. +- `import` is a new verb (one usage line: `share import ... used by migrate over ssh`). It refuses: an `expires` outside `^[0-9]{1,11}$`, an `added` outside `^[0-9]{4}-[0-9]{2}-[0-9]{2}$`, a `src` with a control byte or a tab; an id that is not 6 lowercase hex, or that exists in the local index, `pub/`, or `access-pending`; a name that is empty, starts with `.`, holds `/` or a control byte; opts other than `noindex`, `access=`, `access_rule=` (a live or `host=` row never arrives); a `by` outside the hostname character set. It spools stdin to a temp file under the profile root (capped at `SHARE_IMPORT_MAX_BYTES`, 2 GiB) and lists it with `tar -tvf` first: any member that is not a regular file or a directory (symlinks and hardlinks included), an absolute path, a `..` segment, or a dotfile refuses the whole import before anything is extracted. Only then does it extract into a fresh stage, and it refuses again if `find` sees a link count above 1 or anything but files and directories (TASK-7a (its first step) pins the flags on bsdtar and GNU tar). It then publishes as `stage_publish` does and appends the row with the original added date and expiry, `by=` added to opts, and `src` set to `:`. It reloads caddy when the profile is serving and works on a `not_setup` profile (pub and index need no setup). It never creates an Access app; the gated row keeps its `access=`, because the app's destinations (`/`) do not change with the machine. - `refresh` of a row whose `src` starts with `:` for another host dies with ` was moved from ; re-add it from a source on this machine`. - Live rows and their ports are listed as `not moved: live link (port ); after the move run ' add ' on the new origin`. `migrate` refuses while a `--host` row exists (` has its own hostname on this tunnel; remove it first: rm `). -- Stop points: a failure in steps 1 and 2 changes nothing on this machine and leaves imported copies on the target, unserved unless its profile serves; a rerun skips ids the target already holds with the same name and added date. A failure in step 3 leaves this machine serving; the DNS record may already point at the new tunnel, and `migrate` then prints the rollback, ` setup --force` on this machine (it reuses `air-share` and repoints the CNAME). A failure in step 4 stops before step 5 and names each link that did not answer. +- Stop points: a failure in steps 1 and 2 changes nothing on this machine and leaves imported copies on the target, unserved unless its profile serves; a rerun skips ids the target already holds with the same name and added date. A failure in step 3 leaves this machine serving; the DNS record may already point at the new tunnel, and `migrate` then prints the rollback, ` setup --tunnel-name --force` on this machine. The tunnel name is explicit because `setup` derives `share-` when none is given, which is the name the target just created: without it the old origin would join the new tunnel as a second connector. With it, setup reuses `air-share` and repoints the CNAME there; the target's new tunnel is left with no DNS record and serves nothing. A failure in step 4 stops before step 5 and names each link that did not answer. - `--yes` skips the one confirmation (`Move links of to and retire this machine as its origin? [y/N]`). - Test seam: `SHARE_MIGRATE_SSH` replaces `ssh -o BatchMode=yes ` with a local command (for example `env HOME=

`), so the suite and the rehearsal run both sides on one machine. -What visitors see: before the run, the Air serves (530 whenever it sleeps, as today). During step 3, between the CNAME change and the Mini's connector coming up, a few seconds of 530 (TASK-1(i) measures it in the rehearsal). After it, the Mini serves every moved link at the same URL. Live links from the Air end at step 5. +What visitors see: before the run, the Air serves (530 whenever it sleeps, as today). During step 3, from the CNAME change until the Mini's connector is up (setup writes DNS before it stores the token, installs the service, and starts; its live check allows 60 s), up to about a minute of 530; R2 measures the real gap. After it, the Mini serves every moved link at the same URL. Live links from the Air end at step 5. ### Tokens @@ -279,8 +297,8 @@ SPEC-007's model holds: an admin token from the environment for setup work, neve | Role | Scopes | Source | |---|---|---| -| admin, `setup --r2`, `--no-r2` | SPEC-007's minimal admin set (Workers Scripts Edit on the account, DNS Read and Zone Read on the zone, Workers R2 Storage Edit), plus Workers Routes Edit on the zone (the route), plus Access Apps and Policies Edit and Access Organizations Read (the alias fold's destination edits and the `TEAM` binding) | `op://Toolkit/cf-api-token/credential` when TASK-1(h) shows it holds every scope on the tenant's account; else a run-scoped token minted through `op://Toolkit/cf-tokens-admin` (`POST /user/tokens`, `expires_on` one day) | -| `migrate` switch | Cloudflare Tunnel Edit, DNS Edit, Zone Read on the tenant's zone | `op://Toolkit/cf-api-token/credential` when TASK-1(i) shows it edits tunnels and DNS on the personal account; else a run-scoped token as above | +| admin, `setup --r2`, `--no-r2` | SPEC-007's minimal admin set (Workers Scripts Edit on the account, DNS Read and Zone Read on the zone, Workers R2 Storage Edit), plus Workers Routes Edit on the zone (the route), plus Access Apps and Policies Edit and Access Organizations Read (the alias fold's destination edits and the `TEAM` binding) | `op://Toolkit/cf-api-token/credential` when TASK-1b(h) shows it holds every scope on the tenant's account; else a run-scoped token minted through `op://Toolkit/cf-tokens-admin` (`POST /user/tokens`, `expires_on` one day) | +| `migrate` switch | Cloudflare Tunnel Edit, DNS Edit, Zone Read on the tenant's zone | `op://Toolkit/cf-api-token/credential` when TASK-1b(i) shows it edits tunnels and DNS on the personal account; else a run-scoped token as above | | origin publisher (Mini, `dfoundation`) | Workers R2 Storage Bucket Item Write on `share-dfoundation` only, plus SPEC-004's Access scopes (gated adds), plus Zone Read | minted once through `cf-tokens-admin`, `expires_on` set, stored with `share --profile dfoundation api-token`; `r2_publisher_check` still refuses an admin or account-wide R2 token | | member publisher | as SPEC-007 | as SPEC-007 (DEC-007 there: one per teammate) | @@ -295,20 +313,20 @@ Both runs follow a rehearsal on throwaway hostnames (`## Test plan`, R1 and R2). | # | Step | Check | Rollback | |---|---|---|---| | D1 | upgrade share on the Mini to the release | `share --profile dfoundation status` unchanged | `brew` pin of the previous version | -| D2 | mint the origin publisher token (table above); `share --profile dfoundation api-token` stores it | `api-token --check` passes the bucket and Access lines | revoke the token | | D3 | `CLOUDFLARE_API_TOKEN= share --profile dfoundation setup s.d.foundation --r2 --bucket share-dfoundation --alias f.d.foundation` (storage default `local`) | the setup's live checks; `https://f.d.foundation/ba6377/support-ticket-guide/` answers 301 to `https://s.d.foundation/ba6377/support-ticket-guide/`, which answers 302 to the Access login with `kid` equal to the app's AUD; `a68960` still gated; `share --profile dfoundation ls` shows both rows, `ba6377` as `cloud`, `a68960` as `machine` | below | -| D4 | `share --profile files teardown --yes` (r2 teardown is local: config, token, `r2-own` go; the bucket and every share stay) | `share profiles` lists `default` and `dfoundation`; Share Bar shows one Dwarves section | `share --profile files setup f.d.foundation --backend r2 --bucket share-dfoundation` (join) | -| D5 | move any external monitor of `f.d.foundation/healthz` to `s.d.foundation/healthz` | vps-mon shows the check green | revert the catalog line | +| D2 | after D3 (its config gives `api-token` the bucket and endpoint): mint the origin publisher token (table above); `share --profile dfoundation api-token` stores it in the Keychain from a GUI login session on the Mini (the session Share Bar and Han's adds run in) | `api-token --check` passes the bucket and Access lines; a local `add` and `rm` from that session write and delete a pointer | revoke the token; local adds fail closed until a token is stored | +| D4 | `share --profile files teardown --yes` (r2 teardown is local: config, token, `r2-own` go; the bucket and every share stay) | `share profiles` lists `default` and `dfoundation`; Share Bar shows one Dwarves section | only after the D3 rollback (the join reads the marker host): `share --profile files setup f.d.foundation --backend r2 --bucket share-dfoundation`; revoke the `files` profile's publisher token once D6 is done | +| D5 | right after D3: move any external monitor of `f.d.foundation/healthz` to `s.d.foundation/healthz` | vps-mon shows the check green | revert the catalog line | | D6 | after seven days with no rollback: delete the Worker script `share-f-d-foundation` (it has no domain since D3) | `GET workers/scripts/share-f-d-foundation` answers 404 | redeployable from the v0.8.0 `bin/share` | -Rollback of D3, in order, all with the admin token, rehearsed in R1: rebind `f.d.foundation` to `share-f-d-foundation` (`PUT workers/domains`); restore the marker host `f.d.foundation` (`If-Match`); restore the `f.d.foundation/ba6377{,/*}` destinations on its app; `share --profile dfoundation setup s.d.foundation --no-r2` (the route goes; `s.d.foundation` is the plain tunnel again). The old Worker still serves every `v:1` cloud record, and it answers 404 for the `v:2` pointers it cannot pass through, which is correct on `f.d.foundation`. Hits recorded before D3 stay in dataset `share_f_d_foundation`; `share hits` after D3 reads `share_s_d_foundation` only. +Rollback of D3, in order, all with the admin token, rehearsed in R1 (the step-15 die with `--alias` prints exactly this list): rebind `f.d.foundation` to `share-f-d-foundation` (`PUT workers/domains`); restore the marker host `f.d.foundation` (`If-Match`); restore the `f.d.foundation/ba6377{,/*}` destinations on its app; clear `aliases` in the marker and the config; delete every `v:2` machine record (a v0.8.0 sweep refuses to run while one exists); `share --profile dfoundation setup s.d.foundation --no-r2` (the route goes; `s.d.foundation` is the plain tunnel again). The old Worker still serves every `v:1` cloud record, and it answers 404 for the `v:2` pointers it cannot pass through, which is correct on `f.d.foundation`. Hits recorded before D3 stay in dataset `share_f_d_foundation`; `share hits` after D3 reads `share_s_d_foundation` only. ### Personal (`s.han.ws`, R2 off, origin moves from the Air to the Mini) | # | Step | Check | Rollback | |---|---|---|---| | P1 | upgrade share on the Air and on the Mini | `share import --probe` on the Mini prints `share-import 1` | `brew` pin | -| P2 | on the Air: `CLOUDFLARE_API_TOKEN= share migrate --to tieubao@ --remote-profile default` | its step 4 (every moved link answers through `https://s.han.ws/`); on the Mini `share ls` shows them with `by=`; `share profiles` on the Mini lists `default · s.han.ws · serving` | `share setup s.han.ws --force` on the Air before step 5 (printed by `migrate`); after step 5 the Air's copies sit in `~/share/migrated/` and its rows in `~/share/index.migrated` | +| P2 | on the Air: `CLOUDFLARE_API_TOKEN= share migrate --to tieubao@ --remote-profile default` | its step 4 (every moved link answers through `https://s.han.ws/`); on the Mini `share ls` shows them with `by=`; `share profiles` on the Mini lists `default · s.han.ws · serving` | `share setup s.han.ws --tunnel-name air-share --force` on the Air before step 5 (printed by `migrate`, run once in R2); after step 5 the Air's copies sit in `~/share/migrated/` and its rows in `~/share/index.migrated` | | P3 | Share Bar on the Mini | two sections, `default · s.han.ws · Serving` and `dfoundation · s.d.foundation · Serving` | | The Mini's default profile is free today (`not_setup`), so `s.han.ws` lands there on port 8787 under the user that runs the `dfoundation` profile (`tieubao`). The new tunnel is `share-s-han-ws`; `air-share` is deleted in P2's step 5. R2 stays off; Han can enable it later with `setup --r2` on the Mini, which needs a bucket on the personal account (R2 is enabled there today, with no share bucket). After P2 the Air has no `s.han.ws` profile; it publishes personal links only after R2 is on (as a member) or by ssh to the Mini. @@ -336,31 +354,39 @@ A tunnel profile with no `bucket=` (every R2-off tenant) keeps every file, outpu | a cloud record and a machine link share an id | `If-None-Match: *` on `m/`; the reconcile | a cloud add picks another id; a pre-existing collision (an origin on v0.8.0 while R2 was on) is printed by the origin's `ls` and setup step 6; the Worker serves the cloud record | | a pointer write fails on a local add | `r2_call` code | the add dies before `stage_publish`; nothing is served | | a pointer outlives its link | the reconcile, prune | deleted after 10 minutes by the origin, or after expiry by any member; until then the id passes through and answers 404 | -| a request the route Worker passes through re-enters the Worker | TASK-1(a) | the spike blocks the build: the pass-through design changes to (B) before TASK-3 | -| Access does not run before a route Worker | TASK-1(b) | the spike blocks the build; a gated machine link must never reach the origin unauthenticated | +| a request the route Worker passes through re-enters the Worker | TASK-1a(a) | the spike blocks the build: the pass-through design changes to (B) before TASK-3 | +| Access does not run before a route Worker | TASK-1a(b) | the spike blocks the build; a gated machine link must never reach the origin unauthenticated | | a forged cloud record over a machine id (a publisher with bucket write) | none; same trust as SPEC-007 DEC-011 | the cloud bytes answer, inside the edge's Access app for that path when one exists; publishers are named teammates | -| an alias rebind leaves `f.d.foundation` without a Worker | step 13's API answer; step 14's 301 check | die naming the rollback; the old Worker stays deployed | +| an alias rebind leaves `f.d.foundation` without a Worker | step 14's API answer; step 15's 301 check | die naming the rollback; the old Worker stays deployed | | the gate probe on the added destinations times out | step 10 | the added destinations are removed; nothing else has changed | | `migrate` loses ssh mid-copy | the pipe's exit code | stop; the target holds the copies already imported, unserved unless its profile serves; a rerun skips them | | the switch fails after the CNAME moved | the remote setup's exit | `migrate` prints the one rollback command; the Air's tunnel and copies are intact until step 5 | | a moved link does not answer | step 4 | stop before step 5, naming each link | | a tar entry escapes the stage, or a symlink arrives | `import`'s stage scan | the import of that id dies; nothing is published for it | +| a member removes or refreshes a machine link | the row's `storage` | refused before any call; only the origin removes it | +| a forged bucket record names a live port, a host, or a shell expression | the `v:2` reader's checks; bucket rows never reach `rows()` | skipped or shown as text; nothing reaches the Caddyfile or arithmetic | +| the origin is off and a member joins or adds a gated link | `X-Share-Worker` and `X-Share-Tunnel` on a 503 | treated as Worker up; join and add proceed | +| an alias fold dies after the marker moved | step 11 wrote the config first | the die prints the alias rollback list; `--no-r2` refuses until it ran | | an old share CLI on the origin while R2 is on | none | it serves and adds local links without pointers; the reconcile on the next upgraded `ls` writes them and names any collision | | the token source is a command and the menu polls | the listing token rule | local rows plus `cloud_error`; no prompt during a poll | ## Task Breakdown -- [ ] TASK-1: spike on throwaway hostnames and tokens; answers in `docs/implementation-notes/one-host-per-tenant.md`. (a) A route Worker's `fetch(request)` and a `fetch` of `https:///healthz` reach the tunnel origin without running the Worker again. (b) Access answers an unauthenticated request to a gated path with its 302 before the route Worker runs, and an admitted request reaches the Worker with `Cf-Access-Jwt-Assertion`. (c) The pass-through of an admitted request reaches the origin (Access does not refuse the subrequest, or admits it on the forwarded cookie). (d) A POST and a WebSocket upgrade pass through to a live dev server. (e) The status codes a pass-through sees when the origin connector is down. (f) Rebinding an existing custom domain to another Worker: in place, or DELETE then PUT, and the gap. (g) `PUT access/apps/` with added destinations keeps the AUD. (h) Whether `op://Toolkit/cf-api-token` holds Workers Routes Edit and Access Apps Edit on the Dwarves account. (i) Whether it edits tunnels and DNS on the personal account, and the 530 gap of a CNAME switch. (j) bsdtar and GNU tar flags that refuse absolute and `..` members, and how each reports a symlink. AC: each item answered or the design amended before the task that consumes it; (a) or (b) failing blocks TASK-3. -- [ ] TASK-2: per-link storage on the origin: config keys, `--cloud`, `--local`, the refusals, `rand_id` across both legs, pointer write, delete, and reconcile. Depends on TASK-1(a)(b). AC: rows 2 to 6. -- [ ] TASK-3: Worker v3 (`ALIASES`, `PASS`, two-leg `/healthz`, the offline page, the method rule, `WORKER_RECORD_V` 2) and `tests/worker.mjs`. Depends on TASK-1(a)(b)(c)(d)(e). AC: rows 7 to 10. -- [ ] TASK-4: `setup --r2` and `--no-r2` (steps 1 to 9, 12, 14, 16). Depends on TASK-2, TASK-3, TASK-1(h). AC: rows 11, 12, 13. -- [ ] TASK-5: `--alias` (steps 4, 7, 10, 11, 13, 15). Depends on TASK-4 and TASK-1(f)(g). AC: row 14. -- [ ] TASK-6: one shared list: merged rows, `storage`, `type`, `by`, `r2`, `storage_default`, `cloud_error`, the listing token rule, `profiles --json` rows for r2 profiles. Depends on TASK-2. AC: rows 1, 15, 16, 17. -- [ ] TASK-7: `import` and `migrate`. Depends on TASK-1(i)(j). AC: rows 18 to 21. +- [ ] TASK-1a: edge spike on a throwaway hostname, one Worker, one tunnel, one Access app; answers in `docs/implementation-notes/one-host-per-tenant.md`. (a) A route Worker's `fetch(request)` and a `fetch` of `https:///healthz` reach the tunnel origin without running the Worker again. (b) Access answers an unauthenticated request to a gated path with its 302 before the route Worker runs; an admitted request reaches the Worker with `Cf-Access-Jwt-Assertion`. (c) The pass-through of an admitted request reaches the origin. (d) A POST and a WebSocket upgrade pass through to a live dev server. (e) The status codes a pass-through sees with the connector down, and a header that tells Caddy's own 502 apart. AC: each answered; (a) or (b) failing blocks TASK-3 and amends `### Approaches considered`. +- [ ] TASK-1b: account spike with throwaway tokens and names. (f) Rebinding an existing custom domain to another Worker: in place, or DELETE then PUT, and the gap. (g) `PUT access/apps/` with added destinations keeps the AUD. (h) Whether `op://Toolkit/cf-api-token` holds Workers Routes Edit and Access Apps Edit on the Dwarves account. (i) Whether it edits tunnels and DNS on the personal account. (k) The Workers plan on each account and the route's failure mode at the request limit. AC: each answered, the token table amended to the source each role uses. +- [ ] TASK-2: per-link storage on the origin: config keys, `--cloud`, `--local`, the refusals, storage dispatch for `rm`, `refresh`, `hits` (member refusals included), `rand_id` across both legs, pointer write, delete, reconcile, and the sweep rule for pointers. Depends on TASK-1a. AC: rows 2 to 6, 26, 27. +- [ ] TASK-3: Worker v3 (`ALIASES`, `PASS`, two-leg `/healthz`, the record branches, the rewrap, the offline page, the method rule, `WORKER_RECORD_V` 2), `r2_healthz`'s header reading, and `tests/worker.mjs` with a stub whose responses hold immutable headers. Depends on TASK-1a. AC: rows 7 to 10, 29. +- [ ] TASK-4: `setup --r2` and `--no-r2` without `--alias` (steps 1 to 3, 5, 6, 8, 9, 11, 13, 15) and the rerun convergence. Depends on TASK-2, TASK-3, TASK-1b(h). AC: rows 11, 12, 13. +- [ ] TASK-5: `--alias` (steps 4, 7, 10, 12, 14, 16), the `--no-r2` alias refusal, the printed rollback list. Depends on TASK-4 and TASK-1b(f)(g). AC: rows 14, 30. +- [ ] TASK-6: one shared list: merged display rows, the `v:2` reader's checks, `storage`, `type`, `by`, `r2`, `storage_default`, `cloud_error`, the listing token rule, the newest-25 fetch in `state`. Depends on TASK-2. AC: rows 1, 15, 16, 17, 28. +- [ ] TASK-7a: `import` and `setup --token-stdin`. Depends on no other task; its first step measures the tar flags on bsdtar and GNU tar (absolute, `..`, symlink, and hardlink members) before the pre-scan is written. AC: row 18. +- [ ] TASK-7b: `migrate` (preflight, copy, switch, verify, retire, the printed rollback). Depends on TASK-7a and TASK-1b(i). AC: rows 19, 20, 21, 31. - [ ] TASK-8: Share Bar: decode, `RowGlyphs`, row rendering, the `cloud_error` line, the `Storage` popup. Depends on TASK-6. AC: rows 22 to 25; `swift build` passes. -- [ ] TASK-9: `tests/e2e-tenant.sh`, `tests/e2e-migrate.sh`, and their run logs. Depends on TASK-2 to TASK-8. AC: L1 to L10, R1, R2, every cleanup assertion. -- [ ] TASK-10: README, how-it-works, setup (onboarding), ADR-0008, the verification record with the negative controls. Depends on TASK-9. AC: each doc claim matches the code line it describes; each negative control has a red and a green run. -- [ ] TASK-11: production migration D1 to D5 and P1 to P3, each check recorded in the verification record. Depends on TASK-10 and a release. AC: `## After state`. D6 runs seven days later. +- [ ] TASK-9a: `tests/e2e-tenant.sh` (L1 to L10) and rehearsal R1, with its run log. Depends on TASK-2 to TASK-6. AC: L1 to L10, R1, every cleanup assertion. +- [ ] TASK-9b: `tests/e2e-migrate.sh` and rehearsal R2 (the one-machine leg, then the Air to the Mini on a throwaway name), with its run log. Depends on TASK-7b. AC: R2 and its cleanup assertions. +- [ ] TASK-10: README, how-it-works, setup (onboarding), ADR-0008, the verification record with the negative controls. Depends on TASK-9a, TASK-9b. AC: each negative control has a red and a green run; each `docs/how-it-works.md` command, flag, and state key named in this spec is found by a `tests/share.sh` grep against `bin/share`. +- [ ] TASK-11a: STOP for Han, then the Dwarves migration D1 to D5. Before D3 the lead posts the R1 log and the exact D3 command and waits for Han's explicit go; no loop may run D3 on its own. Each check is recorded in the verification record. Depends on TASK-10 and a release. AC: the Dwarves items of `## After state`. D6 runs seven days later, after a second go. +- [ ] TASK-11b: STOP for Han, then the personal move P1 to P3. Before P2 the lead posts the R2 log and the exact P2 command and waits for Han's explicit go (P2 deletes `air-share` at its step 5). Depends on TASK-10 and a release; independent of TASK-11a. AC: the personal items of `## After state`. ## Test plan @@ -372,29 +398,35 @@ Local rows run in `tests/share.sh`: the r2 rows under `SHARE_R2_DRY=1` (SPEC-007 |---|---|---|---| | 1 | compat | `origin/main`'s `bin/share` and the branch's, each under its own `HOME` with the same seams: a quick setup and a named dry setup with no `bucket=`, add file, add folder, add live, `--host`, `ls`, `state`, `profiles --json`; then an r2 member profile | every file byte-identical; stdout and stderr identical except the `ls` tag column; `state` equal after deleting the keys `storage`, `type`, `by`, `r2`; the member's `profiles --json` entry gains rows | | 2 | storage | origin with R2 off: `add f`, `add --local f`, `add --cloud f`; R2 on with default `local`: `add f`, `add --cloud f`, `add 3000`, `add --cloud 3000`, `add --cloud --host x. f`, `add --cloud --local f`; default `cloud`: `add f`, `add --local f` | local, local, refused with the setup line; local plus pointer, cloud, live plus pointer, refused, refused, refused; cloud, local plus pointer; refusals log no PUT and write no row | -| 3 | member | an r2 member: `add f`, `add --local f`, `add 3000` | cloud; refused naming the origin's `by`; refused | +| 3 | member | an r2 member: `add f`, `add --local f`, `add 3000`; `rm` and `refresh` of a machine row; `rm` of a cloud row | cloud; refused; refused; both refused before any call (no `DELETE m/` logged); the cloud rm runs SPEC-007's path | | 4 | pointer | local add with R2 on; the bucket answers 412 for the first candidate | `GET m/` and the prefix scan precede the pointer PUT; the pointer PUT carries `If-None-Match: *`, `v:2`, `storage:"machine"`, no `access=` and no `host=` in `opts`; the first candidate is skipped; the pointer PUT precedes the `pub/` rename (a watcher on `pub/`); a gated add's pointer PUT precedes `POST app` | | 5 | pointer | the pointer PUT answers 500 three times | exit 1 naming R2; no `pub/`, no row, no app | -| 6 | reconcile | origin `ls` with: a machine record by this host with no row, aged 11 min; one aged 1 min; a local row with no pointer; a local row whose id holds a cloud record; `rm` and `prune` of local rows with and without a token | the old orphan pointer deleted, the young one kept; a pointer written for the bare row; the shadow line printed, nothing deleted; rm and prune delete the pointer with a token and leave it without one | +| 6 | reconcile | origin `ls` with: a machine record with no row, `LastModified` 11 min ago (one with `by` of another machine too); one 1 min ago; one 11 min ago whose id is in `access-pending`; a local row with no pointer; a local row whose id holds a cloud record; `rm` and `prune` of local rows with and without a token | both old orphan pointers deleted, the young one and the pending one kept; a pointer written for the bare row; the shadow line printed, nothing deleted; rm and prune delete the pointer with a token and leave it without one | | 7 | Worker | `PASS` set: a cloud record; a machine record; no record; the bucket throws; a non-hex first segment; `/`; an expired cloud record; a cloud record whose prefix names another id | R2 bytes, no pass-through; pass-through x5 (machine, none, throw, non-hex, root); 404 with no pass-through x2 | | 8 | Worker | `PASS` set, pass-through answers: 200 with a body; 404; 530; 502; a thrown fetch; a POST with a body; an `Upgrade: websocket` request | unchanged x2; 503 offline page with `Retry-After` x3; the POST forwarded with its method and body; the upgrade forwarded and its 101 returned unchanged | | 9 | Worker | `ALIASES=f.test`: GET `https://f.test/ba6377/g/?a=1`, HEAD, POST, `/healthz`; Host `x.test`; `PASS` empty: a miss | 301 to `https:///ba6377/g/?a=1`, 301, 405, 301; 404; 404 with no `fetch` call | | 10 | Worker | `/healthz` with `PASS` set and the origin stub answering `200 ok`, then 530, then a timeout; with `PASS` empty | 200 `X-Share-Tunnel: 1`; 503 `X-Share-Tunnel: 0` x2; SPEC-007's answer with no stub call. Every row 7 to 10 answer carries `no-store` and `noindex`, and SPEC-007's rows 17 to 19 and 28 pass unchanged on the v3 source | -| 11 | setup | dry `setup --r2` on the origin | log order: script GET < bucket reads < marker GET < routes GET < `m/` list < script PUT < subdomain POST < subdomain GET < pointer PUTs < route POST < healthz; config gains the four keys; the tunnel config and Keychain items unchanged | +| 11 | setup | dry `setup --r2` on the origin | log order: script GET < bucket reads < marker GET < routes GET < `m/` list < script PUT < subdomain POST < subdomain GET < pointer PUTs < config write < route POST < healthz; config gains the four keys; the tunnel config and Keychain items unchanged | | 12 | setup | refusals: on a non-origin; on quick mode; a publisher token (403 on the script read); a route on `/*` naming another script; a local id holding a cloud record; a marker naming a third host | each dies before any PUT, POST, or DELETE in the log | | 13 | rollback | `--no-r2` after row 11 | route DELETE logged; the four keys gone; the next `add` writes no pointer; `rows()` output equal to before row 11 | -| 14 | alias | dry `--alias f.test` with a gated cloud record; the alias domain naming another service; the gate probe failing | order: app GET < script PUT < pointer PUTs < app PUT (destinations added, AUD unchanged) < probes < marker PUT `If-Match` < route POST < domain PUT < healthz < app PUT (alias destinations dropped) < config; the other service: dies before any write; probe failure: the added destinations removed, no marker PUT, no route | +| 14 | alias | dry `--alias f.test` with a gated cloud record; the alias domain naming another service; the gate probe failing | order: app GET < script PUT < pointer PUTs < app PUT (destinations added, AUD unchanged) < probes < config write < marker PUT `If-Match` < route POST < domain PUT < healthz < app PUT (alias destinations dropped); the other service: dies before any write; probe failure: the added destinations removed, no marker PUT, no route | | 15 | list | origin with R2 on: two local rows (one live, one gated), their pointers, two cloud records (one by another machine, one gated), a `v:1` cloud record without `type` named `guide` | `ls` shows six rows with tags `machine`, `live`, `cloud`, `cloud`, their types, and `by=`; no id twice; `state` rows carry `storage`, `type`, `by`; `guide` is `folder` | | 16 | type | the type table, one fixture per row, plus a folder with `index.html`, a folder without, `Report.PDF`, `a.tar.gz`, no extension on a local file | each `type` as the table; the extension match ignores case | | 17 | list | `state` with the bucket listing failing; with the token source `api_token_cmd`; `profiles --json` with an r2 member and a tunnel profile | local rows plus `cloud_error` (exit 0); no command run (a sentinel command that touches a file is never run), `cloud_error` names `api-token`; the member entry lists its rows | | 18 | import | a tar with a regular tree; a symlink; a dotfile; a `../x` member; an absolute member; an id already in the index; opts `live`; opts `host=x`; a name with `/` | the first published with the original dates, `by=`, and `src` `:`; each other refused with no `pub/` and no row | -| 19 | migrate | two `HOME`s (A the old origin, B the target, `SHARE_MIGRATE_SSH`): A holds a snapshot, a folder, a gated snapshot, a live row | B's index holds the three snapshots with A's ids, names, expiries, and `access=`; B's `pub/` trees equal A's byte for byte; the live row is listed as not moved; B's dry setup ran with `--force` and the token from stdin (a `curl` shim shows the token in no argv); A's rows are in `index.migrated`, its trees in `migrated/`; A's teardown path ran and logged no DELETE of an Access app | -| 20 | migrate | a `--host` row on A; B's profile set up for another hostname; the copy failing on the second id; the remote setup failing; a verify probe failing | refused before any copy; refused before any copy; stop with the first id on B and A unchanged; A unchanged and the rollback line printed; stop before retire, the failing link named | +| 19 | migrate | two `HOME`s (A the old origin, B the target, `SHARE_MIGRATE_SSH` pointing at a wrapper that logs its own argv): A holds a snapshot, a folder, a gated snapshot, a live row | B's index holds the three snapshots with A's ids, names, expiries, and `access=`; B's `pub/` trees equal A's byte for byte; the live row is listed as not moved; B's dry setup ran with `--force --token-stdin` (the token is in no argv the wrapper or a `curl` shim logged); A's rows are in `index.migrated`, its trees in `migrated/`; A's teardown path ran and logged no DELETE of an Access app | +| 20 | migrate | a `--host` row on A; `bucket=` on A; B's profile set up for another hostname; B's `not_setup` profile holding an old row; the copy failing on the second id; the remote setup failing; a gated link answering 200 at verify | refused before any copy x4; stop with the first id on B and A unchanged; A unchanged and the rollback line printed with `--tunnel-name `; stop before retire, the gated link named | | 21 | migrate | rerun after row 20's copy failure | the first id skipped as already moved; the rest copied | | 22 | swift | decode `profiles --json` fixtures with and without the new fields; a share with an unknown `type` | `storage`, `type`, `by`, `r2`, `storageDefault`, `cloudError` decoded; absent fields are nil; unknown type maps to `doc` | | 23 | swift | `RowGlyphs` over every value in the SF Symbols table | the symbol names and words match the table; every symbol resolves with `NSImage(systemSymbolName:)` on the test host | | 24 | swift | `MenuModel` rows: a gated cloud PDF, a live machine row, an older-CLI row with no `storage` | accessibility titles `, , PDF, in the cloud, snapshot, login required`; `..., site, on this tenant's machine, live server`; no storage word for the old row | | 25 | swift | `PublishForm` on an origin with `r2: true` and `storage_default: cloud`; on a member; on a tunnel profile with `r2: false` | the `Storage` popup preselects `In the cloud` and the argv carries `--cloud`; a disabled `In the cloud` label and no flag; no popup and no flag | +| 26 | sweep | R2 on: bare `prune` with machine pointers present and an unreferenced upload 25 h old; then a record that is not JSON | the old upload deleted, no warning; with the broken record, nothing deleted and the warning names it | +| 27 | dispatch | on the origin: `rm` of a cloud row, `rm` of a machine row, `refresh` of each, `hits` of each | the cloud ones take the r2 path (S3 calls, the SQL call); the machine ones take the tunnel path plus the pointer DELETE; `hits` of a machine row reads the access log | +| 28 | hostile | on an R2-on origin, forged records: `v:1` with `opts:"live host=x.test"` and `src:"x:22"`; `v:2` machine with `opts:"live"`, `src:"x:22"`, and `expires:"a[$(touch S)]"`; `v:2` with `name` holding a tab; then `ls`, `state`, `profiles --json`, `prune`, `refresh ` | the Caddyfile, `index.tsv`, and the running Caddy config are byte-identical before and after; the file `S` does not exist; each forged record is skipped or shown as display text only; `refresh` of the forged ids dies | +| 29 | healthz | dry Worker answer `503` with this CLI's pair and `X-Share-Tunnel: 0`: a member joins, adds a gated cloud link, and runs `state` | the join exits 0; the gated add publishes; `state` has `ready: true`; a `503` with no `X-Share-Worker` header still fails all three | +| 30 | alias | dry `--alias f.test`, die injected at each of steps 12, 13, 14, 15; then `--no-r2`; then the printed rollback list; then a rerun of the fold | each die prints the alias rollback list, not `--no-r2` alone; `--no-r2` refuses while `aliases=` is set; after the list, the integration Worker for the alias serves the cloud record itself; the rerun converges to row 14's end state | +| 31 | rollback | after row 20's remote-setup failure, run the printed line on A in dry mode | the dry DNS PUT names A's own tunnel; A's config keeps its `tunnel_id`; no call touches B's tunnel | ### Negative controls @@ -415,6 +447,12 @@ Each is a temporary one-line patch that must turn the named rows red, recorded w | run the full teardown (with Access deletes) on the old origin before moving rows aside | row 19 | | read the bucket in `state` through `api_token_cmd` | row 17 | | write `bucket=` into a plain tunnel config at setup | row 1 | +| merge bucket rows through `rows()` | row 28 (the Caddyfile changes) | +| let the sweep count a machine pointer as unreadable | row 26 | +| let a member `rm` a machine row | row 3 | +| read the healthz status alone in `r2_healthz` | row 29 | +| print `--no-r2` as the step-15 rollback with an alias | row 30 | +| omit `--tunnel-name` from the printed migrate rollback | row 31 | ### Live legs (`tests/e2e-tenant.sh`, by hand before the release) @@ -426,7 +464,7 @@ Inputs: `SHARE_E2E_TENANT_HOST` and `SHARE_E2E_ALIAS_HOST` (unused names on a zo | L2 | `setup --r2` on A | `/healthz` 200 with `X-Share-Tunnel: 1` and this CLI's pair; pointers exist for L1's ids; `https://..workers.dev/healthz` does not answer 200 | | L3 | through the Worker | L1's links answer as before; the gated one still 302s unauthenticated and serves with the service token (SPEC-007 TASK-1(l) method); the live server answers a POST and a WebSocket echo | | L4 | A `add --cloud`; member B joins and adds | both cloud links 200 with `no-store`; `ls` on A and on B show the same five rows with tags | -| L5 | stop A's tunnel (`share stop`) | cloud links 200; machine and live links 503 offline page; `/healthz` 503 with `X-Share-Tunnel: 0`; start A, all back | +| L5 | stop A's tunnel (`share stop`) | cloud links 200; machine and live links 503 offline page; `/healthz` 503 with `X-Share-Tunnel: 0`; a fresh member C joins and B adds a gated cloud link, both succeed, and B's `state` reads `ready: true`; start A, all back | | L6 | encoded paths `/x/..%2F/f`, `///f`, `/%2F/f` on a machine id and a cloud id | 400 each, from the Worker | | L7 | `--no-r2` | the route is gone; machine links serve straight from the tunnel; cloud links 404 at Caddy | | L8 | `setup --r2` again | back to L4's state with no new pointer PUT for existing pointers | @@ -438,7 +476,7 @@ Rehearsals (each before its production run): | # | Rehearsal | Assert | |---|---|---| | R1 | the Dwarves fold on throwaway names: A is a tunnel origin on ``, C an r2 profile on `` with a gated cloud share; then `setup --r2 --alias ` on A; then the rollback list in `### Dwarves`; then the fold again | after the fold: `https://///` 301 to `https://///`, which 302s with `kid` equal to the original AUD and serves with the service token; after the rollback: the alias serves the share itself and the tenant is a plain tunnel; after the second fold: as after the first | -| R2 | the personal move on throwaway names: A (stand-in for the Air) sets up `` with a snapshot, a folder, a gated snapshot, a live server; `migrate --to` B on the same machine (`SHARE_MIGRATE_SSH`), then once more from the real Air to the Mini over ssh (a second throwaway name) | every snapshot link answers from B (or the Mini) at the same URL; the gated one keeps its 302 and AUD; the live link is reported as not moved; A's tunnel is deleted, its DNS record points at B's tunnel; the measured 530 gap is logged | +| R2 | the personal move on throwaway names: A (stand-in for the Air) sets up `` with a snapshot, a folder, a gated snapshot, a live server; `migrate --to` B on the same machine (`SHARE_MIGRATE_SSH`), then once more from the real Air to the Mini over ssh (a second throwaway name) | every snapshot link answers from B (or the Mini) at the same URL; the gated one keeps its 302 and AUD; the live link is reported as not moved; A's tunnel is deleted, its DNS record points at B's tunnel; the measured 530 gap is logged. Before that, one run injects a remote-setup failure and runs the printed rollback: the hostname serves from A's tunnel again | Cleanup is asserted, then enforced, as SPEC-007's live leg does: after L10 and each rehearsal the script checks that no route, Worker, custom domain, DNS record, tunnel, bucket, Access app, or minted token with the throwaway names remains, and its EXIT trap runs the purges and a by-name fallback whatever happened before, printing each leftover. @@ -459,7 +497,7 @@ Read-only probes, 2026-10-01, from the Mini, through a helper that sends the Too | personal account | `GET accounts//r2/buckets`, `POST .../analytics_engine/sql` | `200`, no bucket named `share*`; `200` | | zone plans | `GET zones?name=` | both `Free Website`; Workers paid status not probed (SPEC-007 cost section assumed the Dwarves paid plan) | -Not sampled (TASK-1): the route pass-through, Access order against a route Worker, the subrequest under Access, WebSocket pass-through, the down-origin status codes, the custom-domain rebind, the AUD after a destination edit, the Toolkit token's route, Access, and tunnel scopes, tar member handling. +Not sampled (TASK-1a, TASK-1b, TASK-7a): the route pass-through, Access order against a route Worker, the subrequest under Access, WebSocket pass-through, the down-origin status codes, the custom-domain rebind, the AUD after a destination edit, the Toolkit token's route, Access, and tunnel scopes, tar member handling. Dry trace for one negative control: "write the pointer after `stage_publish`" moves the `r2_call PUT m/` line below the `mv` in the local add; row 4's background watcher records the first time `pub/` exists and compares it with the line number of `PUT m/` in `r2-calls.log` (the watcher appends a `SEEN pub/` line to the same log), so the order flips and the row fails mechanically. @@ -509,7 +547,22 @@ Moving a tunnel tenant's local shares into its bucket (a `--cloud` re-add covers - DEC-003 (operator): the Mini is the single origin for both tenants, because it is the only always-on machine; the Air's sleep is the cause of today's 530s on `s.han.ws`. - DEC-004: the Worker reaches the tunnel by route pass-through on the same hostname; no internal hostname, no service token, and the rollback is one route delete. - DEC-005: R2 first, then the tunnel; a cloud id never falls back to the origin. -- DEC-006: pointer records exist only with R2 on; they make the list shared on every member and the namespace collision-proof. A local add fails closed when R2 is on and the pointer cannot be written. +- DEC-006: pointer records exist only with R2 on; they make the list shared on every member and the namespace collision-proof. A local add fails closed when R2 is on and the pointer cannot be written. The cost, stated plainly: on an R2-on tenant, local publishing depends on R2 answering and on a stored publisher token (Share Bar and the CLI alike); the gain over failing open is that no member's cloud add can take an id the origin holds, a 1 in 16.7 million chance per add, plus a list that is never missing a machine link. Accepted because the tenant admin turned R2 on deliberately; an R2-off tenant keeps today's independence. - DEC-007: cloud records stay `v:1` (plus an optional `type`), pointers are `v:2`, so the old Worker remains a valid rollback for every cloud link. - DEC-008: `f.d.foundation` becomes an alias the tenant Worker answers with 301; the bucket `share-dfoundation` is adopted in place, so `ba6377` keeps its record, bytes, and Access app AUD. - DEC-009: `share migrate` moves an origin over ssh with the receiver's `import`, keeping ids, names, dates, expiries, and gates; nothing on the old origin is deleted, only moved aside. +- DEC-010: the new origin gets its own tunnel; reusing the old one would leave its id in the old machine's config, where a later teardown deletes the new origin's tunnel. +- DEC-011: bucket rows are display data only; the origin's index, Caddyfile, and stage paths never read a record another publisher could write. +- Round 1 (seven fresh-context reviewers, 2026-10-01): eleven criticals, all folded. Warnings that do not change the design went to `docs/implementation-notes/one-host-per-tenant.md` for the builder. + +| Change | Why (reviewer) | +|---|---| +| Bucket rows never enter `rows()`, the index, the Caddyfile, `refresh`, or a stage; the `v:2` reader checks every field before shell arithmetic; pointers carry `gated`, not the rule; row 28 | a forged record could add a reverse proxy to a local port or run a command through `expires` (1) | +| `setup --r2` writes its config before the marker; `--no-r2` refuses while an alias is set; the step-15 die prints the alias rollback list; reruns converge; row 30 | a die after the rebind plus the printed `--no-r2` would leave the guide link 404 for good (2) | +| A member's `rm` and `refresh` of a machine row refuse; `rm`, `refresh`, `hits` dispatch on storage; rows 3, 27 | a member saw "unpublished" while the origin kept serving the file (2) | +| The migrate rollback names `--tunnel-name `; row 31; R2 runs it | `setup` derives the new origin's tunnel name, so the printed line joined the old origin to the new tunnel (3) | +| `/healthz` 503 with a valid Worker pair counts as Worker up for joins, gated member adds, and r2 `state`; row 29; L5 | members could not join or publish while the origin was off, the case R2 exists for (3) | +| TASK-1 split into an edge spike and an account spike, the tar check moved into TASK-7a; TASK-7, TASK-9, TASK-11 split; TASK-11a and TASK-11b start with an explicit stop for Han | task size, and no human gate before the production migrations (4) | +| The orphan sweep reads a `v:2` machine pointer as readable and referencing nothing; row 26 | every pointer counted as unreadable, so the sweep would never run again on any machine (5) | +| Also fixed because the text was false or unsafe: `profiles --json` already lists r2 rows; the poll cost (newest 25 records, about $0.60 a month); reconcile keyed on `storage` and aged by `LastModified`; `migrate` refuses an R2-on tenant, runs a fixed `/bin/bash -c`, takes the token through `setup --token-stdin`, pre-scans the tar, validates `added`, `expires`, `src`, preflights the target, and verifies through a target-only nonce and the gated AUD; the real 530 bound; D2 after D3, D5 with D3, D4's rollback only after D3's; the Worker record branches, its 2 s read cap, and the response rewrap | reviewer warnings with a concrete wrong statement or unsafe default behind each (1, 2, 3, 5) | +| Not taken: reusing `air-share` (DEC-010); failing open on a pointer write (DEC-006); a redirect rule in place of `--alias` (the 301 stays in tested code; the alias surface is permanent but small, and it retires with `--no-r2` after the alias rollback) | (5) | From f90fcb5382061de8e658a8220b2a210828f2caee Mon Sep 17 00:00:00 2001 From: Han Ngo Date: Thu, 1 Oct 2026 17:16:03 +0700 Subject: [PATCH 03/55] docs: fold round 2 spec review into the tenant spec The r2 setup refuses a tenant host, the storage task splits in two, and bucket reads on the origin get their own reader. Remaining warnings go to the implementation notes. --- .../one-host-per-tenant.md | 21 +++++++++ docs/specs/SPEC-008-one-host-per-tenant.md | 44 +++++++++++++------ 2 files changed, 52 insertions(+), 13 deletions(-) diff --git a/docs/implementation-notes/one-host-per-tenant.md b/docs/implementation-notes/one-host-per-tenant.md index 6233ea6..85f0ab1 100644 --- a/docs/implementation-notes/one-host-per-tenant.md +++ b/docs/implementation-notes/one-host-per-tenant.md @@ -21,3 +21,24 @@ Round 1 warnings the spec did not fold. Each one is a build rule unless a task's | After P2 nothing watches `s.han.ws` | add `s.han.ws/healthz` to vps-mon in TASK-11b | TASK-11b | | After P2, a local restore on the Air (rows from `index.migrated`, trees from `migrated/`) has no command | rehearse the manual restore once in R2 and write it into the verification record | TASK-9b | | `--alias` adds permanent surface for a one-time fold | keep the alias code small and covered; it retires with the alias rollback and `--no-r2` | TASK-5 | + +Round 2 warnings, same rule: + +| Warning | Build rule | Task | +|---|---|---| +| A D3 rollback breaks cloud links added after the fold | the alias rollback list prints the cloud ids added since the fold, so Han re-adds or warns first | TASK-5 | +| Teammates still joined at `f.d.foundation` are not told | D4 lists the bucket's `by` values and tells each to rejoin at `s.d.foundation` | TASK-11a | +| `migrate` holds no index lock; an add on the old origin mid-run is never moved | hold the index lock from step 1 to step 5, or re-diff the index before retiring and stop on new rows | TASK-7b | +| `migrate` on a laptop can sleep mid-retire | run under `caffeinate -i` on macOS; make the retire idempotent on rerun | TASK-7b | +| The 2 GiB import cap has no override | the preflight lists over-cap ids; add `--max-bytes` | TASK-7b | +| A fresh-bucket `setup --r2` can die between pointers and the marker | write `share.json` before step 9 when the bucket is new; add a fresh-bucket variant of row 11 | TASK-4 | +| A slow R2 adds up to 2 s to every machine request | add a failure-mode line with Worker wall-time as the signal; consider a lower read cap after L3 measures it | TASK-3 | +| A DELETE-then-PUT rebind can leave NXDOMAIN cached past the API gap | TASK-1b(f) measures through a public resolver queried during the gap | TASK-1b | +| A die at steps 9 or 10 with `--alias` leaves `v:2` pointers that block a v0.8.0 member's sweep | that die names the rerun or the pointer cleanup | TASK-5 | +| The Mini carries both tenants; a reboot stalled at the login window takes both down | vps-mon `healthz` on both hostnames is the signal; state it in the failure table of the ADR | TASK-10, TASK-11b | +| `hits` of a machine row on a member is unspecified; members see `--host` rows at the wrong URL | `hits` on a member refuses a machine row; pointers carry an `own_host` display field | TASK-2b, TASK-6 | +| The member purge is undefined on a tenant (route, not custom domain; machine rows refused on members) | a member purge refuses while the Worker binds `PASS="1"`; the origin runs `--no-r2` first; tenant purge order is route, pointers, records, script, bucket | TASK-4 | +| The migrate gated check needs Access read; the old origin's tunnel name may be missing | step 4 uses the old origin's stored Access token; preflight reads `tunnel_name` from the API by `tunnel_id` when the key is absent | TASK-7b | +| bsdtar lists a hardlink as a regular mode with `link to`; archive flags can pin files | the pre-scan rejects any `link to`; extract with no file flags or xattrs; row 18 gains a sibling hardlink and an absolute one | TASK-7a | +| Pointers expose every machine link's URL to every member | `docs/setup.md` says so in the onboarding | TASK-10 | +| The D3 rollback rebinds before restoring app destinations | restore destinations first, then rebind | TASK-5 | diff --git a/docs/specs/SPEC-008-one-host-per-tenant.md b/docs/specs/SPEC-008-one-host-per-tenant.md index 61c0d09..f6264a1 100644 --- a/docs/specs/SPEC-008-one-host-per-tenant.md +++ b/docs/specs/SPEC-008-one-host-per-tenant.md @@ -117,10 +117,14 @@ It runs on the origin only (`host_ok`, else `run this on the origin, `), | 15 | live checks: `/healthz` three times in a row with this CLI's `X-Share-Worker` pair and `X-Share-Tunnel: 1`; a probe file through the tunnel leg; with `--alias`: `https:///healthz` answers 301 to `https:///healthz` | timeout: die naming the leg. Without `--alias` the named rollback is `--no-r2`. With `--alias` the die prints the alias rollback list (`## Migration`, Dwarves), never `--no-r2` alone | | 16 | with `--alias`: each step-7 app drops its `` destinations; the local profile whose `hostname` is `` hands its `r2-own` lines to this profile | | +A folded app keeps its name `share ` and its AUD. `access_delete` and the by-name purge accept an app named for this host or for any host in `aliases=`, so `rm` and expiry of a folded share delete its app. + A rerun of the same command after a die at any step converges: each write is idempotent (deploy compares the SHA, pointers answer 412 for an id that already holds this machine's pointer and count as done, the app PUT and the marker PUT compare before writing, the route and domain reads show an existing binding to this Worker). Worker bindings on a tenant: SPEC-007's set plus `PASS="1"` (pass misses to the origin) and `ALIASES` (comma list). An r2-only profile (SPEC-007, custom domain) keeps `PASS=""` and today's answers. +SPEC-007's `setup --backend r2 --bucket ` refuses a tenant host in its admin role, even with `--force`, before any write: when the deployed Worker binds `PASS="1"`, when the marker holds `aliases`, or when a route on `/*` exists. Its message names the tenant command, `run ' setup --r2 --bucket ' on the origin`. Without this, an admin token in the environment on a member (the join path's version hint points there) would redeploy the Worker without `PASS` and `ALIASES`, and replace the tunnel CNAME with a custom domain, taking every machine link and the alias down. The join role is unchanged; the join's version-mismatch hint names the tenant command when `X-Share-Tunnel` is present. + `share [--profile

] setup --no-r2` (admin token, on the origin) is the rollback for a tenant without an alias: delete the route, then drop the four config keys. It refuses while the config's `aliases=` or the marker's `aliases` is non-empty (` redirects here; run the alias rollback first: ...` with the list), because an alias whose 301 lands on a plain tunnel turns every folded cloud link into a 404. The tunnel serves the hostname alone again. Pointer records, cloud records, the bucket, and the Worker stay; `teardown --yes --purge` on a member, or the purge path on the origin, removes them as SPEC-007 says. ### Per-link storage on the origin @@ -145,7 +149,11 @@ With R2 on, a local add on the origin also writes the id's pointer record: 3. `rm` and `prune` of a local row delete its pointer after the local removal. Without a resolvable token (the login service's hourly prune runs with `SHARE_API_TOKEN_OFF=1`) the pointer stays; it is harmless (the Worker passes the id to the tunnel, which answers 404), and the next interactive `ls` or `prune` on the origin, or any member's `prune` once `expires` is past, deletes it. 4. Reconcile on the origin's interactive `ls` and `prune`, keyed on `storage == "machine"` alone (one origin per tenant, so every machine record is the origin's, migrated rows included): a machine record with no local row, whose S3 `LastModified` is over 10 minutes old, and whose id is neither in `access-pending` nor under a held add lock, is deleted (an add that died after step 2); a local row with no pointer gets one (`If-None-Match: *`; a 412 means a cloud record holds the id: printed as ` is shadowed by a cloud link; rm one of them`, never auto-deleted). -Bucket rows are display data. A record read from the bucket, cloud or machine, never enters `rows()`, `index.tsv`, the Caddyfile, `refresh`, or a stage path, so a forged record cannot add a reverse proxy, a site block, or a source path on the origin. The `v:2` reader applies `r2_record_row`'s checks plus: `id` equal to the key's id; `expires` matching `^[0-9]{1,11}$` and `added` matching `^[0-9]{4}-[0-9]{2}-[0-9]{2}$` before any shell arithmetic sees them; `name` and `by` free of control bytes and tabs, `by` in the hostname set; `opts` tokens only from `noindex`, `live`, `gated`; `type` from the type table; any other field ignored. A record that fails is skipped like a forged row. +Bucket rows are display data. A record read from the bucket, cloud or machine, never enters `rows()`, `index.tsv`, the Caddyfile, `refresh`, or a stage path, so a forged record cannot add a reverse proxy, a site block, or a source path on the origin. On a profile with a tunnel, the bucket reader writes its own snapshot file and never assigns `index` (SPEC-007's `r2_snapshot` rebinds `index` for the process, which on the origin would hand bucket rows to `write_caddyfile` in the same run). Every bucket key is checked against `^m/[0-9a-f]{6}$` before its record is read, displayed, built into a URL, or deleted. + +The origin's interactive bare `prune` and `ls` run SPEC-007's cloud expiry, its gated-expiry rule, and the orphan sweep through that reader, because after the fold the origin may be the only publisher left; the login service's hourly prune (token off) touches local rows only, as today. + +`by` is always `r2_by` (lowercase, `a-z0-9.-`): on local rows, pointers, `import`'s argument, and `refresh`'s `:` match, so `Mac-mini` and `mac-mini` never disagree. The `v:2` reader applies `r2_record_row`'s checks plus: `id` equal to the key's id; `expires` matching `^[0-9]{1,11}$` and `added` matching `^[0-9]{4}-[0-9]{2}-[0-9]{2}$` before any shell arithmetic sees them; `name` and `by` free of control bytes and tabs, `by` in the hostname set; `opts` tokens only from `noindex`, `live`, `gated`; `type` from the type table; any other field ignored. A record that fails is skipped like a forged row. Record versions: cloud records stay `v:1` and gain an optional `type` field that `v:1` readers ignore, so a v0.8.0 CLI and Worker keep reading every cloud record. Machine records are `v:2` (`WORKER_RECORD_V` becomes 2): a v0.8.0 CLI drops them from `ls` and its orphan sweep skips with a warning, and a v0.8.0 Worker answers 404 for them, which is correct for a Worker that cannot pass through. The new CLI's orphan sweep (`r2_record_raw`) accepts a `v:2` record whose `storage` is `machine` and which has no `prefix` as readable and referencing no upload, so pointers never block the sweep; only an unreadable record or one above `v:2` still does. @@ -213,7 +221,7 @@ Local rows are typed at read time from `pub/` (`-d`, `-f`). New cloud record `state` (schema stays 1; every addition is a new key): `shares[].storage`, `shares[].type`, `shares[].by`; top-level `r2` (`true` when the profile reads a bucket) and, on an origin with R2 on, `storage_default`. When bucket rows cannot be read, `state` still prints the local rows and adds `cloud_error` (the reason line), so the menu never loses machine links to an R2 problem. -Token rule for listings: `state` and `profiles --json` read the bucket only with a token from the Keychain item or the 600 file. A profile whose token source is `api_token_cmd` (it may prompt) or the environment gets `cloud_error: "the menu reads cloud links only with a stored token: api-token"`. `ls` uses the full resolution, as SPEC-007. `profiles --json` already lists r2 rows (only the text `profiles` sets `SHARE_STATE_BRIEF`). To bound the poll's cost, `state` reads the `m/` listing (its `LastModified` per key) and fetches only the newest 25 records, the most any section shows; `ls` still reads every record. At a 60 s poll that is about 1.1 million Class B reads and 43,000 listings a month per polling Mac, roughly $0.60 at list prices, whatever the record count. +Token rule for listings: `state` and `profiles --json` read the bucket only with a token from the Keychain item or the 600 file. A profile whose token source is `api_token_cmd` (it may prompt) or the environment gets `cloud_error: "the menu reads cloud links only with a stored token: api-token"`. `ls` uses the full resolution, as SPEC-007. `profiles --json` already lists r2 rows (only the text `profiles` sets `SHARE_STATE_BRIEF`). To bound the poll's cost, `state` reads the `m/` listing (its `LastModified` per key), drops the keys whose id is in the local index (the origin's own pointers), and fetches only the newest 25 of the rest, the most any section shows; it reports the remaining listing count as `cloud_more`, which the section adds to its `more` line. `ls` still reads every record. At a 60 s poll that is about 1.1 million Class B reads and 43,000 listings a month per polling Mac, roughly $0.60 at list prices, whatever the record count. ### Share Bar @@ -256,7 +264,7 @@ The publish dialog gains a `Storage` popup only for a profile whose state has `r `share [--profile

] migrate --to [--remote-profile ] [--remote-bin ] [--yes]`, run on the current origin while it is online. It needs `CLOUDFLARE_API_TOKEN` (Tunnel Edit, DNS Edit, Zone Read on the tenant's zone) and an ssh login on the target as the user who will run share there. It moves a tenant with R2 off only: on a profile with `bucket=` it refuses (`R2 is on for ; moving its origin also moves the pointers and the publisher token, which this version does not do`). -Every remote call is one fixed command, never a string the remote login shell (fish on Han's machines) parses: `ssh -o BatchMode=yes -- /bin/bash -c '' share-migrate `, where every argument is a checked slug or base64, the script sets `PATH` from the `--remote-bin` directory plus `/opt/homebrew/bin:/usr/local/bin:/usr/bin:/bin`, and a target starting with `-` is refused. The switch token travels on ssh stdin to a new `setup --token-stdin` flag, which reads one line into the process's own `CLOUDFLARE_API_TOKEN`. +Every remote call is one fixed string that any login shell (fish on Han's machines) parses the same way, because ssh joins its argv and the remote shell parses it: `/bin/bash -c "$(printf %s | base64 -D 2>/dev/null || printf %s | base64 -d)" share-migrate ` built with no quote or backslash outside the base64, where every argument is a checked slug or base64 (`--remote-bin` and `--remote-profile` included); the script sets `PATH` from the `--remote-bin` directory plus `/opt/homebrew/bin:/usr/local/bin:/usr/bin:/bin`, and a target starting with `-` is refused. The switch token travels on ssh stdin to a new `setup --token-stdin` flag, which reads one line into the process's own `CLOUDFLARE_API_TOKEN`. ``` Air (old origin) Mini (new origin), over ssh @@ -287,7 +295,7 @@ Every remote call is one fixed command, never a string the remote login shell (f - Live rows and their ports are listed as `not moved: live link (port ); after the move run ' add ' on the new origin`. `migrate` refuses while a `--host` row exists (` has its own hostname on this tunnel; remove it first: rm `). - Stop points: a failure in steps 1 and 2 changes nothing on this machine and leaves imported copies on the target, unserved unless its profile serves; a rerun skips ids the target already holds with the same name and added date. A failure in step 3 leaves this machine serving; the DNS record may already point at the new tunnel, and `migrate` then prints the rollback, ` setup --tunnel-name --force` on this machine. The tunnel name is explicit because `setup` derives `share-` when none is given, which is the name the target just created: without it the old origin would join the new tunnel as a second connector. With it, setup reuses `air-share` and repoints the CNAME there; the target's new tunnel is left with no DNS record and serves nothing. A failure in step 4 stops before step 5 and names each link that did not answer. - `--yes` skips the one confirmation (`Move links of to and retire this machine as its origin? [y/N]`). -- Test seam: `SHARE_MIGRATE_SSH` replaces `ssh -o BatchMode=yes ` with a local command (for example `env HOME=

`), so the suite and the rehearsal run both sides on one machine. +- Test seam: `SHARE_MIGRATE_SSH` replaces `ssh -o BatchMode=yes ` with a local command (for example `env HOME=`), so the suite and the rehearsal run both sides on one machine. The seam joins the remote argv into one string and runs it through `fish -c` when fish is installed, else `sh -c`, so the suite parses the command as a real login shell does. What visitors see: before the run, the Air serves (530 whenever it sleeps, as today). During step 3, from the CNAME change until the Mini's connector is up (setup writes DNS before it stores the token, installs the service, and starts; its live check allows 60 s), up to about a minute of 530; R2 measures the real gap. After it, the Mini serves every moved link at the same URL. Live links from the Air end at step 5. @@ -299,7 +307,7 @@ SPEC-007's model holds: an admin token from the environment for setup work, neve |---|---|---| | admin, `setup --r2`, `--no-r2` | SPEC-007's minimal admin set (Workers Scripts Edit on the account, DNS Read and Zone Read on the zone, Workers R2 Storage Edit), plus Workers Routes Edit on the zone (the route), plus Access Apps and Policies Edit and Access Organizations Read (the alias fold's destination edits and the `TEAM` binding) | `op://Toolkit/cf-api-token/credential` when TASK-1b(h) shows it holds every scope on the tenant's account; else a run-scoped token minted through `op://Toolkit/cf-tokens-admin` (`POST /user/tokens`, `expires_on` one day) | | `migrate` switch | Cloudflare Tunnel Edit, DNS Edit, Zone Read on the tenant's zone | `op://Toolkit/cf-api-token/credential` when TASK-1b(i) shows it edits tunnels and DNS on the personal account; else a run-scoped token as above | -| origin publisher (Mini, `dfoundation`) | Workers R2 Storage Bucket Item Write on `share-dfoundation` only, plus SPEC-004's Access scopes (gated adds), plus Zone Read | minted once through `cf-tokens-admin`, `expires_on` set, stored with `share --profile dfoundation api-token`; `r2_publisher_check` still refuses an admin or account-wide R2 token | +| origin publisher (Mini, `dfoundation`) | Workers R2 Storage Bucket Item Write on `share-dfoundation` only, plus SPEC-004's Access scopes (gated adds), plus Zone Read, plus Account Analytics Read (`hits` of a cloud row) | minted once through `cf-tokens-admin`, `expires_on` set, stored with `share --profile dfoundation api-token`. `api-token` and every bucket call key the r2 branch on `bucket=` (today they key on `backend == r2`), so `r2_publisher_check` refuses an admin or account-wide R2 token on the origin too, when stored and when first used from the environment | | member publisher | as SPEC-007 | as SPEC-007 (DEC-007 there: one per teammate) | No token reaches argv, a file, a Worker binding, or an object. `migrate` sends the switch token over ssh stdin, read on the target by `IFS= read -r` into the environment of the one `setup` process. @@ -374,19 +382,20 @@ A tunnel profile with no `bucket=` (every R2-off tenant) keeps every file, outpu - [ ] TASK-1a: edge spike on a throwaway hostname, one Worker, one tunnel, one Access app; answers in `docs/implementation-notes/one-host-per-tenant.md`. (a) A route Worker's `fetch(request)` and a `fetch` of `https:///healthz` reach the tunnel origin without running the Worker again. (b) Access answers an unauthenticated request to a gated path with its 302 before the route Worker runs; an admitted request reaches the Worker with `Cf-Access-Jwt-Assertion`. (c) The pass-through of an admitted request reaches the origin. (d) A POST and a WebSocket upgrade pass through to a live dev server. (e) The status codes a pass-through sees with the connector down, and a header that tells Caddy's own 502 apart. AC: each answered; (a) or (b) failing blocks TASK-3 and amends `### Approaches considered`. - [ ] TASK-1b: account spike with throwaway tokens and names. (f) Rebinding an existing custom domain to another Worker: in place, or DELETE then PUT, and the gap. (g) `PUT access/apps/` with added destinations keeps the AUD. (h) Whether `op://Toolkit/cf-api-token` holds Workers Routes Edit and Access Apps Edit on the Dwarves account. (i) Whether it edits tunnels and DNS on the personal account. (k) The Workers plan on each account and the route's failure mode at the request limit. AC: each answered, the token table amended to the source each role uses. -- [ ] TASK-2: per-link storage on the origin: config keys, `--cloud`, `--local`, the refusals, storage dispatch for `rm`, `refresh`, `hits` (member refusals included), `rand_id` across both legs, pointer write, delete, reconcile, and the sweep rule for pointers. Depends on TASK-1a. AC: rows 2 to 6, 26, 27. +- [ ] TASK-2a: per-link storage on the origin: config keys, `--cloud`, `--local`, their refusals, `rand_id` across both legs, the pointer write and its delete on `rm` and `prune`. Depends on TASK-1a. AC: rows 2, 4, 5. +- [ ] TASK-2b: storage dispatch for `rm`, `refresh`, `hits` with the member refusals, the reconcile, the sweep rule for pointers, and the origin's cloud expiry through its own reader. Depends on TASK-2a. AC: rows 3, 6, 26, 27. - [ ] TASK-3: Worker v3 (`ALIASES`, `PASS`, two-leg `/healthz`, the record branches, the rewrap, the offline page, the method rule, `WORKER_RECORD_V` 2), `r2_healthz`'s header reading, and `tests/worker.mjs` with a stub whose responses hold immutable headers. Depends on TASK-1a. AC: rows 7 to 10, 29. -- [ ] TASK-4: `setup --r2` and `--no-r2` without `--alias` (steps 1 to 3, 5, 6, 8, 9, 11, 13, 15) and the rerun convergence. Depends on TASK-2, TASK-3, TASK-1b(h). AC: rows 11, 12, 13. +- [ ] TASK-4: `setup --r2` and `--no-r2` without `--alias` (steps 1 to 3, 5, 6, 8, 9, 11, 13, 15) and the rerun convergence. Depends on TASK-2a, TASK-3, TASK-1b(h). AC: rows 11, 12, 13, 32. - [ ] TASK-5: `--alias` (steps 4, 7, 10, 12, 14, 16), the `--no-r2` alias refusal, the printed rollback list. Depends on TASK-4 and TASK-1b(f)(g). AC: rows 14, 30. -- [ ] TASK-6: one shared list: merged display rows, the `v:2` reader's checks, `storage`, `type`, `by`, `r2`, `storage_default`, `cloud_error`, the listing token rule, the newest-25 fetch in `state`. Depends on TASK-2. AC: rows 1, 15, 16, 17, 28. +- [ ] TASK-6: one shared list: the separate bucket reader, merged display rows, the `v:2` reader's checks, `storage`, `type`, `by`, `r2`, `storage_default`, `cloud_error`, `cloud_more`, the listing token rule, the newest-25 fetch in `state`. Depends on TASK-2b. AC: rows 1, 15, 16, 17, 28. - [ ] TASK-7a: `import` and `setup --token-stdin`. Depends on no other task; its first step measures the tar flags on bsdtar and GNU tar (absolute, `..`, symlink, and hardlink members) before the pre-scan is written. AC: row 18. - [ ] TASK-7b: `migrate` (preflight, copy, switch, verify, retire, the printed rollback). Depends on TASK-7a and TASK-1b(i). AC: rows 19, 20, 21, 31. - [ ] TASK-8: Share Bar: decode, `RowGlyphs`, row rendering, the `cloud_error` line, the `Storage` popup. Depends on TASK-6. AC: rows 22 to 25; `swift build` passes. -- [ ] TASK-9a: `tests/e2e-tenant.sh` (L1 to L10) and rehearsal R1, with its run log. Depends on TASK-2 to TASK-6. AC: L1 to L10, R1, every cleanup assertion. +- [ ] TASK-9a: `tests/e2e-tenant.sh` (L1 to L10) and rehearsal R1, with its run log. Depends on TASK-2a to TASK-6. AC: L1 to L10, R1, every cleanup assertion. - [ ] TASK-9b: `tests/e2e-migrate.sh` and rehearsal R2 (the one-machine leg, then the Air to the Mini on a throwaway name), with its run log. Depends on TASK-7b. AC: R2 and its cleanup assertions. - [ ] TASK-10: README, how-it-works, setup (onboarding), ADR-0008, the verification record with the negative controls. Depends on TASK-9a, TASK-9b. AC: each negative control has a red and a green run; each `docs/how-it-works.md` command, flag, and state key named in this spec is found by a `tests/share.sh` grep against `bin/share`. - [ ] TASK-11a: STOP for Han, then the Dwarves migration D1 to D5. Before D3 the lead posts the R1 log and the exact D3 command and waits for Han's explicit go; no loop may run D3 on its own. Each check is recorded in the verification record. Depends on TASK-10 and a release. AC: the Dwarves items of `## After state`. D6 runs seven days later, after a second go. -- [ ] TASK-11b: STOP for Han, then the personal move P1 to P3. Before P2 the lead posts the R2 log and the exact P2 command and waits for Han's explicit go (P2 deletes `air-share` at its step 5). Depends on TASK-10 and a release; independent of TASK-11a. AC: the personal items of `## After state`. +- [ ] TASK-11b: STOP for Han, then the personal move P1 to P3. Before P2 the lead posts the R2 log and the exact P2 command and waits for Han's explicit go; no loop may run P2 on its own (P2 deletes `air-share` at its step 5). Depends on TASK-10 and a release; independent of TASK-11a. AC: the personal items of `## After state`. ## Test plan @@ -403,13 +412,13 @@ Local rows run in `tests/share.sh`: the r2 rows under `SHARE_R2_DRY=1` (SPEC-007 | 5 | pointer | the pointer PUT answers 500 three times | exit 1 naming R2; no `pub/`, no row, no app | | 6 | reconcile | origin `ls` with: a machine record with no row, `LastModified` 11 min ago (one with `by` of another machine too); one 1 min ago; one 11 min ago whose id is in `access-pending`; a local row with no pointer; a local row whose id holds a cloud record; `rm` and `prune` of local rows with and without a token | both old orphan pointers deleted, the young one and the pending one kept; a pointer written for the bare row; the shadow line printed, nothing deleted; rm and prune delete the pointer with a token and leave it without one | | 7 | Worker | `PASS` set: a cloud record; a machine record; no record; the bucket throws; a non-hex first segment; `/`; an expired cloud record; a cloud record whose prefix names another id | R2 bytes, no pass-through; pass-through x5 (machine, none, throw, non-hex, root); 404 with no pass-through x2 | -| 8 | Worker | `PASS` set, pass-through answers: 200 with a body; 404; 530; 502; a thrown fetch; a POST with a body; an `Upgrade: websocket` request | unchanged x2; 503 offline page with `Retry-After` x3; the POST forwarded with its method and body; the upgrade forwarded and its 101 returned unchanged | +| 8 | Worker | `PASS` set, pass-through answers: 200 with a body; 404; 530; 502; a thrown fetch; a POST with a body; an `Upgrade: websocket` request | unchanged x2 (no Analytics Engine data point for any pass-through); 503 offline page with `Retry-After` x3; the POST forwarded with its method and body; the upgrade forwarded and its 101 returned unchanged | | 9 | Worker | `ALIASES=f.test`: GET `https://f.test/ba6377/g/?a=1`, HEAD, POST, `/healthz`; Host `x.test`; `PASS` empty: a miss | 301 to `https:///ba6377/g/?a=1`, 301, 405, 301; 404; 404 with no `fetch` call | | 10 | Worker | `/healthz` with `PASS` set and the origin stub answering `200 ok`, then 530, then a timeout; with `PASS` empty | 200 `X-Share-Tunnel: 1`; 503 `X-Share-Tunnel: 0` x2; SPEC-007's answer with no stub call. Every row 7 to 10 answer carries `no-store` and `noindex`, and SPEC-007's rows 17 to 19 and 28 pass unchanged on the v3 source | | 11 | setup | dry `setup --r2` on the origin | log order: script GET < bucket reads < marker GET < routes GET < `m/` list < script PUT < subdomain POST < subdomain GET < pointer PUTs < config write < route POST < healthz; config gains the four keys; the tunnel config and Keychain items unchanged | | 12 | setup | refusals: on a non-origin; on quick mode; a publisher token (403 on the script read); a route on `/*` naming another script; a local id holding a cloud record; a marker naming a third host | each dies before any PUT, POST, or DELETE in the log | | 13 | rollback | `--no-r2` after row 11 | route DELETE logged; the four keys gone; the next `add` writes no pointer; `rows()` output equal to before row 11 | -| 14 | alias | dry `--alias f.test` with a gated cloud record; the alias domain naming another service; the gate probe failing | order: app GET < script PUT < pointer PUTs < app PUT (destinations added, AUD unchanged) < probes < config write < marker PUT `If-Match` < route POST < domain PUT < healthz < app PUT (alias destinations dropped); the other service: dies before any write; probe failure: the added destinations removed, no marker PUT, no route | +| 14 | alias | dry `--alias f.test` with a gated cloud record (then `rm` of it on the tenant); the alias domain naming another service; the gate probe failing | order: app GET < script PUT < pointer PUTs < app PUT (destinations added, AUD unchanged) < probes < config write < marker PUT `If-Match` < route POST < domain PUT < healthz < app PUT (alias destinations dropped); the other service: dies before any write; probe failure: the added destinations removed, no marker PUT, no route; the `rm` deletes the folded app | | 15 | list | origin with R2 on: two local rows (one live, one gated), their pointers, two cloud records (one by another machine, one gated), a `v:1` cloud record without `type` named `guide` | `ls` shows six rows with tags `machine`, `live`, `cloud`, `cloud`, their types, and `by=`; no id twice; `state` rows carry `storage`, `type`, `by`; `guide` is `folder` | | 16 | type | the type table, one fixture per row, plus a folder with `index.html`, a folder without, `Report.PDF`, `a.tar.gz`, no extension on a local file | each `type` as the table; the extension match ignores case | | 17 | list | `state` with the bucket listing failing; with the token source `api_token_cmd`; `profiles --json` with an r2 member and a tunnel profile | local rows plus `cloud_error` (exit 0); no command run (a sentinel command that touches a file is never run), `cloud_error` names `api-token`; the member entry lists its rows | @@ -423,10 +432,11 @@ Local rows run in `tests/share.sh`: the r2 rows under `SHARE_R2_DRY=1` (SPEC-007 | 25 | swift | `PublishForm` on an origin with `r2: true` and `storage_default: cloud`; on a member; on a tunnel profile with `r2: false` | the `Storage` popup preselects `In the cloud` and the argv carries `--cloud`; a disabled `In the cloud` label and no flag; no popup and no flag | | 26 | sweep | R2 on: bare `prune` with machine pointers present and an unreferenced upload 25 h old; then a record that is not JSON | the old upload deleted, no warning; with the broken record, nothing deleted and the warning names it | | 27 | dispatch | on the origin: `rm` of a cloud row, `rm` of a machine row, `refresh` of each, `hits` of each | the cloud ones take the r2 path (S3 calls, the SQL call); the machine ones take the tunnel path plus the pointer DELETE; `hits` of a machine row reads the access log | -| 28 | hostile | on an R2-on origin, forged records: `v:1` with `opts:"live host=x.test"` and `src:"x:22"`; `v:2` machine with `opts:"live"`, `src:"x:22"`, and `expires:"a[$(touch S)]"`; `v:2` with `name` holding a tab; then `ls`, `state`, `profiles --json`, `prune`, `refresh ` | the Caddyfile, `index.tsv`, and the running Caddy config are byte-identical before and after; the file `S` does not exist; each forged record is skipped or shown as display text only; `refresh` of the forged ids dies | +| 28 | hostile | on an R2-on origin, and again on a member (`ls`, `state`, `profiles --json` only), forged records, plus a key `m/../share.json` and an expired local row with one local `add` in the same process: `v:1` with `opts:"live host=x.test"` and `src:"x:22"`; `v:2` machine with `opts:"live"`, `src:"x:22"`, and `expires:"a[$(touch S)]"`; `v:2` with `name` holding a tab; then `ls`, `state`, `profiles --json`, `prune`, `refresh ` | the Caddyfile and the running Caddy config hold exactly the local rows (the expired one gone, the new add present, no bucket row); `index.tsv` holds no bucket row; the file `S` does not exist; the odd key is skipped and `share.json` survives; each forged record is skipped or shown as display text only; `refresh` of the forged ids dies | | 29 | healthz | dry Worker answer `503` with this CLI's pair and `X-Share-Tunnel: 0`: a member joins, adds a gated cloud link, and runs `state` | the join exits 0; the gated add publishes; `state` has `ready: true`; a `503` with no `X-Share-Worker` header still fails all three | | 30 | alias | dry `--alias f.test`, die injected at each of steps 12, 13, 14, 15; then `--no-r2`; then the printed rollback list; then a rerun of the fold | each die prints the alias rollback list, not `--no-r2` alone; `--no-r2` refuses while `aliases=` is set; after the list, the integration Worker for the alias serves the cloud record itself; the rerun converges to row 14's end state | | 31 | rollback | after row 20's remote-setup failure, run the printed line on A in dry mode | the dry DNS PUT names A's own tunnel; A's config keeps its `tunnel_id`; no call touches B's tunnel | +| 32 | refuse | SPEC-007 `setup --backend r2 --bucket ` with the admin fixture against a tenant Worker (`PASS="1"`), with `--force`; against a marker holding `aliases`; against a route on `/*` | each dies before any PUT, POST, or DELETE, naming `setup --r2` on the origin | ### Negative controls @@ -448,6 +458,7 @@ Each is a temporary one-line patch that must turn the named rows red, recorded w | read the bucket in `state` through `api_token_cmd` | row 17 | | write `bucket=` into a plain tunnel config at setup | row 1 | | merge bucket rows through `rows()` | row 28 (the Caddyfile changes) | +| let SPEC-007's r2 setup take a tenant host with `--force` | row 32 | | let the sweep count a machine pointer as unreadable | row 26 | | let a member `rm` a machine row | row 3 | | read the healthz status alone in `r2_healthz` | row 29 | @@ -566,3 +577,10 @@ Moving a tunnel tenant's local shares into its bucket (a `--cloud` re-add covers | The orphan sweep reads a `v:2` machine pointer as readable and referencing nothing; row 26 | every pointer counted as unreadable, so the sweep would never run again on any machine (5) | | Also fixed because the text was false or unsafe: `profiles --json` already lists r2 rows; the poll cost (newest 25 records, about $0.60 a month); reconcile keyed on `storage` and aged by `LastModified`; `migrate` refuses an R2-on tenant, runs a fixed `/bin/bash -c`, takes the token through `setup --token-stdin`, pre-scans the tar, validates `added`, `expires`, `src`, preflights the target, and verifies through a target-only nonce and the gated AUD; the real 530 bound; D2 after D3, D5 with D3, D4's rollback only after D3's; the Worker record branches, its 2 s read cap, and the response rewrap | reviewer warnings with a concrete wrong statement or unsafe default behind each (1, 2, 3, 5) | | Not taken: reusing `air-share` (DEC-010); failing open on a pointer write (DEC-006); a redirect rule in place of `--alias` (the 301 stays in tested code; the alias surface is permanent but small, and it retires with `--no-r2` after the alias rollback) | (5) | +- Round 2 (five fresh-context reviewers re-ran the lenses that raised round-1 criticals, 2026-10-01): every round-1 critical cleared; two new criticals, both folded. + +| Change | Why (reviewer) | +|---|---| +| SPEC-007's r2 setup refuses a tenant host in its admin role, even with `--force`; row 32 | an admin token on a member would redeploy the Worker without `PASS` and swap the tunnel CNAME for a custom domain, and the new healthz reading would call that ready (3) | +| TASK-2 split into TASK-2a (storage and pointers) and TASK-2b (dispatch, reconcile, sweep, origin cloud expiry) | seven AC rows in one task (4) | +| Also folded: a bucket reader on the origin that never rebinds `index`; the 6-hex key check; row 28 with a same-process add and a member leg; `by` through `r2_by`; the origin prunes cloud rows; `api-token` and bucket calls keyed on `bucket=`; Analytics Read for the origin; the newest 25 skip local ids and report `cloud_more`; folded apps deletable by alias name; a base64 ssh script and a seam that parses like a login shell; no hit counted on a pass-through; the TASK-11b loop stop | warnings with a concrete wrong outcome behind each (1, 2, 3, 4, 5) | From 1205017a630cdd5a7ddb42929a759637adbcc275 Mon Sep 17 00:00:00 2001 From: Han Ngo Date: Thu, 1 Oct 2026 17:22:01 +0700 Subject: [PATCH 04/55] docs: fold round 3 spec review and mark the tenant spec validated Cloud verbs on a profile with a tunnel use their own bucket reader and never touch the index. Round 3 warnings are recorded as build rules. --- docs/specs/SPEC-008-one-host-per-tenant.md | 18 +++++++++++++++--- 1 file changed, 15 insertions(+), 3 deletions(-) diff --git a/docs/specs/SPEC-008-one-host-per-tenant.md b/docs/specs/SPEC-008-one-host-per-tenant.md index f6264a1..5c1326c 100644 --- a/docs/specs/SPEC-008-one-host-per-tenant.md +++ b/docs/specs/SPEC-008-one-host-per-tenant.md @@ -1,6 +1,6 @@ # SPEC-008: one hostname per tenant, cloud storage optional per link -Status: DRAFT +Status: VALIDATED (three rounds; the round-3 critical folded without a fourth review per the cap; remaining warnings in the Decision Log, round 3) Lane: full (an API and data contract between the CLI, the Worker, and Share Bar; a new public route in front of live links; a migration of two production hostnames) Depth: research (outside: a route Worker passing requests through to a tunnel on its own hostname, Access order against a route Worker, custom-domain rebind, Access destination edits; TASK-1a and TASK-1b sample each before the task that consumes it) | blind-spot (failure: the Worker passes a request to the tunnel that a cloud record or a gate should have stopped, or the migration leaves a link dead) References: SPEC-004 (Access gate), SPEC-005 (profiles), SPEC-006 (Share Bar across profiles), SPEC-007 (R2 backend), ADR-0002, ADR-0003, ADR-0005, ADR-0006, ADR-0007; `bin/share` `cmd_add`, `cmd_add_r2`, `rand_id`, `rows`, `share_url`, `cmd_ls`, `cmd_state`, `cmd_state_r2`, `cmd_profiles_json`, `cmd_setup`, `cmd_setup_r2`, `cmd_teardown`, `worker_js`, `r2_call`, `r2_snapshot`, `r2_healthz`; `mac/Sources/ShareBarCore/Snapshot.swift`, `MenuModel.swift`; `mac/Sources/ShareBar/StatusItemController.swift`. @@ -149,7 +149,7 @@ With R2 on, a local add on the origin also writes the id's pointer record: 3. `rm` and `prune` of a local row delete its pointer after the local removal. Without a resolvable token (the login service's hourly prune runs with `SHARE_API_TOKEN_OFF=1`) the pointer stays; it is harmless (the Worker passes the id to the tunnel, which answers 404), and the next interactive `ls` or `prune` on the origin, or any member's `prune` once `expires` is past, deletes it. 4. Reconcile on the origin's interactive `ls` and `prune`, keyed on `storage == "machine"` alone (one origin per tenant, so every machine record is the origin's, migrated rows included): a machine record with no local row, whose S3 `LastModified` is over 10 minutes old, and whose id is neither in `access-pending` nor under a held add lock, is deleted (an add that died after step 2); a local row with no pointer gets one (`If-None-Match: *`; a 412 means a cloud record holds the id: printed as ` is shadowed by a cloud link; rm one of them`, never auto-deleted). -Bucket rows are display data. A record read from the bucket, cloud or machine, never enters `rows()`, `index.tsv`, the Caddyfile, `refresh`, or a stage path, so a forged record cannot add a reverse proxy, a site block, or a source path on the origin. On a profile with a tunnel, the bucket reader writes its own snapshot file and never assigns `index` (SPEC-007's `r2_snapshot` rebinds `index` for the process, which on the origin would hand bucket rows to `write_caddyfile` in the same run). Every bucket key is checked against `^m/[0-9a-f]{6}$` before its record is read, displayed, built into a URL, or deleted. +Bucket rows are display data. A record read from the bucket, cloud or machine, never enters `rows()`, `index.tsv`, the Caddyfile, `refresh`, or a stage path, so a forged record cannot add a reverse proxy, a site block, or a source path on the origin. On a profile with a tunnel, the bucket reader writes its own snapshot file and never assigns `index` (SPEC-007's `r2_snapshot` rebinds `index` for the process, which on the origin would hand bucket rows to `write_caddyfile` in the same run). Every bucket key is checked against `^m/[0-9a-f]{6}$` (after URL decoding) before its record is read, displayed, built into a URL, or deleted. The same holds for every cloud verb on a profile with a tunnel: `cmd_rm_r2`, `r2_prune`, the cloud expiry, `hits`, and the purge read the reader's own file and never call `r2_snapshot`, never assign `index`, and never write `$index` (today `cmd_rm_r2` falls back to `r2_snapshot` and moves a file over `$index`), so one `prune` that expires a cloud row and then a local row renders only local rows. The origin's interactive bare `prune` and `ls` run SPEC-007's cloud expiry, its gated-expiry rule, and the orphan sweep through that reader, because after the fold the origin may be the only publisher left; the login service's hourly prune (token off) touches local rows only, as today. @@ -432,7 +432,7 @@ Local rows run in `tests/share.sh`: the r2 rows under `SHARE_R2_DRY=1` (SPEC-007 | 25 | swift | `PublishForm` on an origin with `r2: true` and `storage_default: cloud`; on a member; on a tunnel profile with `r2: false` | the `Storage` popup preselects `In the cloud` and the argv carries `--cloud`; a disabled `In the cloud` label and no flag; no popup and no flag | | 26 | sweep | R2 on: bare `prune` with machine pointers present and an unreferenced upload 25 h old; then a record that is not JSON | the old upload deleted, no warning; with the broken record, nothing deleted and the warning names it | | 27 | dispatch | on the origin: `rm` of a cloud row, `rm` of a machine row, `refresh` of each, `hits` of each | the cloud ones take the r2 path (S3 calls, the SQL call); the machine ones take the tunnel path plus the pointer DELETE; `hits` of a machine row reads the access log | -| 28 | hostile | on an R2-on origin, and again on a member (`ls`, `state`, `profiles --json` only), forged records, plus a key `m/../share.json` and an expired local row with one local `add` in the same process: `v:1` with `opts:"live host=x.test"` and `src:"x:22"`; `v:2` machine with `opts:"live"`, `src:"x:22"`, and `expires:"a[$(touch S)]"`; `v:2` with `name` holding a tab; then `ls`, `state`, `profiles --json`, `prune`, `refresh ` | the Caddyfile and the running Caddy config hold exactly the local rows (the expired one gone, the new add present, no bucket row); `index.tsv` holds no bucket row; the file `S` does not exist; the odd key is skipped and `share.json` survives; each forged record is skipped or shown as display text only; `refresh` of the forged ids dies | +| 28 | hostile | on an R2-on origin, and again on a member (`ls`, `state`, `profiles --json` only), forged records, plus a key `m/../share.json`, an expired valid cloud record whose id sorts before an expired local row (one `prune` expires both, cloud first), `rm` and `hits` of a forged cloud id, and one local `add` in the same process: `v:1` with `opts:"live host=x.test"` and `src:"x:22"`; `v:2` machine with `opts:"live"`, `src:"x:22"`, and `expires:"a[$(touch S)]"`; `v:2` with `name` holding a tab; then `ls`, `state`, `profiles --json`, `prune`, `refresh ` | the Caddyfile and the running Caddy config hold exactly the local rows (the expired one gone, the new add present, no bucket row); `index.tsv` holds no bucket row; the file `S` does not exist; the odd key is skipped and `share.json` survives; each forged record is skipped or shown as display text only; `refresh` of the forged ids dies | | 29 | healthz | dry Worker answer `503` with this CLI's pair and `X-Share-Tunnel: 0`: a member joins, adds a gated cloud link, and runs `state` | the join exits 0; the gated add publishes; `state` has `ready: true`; a `503` with no `X-Share-Worker` header still fails all three | | 30 | alias | dry `--alias f.test`, die injected at each of steps 12, 13, 14, 15; then `--no-r2`; then the printed rollback list; then a rerun of the fold | each die prints the alias rollback list, not `--no-r2` alone; `--no-r2` refuses while `aliases=` is set; after the list, the integration Worker for the alias serves the cloud record itself; the rerun converges to row 14's end state | | 31 | rollback | after row 20's remote-setup failure, run the printed line on A in dry mode | the dry DNS PUT names A's own tunnel; A's config keeps its `tunnel_id`; no call touches B's tunnel | @@ -584,3 +584,15 @@ Moving a tunnel tenant's local shares into its bucket (a `--cloud` re-add covers | SPEC-007's r2 setup refuses a tenant host in its admin role, even with `--force`; row 32 | an admin token on a member would redeploy the Worker without `PASS` and swap the tunnel CNAME for a custom domain, and the new healthz reading would call that ready (3) | | TASK-2 split into TASK-2a (storage and pointers) and TASK-2b (dispatch, reconcile, sweep, origin cloud expiry) | seven AC rows in one task (4) | | Also folded: a bucket reader on the origin that never rebinds `index`; the 6-hex key check; row 28 with a same-process add and a member leg; `by` through `r2_by`; the origin prunes cloud rows; `api-token` and bucket calls keyed on `bucket=`; Analytics Read for the origin; the newest 25 skip local ids and report `cloud_more`; folded apps deletable by alias name; a base64 ssh script and a seam that parses like a login shell; no hit counted on a pass-through; the TASK-11b loop stop | warnings with a concrete wrong outcome behind each (1, 2, 3, 4, 5) | +- Round 3 (three fresh-context reviewers on the round-2 diff, the cap, 2026-10-01): every round-2 critical cleared. One new critical, folded without a fourth review because of the cap: the SPEC-007 cloud verbs on a tunnel profile (`cmd_rm_r2`, `r2_prune`) still rebound or overwrote `$index`, so a `prune` that expired a cloud row before a local row could render a forged `v:1` record into the Caddyfile. The reader rule now covers every cloud verb on a tunnel profile, and row 28 gains the cloud-then-local expiry, `rm`, and `hits` legs. Remaining warnings, binding on the build: + +| Warning (reviewer) | Build rule | Task | +|---|---|---| +| A v0.8.0 CLI with the admin token passes the `v:1` tenant marker and its version die suggests `--force` (3) | D1 checks that every machine holding the Dwarves admin token runs the release; the tenant marker gains a field v0.8.0's setup refuses only if TASK-4 finds one that does not break v0.8.0 joins | TASK-4, TASK-11a | +| `access_sweep` decides stale lines by `$backend`, so on the origin a crashed gated `--cloud` add's line is checked against the local index and the sweep deletes the app of a published gated cloud link (3) | the sweep decides per id: a line whose id holds a cloud record (fresh `GET m/`) takes SPEC-007's r2 rule | TASK-2b | +| Members have no `aliases=`, so a member's `rm` or expiry of a folded gated share cannot delete its app (1, 3) | a member stores `aliases=` at join, read once from the marker and confirmed against the Worker's `ALIASES` binding through the alias's 301; never read live from the marker (any publisher can write it); row 14 gains a member leg | TASK-5 | +| The SPEC-007 refusal's route read may answer 403 for SPEC-007's minimal admin scopes (1) | a non-200 on the Worker settings read or the marker read dies; a 403 on the routes read alone is tolerated because the `PASS` binding check already catches a tenant; row 32 gains that leg | TASK-4 | +| The ssh template: GNU `base64` wraps lines, `"$(...)"` needs fish 3.4 or later, the "no quote" wording contradicts the template, and a runner without fish exercises sh only (1, 3) | encode with `| tr -d '\n'`; the preflight names the minimum fish; the fixed template is the only quoting and arguments add none; CI installs fish for the seam | TASK-7b | +| `api-token` keyed on `bucket=` takes the r2 form and reports Access scopes as optional (3) | on a profile with a tunnel, the Access scopes stay required as SPEC-004 has them, and the form names both the bucket and Access permissions | TASK-2a | +| `cloud_more` and the newest 25 count orphan pointers and unreadable records (3) | display only; the count says "about" in the menu line | TASK-6, TASK-8 | +| TASK-1a and TASK-1b run long as lettered probes; TASK-4, TASK-8, TASK-10 sit at the five-file or five-row edge (4) | accepted: each letter is one probe; split TASK-8's tests if they exceed one file per source | all | From c74d12795d0fac24f2dbd54438543a7640252263 Mon Sep 17 00:00:00 2001 From: Han Ngo Date: Thu, 1 Oct 2026 17:34:25 +0700 Subject: [PATCH 05/55] docs: record the edge spike for the tenant route Worker A route Worker passes misses and its healthz fetch to the tunnel origin without re-entry. Access gates before it. POST and WebSocket pass through. The header cf-cache-status tells an origin answer apart from an edge error. --- .../one-host-per-tenant.md | 37 +++++++++++++++++++ 1 file changed, 37 insertions(+) diff --git a/docs/implementation-notes/one-host-per-tenant.md b/docs/implementation-notes/one-host-per-tenant.md index 85f0ab1..982fb0d 100644 --- a/docs/implementation-notes/one-host-per-tenant.md +++ b/docs/implementation-notes/one-host-per-tenant.md @@ -42,3 +42,40 @@ Round 2 warnings, same rule: | bsdtar lists a hardlink as a regular mode with `link to`; archive flags can pin files | the pre-scan rejects any `link to`; extract with no file flags or xattrs; row 18 gains a sibling hardlink and an absolute one | TASK-7a | | Pointers expose every machine link's URL to every member | `docs/setup.md` says so in the onboarding | TASK-10 | | The D3 rollback rebinds before restoring app destinations | restore destinations first, then rebind | TASK-5 | + +## TASK-1a: edge spike (2026-10-01) + +Throwaway objects on the Dwarves account, all created with the Toolkit admin token: hostname `share-e2e-spk584462.d.foundation` (proxied CNAME to tunnel `share-e2e-spk584462`), a route Worker of the same name on `/*`, a service token, and one Access app on `/gated{,/*}` with a `non_identity` service-token policy plus an email include (so an unauthenticated browser gets 302, not 403). The origin ran on the Mini: `cloudflared tunnel run` (token from the environment) to Caddy on 127.0.0.1:28990, which proxied to a node echo server that logged every request it received and spoke a WebSocket echo. The spike Worker answered 418 `REENTERED` for any request carrying `x-spike-pass`, answered `/__w` itself, fetched `https:///healthz` on `/__hz`, and otherwise ran `fetch(request)`; `?mark=1` cloned the request with `x-spike-pass: 1` first, and `x-spike-raw: 1` returned the subrequest's status and headers as JSON. Every probe used `curl --doh-url https://1.1.1.1/dns-query`. + +| Q | Command (shape) | Observed | +|---|---|---| +| (a) | `GET https:///abc123/f?x=1` | 200 with `x-spike-worker: 1` and `x-origin: 1`; the origin logged the request with `cf-worker: d.foundation` | +| (a) | `GET https:///abc123/f?mark=1` | 200 from the origin, which logged `x-spike-pass: 1`; no 418, so the subrequest skipped the route Worker | +| (a) | `GET https:///__hz` (Worker fetches `https:///healthz` with `x-spike-pass`, `AbortSignal.timeout(3000)`) | `{"status":200,"body":"ok","reentered":null}`; the origin logged `GET /healthz` | +| (b) | unauthenticated `GET /gated/x`, `/gated/__w`, `/gated` | 302 to `https://dwarves.cloudflareaccess.com/cdn-cgi/access/login/?kid=`, `kid` equal to the app's AUD; no `x-spike-worker` header on any; the origin log stayed empty, and `/gated/__w` (answered by the Worker itself) also got the 302, so Access runs before the route Worker | +| (b) | `GET /gated/__w` with `CF-Access-Client-Id`/`-Secret` | 200 from the Worker, `{"worker":true,"jwt":true,"cookie":true}`: the admitted request carries `Cf-Access-Jwt-Assertion` and a `CF_Authorization` cookie | +| (b) | the same with a wrong secret | 302 to the login flow, never admitted | +| (c) | `GET /gated/x` with the service token | 200 from the origin; the origin logged `jwt=1` and `cookie` present: `fetch(request)` forwards the JWT and the cookie | +| (d) | `POST /live/api` with 100000 random bytes; `PUT`; `DELETE` | 200 each; the origin logged `POST body=100000`, `PUT body=1`, `DELETE` | +| (d) | node 22 `new WebSocket("wss:///live/ws")`, send `ping1` | `["hello-from-origin","echo:ping1"]`; the origin logged `UPGRADE /live/ws cfworker=d.foundation`; the Worker returned the 101 response object as is | +| (d) | the same on `/gated/ws` without, then with, the service token | without: handshake refused (non-101); with: echo works, the origin logged `jwt=1` | +| (e) | Caddy up, `/dead/x` proxied to a closed port (Caddy answers `502`, `Server: Caddy`, empty body, checked locally) | the Worker's subrequest sees `502` with Cloudflare's own HTML page (`d.foundation \| 502: Bad gateway`), `server: cloudflare`, `retry-after: 60`, no `cf-cache-status` | +| (e) | Caddy `respond "caddy 502 with body" 502` | the same Cloudflare 502 page; Cloudflare replaces every origin 502, body or not | +| (e) | Caddy stopped, cloudflared up | `502`, the same Cloudflare page and headers as above; `/__hz` got `502 error code: 502` | +| (e) | cloudflared stopped | `530` (`error code: 1033`, the Cloudflare Tunnel error page) to both the pass-through and `/__hz`; `retry-after` present, no `cf-cache-status`; the client saw the 530 through the Worker | +| (e) | Caddy `respond "caddy 503" 503`; Caddy `respond 404`; the node origin's own `503` | `503` with body `caddy 503` and `cf-cache-status: DYNAMIC`; `404` with `cf-cache-status: DYNAMIC`; `503` with `cf-cache-status: DYNAMIC` and `via: 1.1 Caddy` | + +Answers: + +- (a) holds: a route Worker's `fetch(request)` and its `fetch("https:///healthz")` both reach the tunnel origin and never re-enter the Worker. Approach (A) stands. +- (b) holds: Access answers an unauthenticated gated path with its 302 before the route Worker runs; an admitted request reaches the Worker with `Cf-Access-Jwt-Assertion`. +- (c) holds: the pass-through keeps the JWT header and the `CF_Authorization` cookie. +- (d) holds: POST, PUT, DELETE with bodies and a WebSocket upgrade pass through; `return r` on a 101 is enough. +- (e) the pass-through sees 530 with the connector down and 502 with the connector up but nothing answering. An origin's own 502 never reaches the Worker: Cloudflare swaps it for its own page, so a Caddy 502 and an edge 502 look the same and both get the offline page (as the spec wants). The header that tells an origin answer apart is `cf-cache-status`: present on every response the origin produced (503, 404, 200), absent on every edge-generated error (502, 530). `via: 1.1 Caddy` appears only on responses Caddy proxied, so it is not a general marker. Build rule for TASK-3: offline page when the status is 530, 520 to 527, or 502; or 503 without `cf-cache-status`. Not measured: 520 to 527 and an edge-generated 503 (no way to force them with a tunnel origin); the rule keys them on the same header. + +Side findings for later tasks: + +- A route object carries `request_limit_fail_open` (default `false`); `PUT zones//workers/routes/` with `true` was accepted and read back. TASK-1b(k) uses this. +- After the DNS record was deleted, the authoritative server answered NXDOMAIN at once, while 1.1.1.1 still served its cached proxied A records (the edge answered 530) until the TTL ran out. + +Cleanup, checked through the API afterwards: route 0, Worker settings 404, DNS records for the name 0, Access apps and service tokens named `share-e2e-spk*` 0, tunnels named `share-e2e-spk*` (not deleted) 0, no spike process left on the Mini; `dig @david.ns.cloudflare.com ` NXDOMAIN. From bcb5b5c6576ddb7aaea61588da1f2e02eee5a673 Mon Sep 17 00:00:00 2001 From: Han Ngo Date: Thu, 1 Oct 2026 17:43:11 +0700 Subject: [PATCH 06/55] docs: record the account spike and amend the tenant spec A custom domain rebinds in place only with override_existing_origin. An Access app PUT keeps the AUD when it sends the full read-back. The Toolkit token holds every admin scope on both accounts. Both accounts run Workers Paid. The spec now names the rebind flag, the full-body PUT, the route fail-open flag, the 503 rule, and the token sources. --- .../one-host-per-tenant.md | 26 +++++++++++++++++++ docs/specs/SPEC-008-one-host-per-tenant.md | 16 ++++++------ 2 files changed, 34 insertions(+), 8 deletions(-) diff --git a/docs/implementation-notes/one-host-per-tenant.md b/docs/implementation-notes/one-host-per-tenant.md index 982fb0d..f408fb7 100644 --- a/docs/implementation-notes/one-host-per-tenant.md +++ b/docs/implementation-notes/one-host-per-tenant.md @@ -79,3 +79,29 @@ Side findings for later tasks: - After the DNS record was deleted, the authoritative server answered NXDOMAIN at once, while 1.1.1.1 still served its cached proxied A records (the edge answered 530) until the TTL ran out. Cleanup, checked through the API afterwards: route 0, Worker settings 404, DNS records for the name 0, Access apps and service tokens named `share-e2e-spk*` 0, tunnels named `share-e2e-spk*` (not deleted) 0, no spike process left on the Mini; `dig @david.ns.cloudflare.com ` NXDOMAIN. + +## TASK-1b: account spike (2026-10-01) + +Throwaway objects: a token `share-e2e-spk13b60d-admin` minted through `op://Toolkit/cf-tokens-admin` (`POST /user/tokens`, one-day `expires_on`) with the tenant admin set (account: Workers Scripts Edit, Workers R2 Storage Edit, Access Apps and Policies Edit, Access Organizations Read; zone `d.foundation`: Zone Read, DNS Read, Workers Routes Edit); Workers `share-e2e-spk13b60d-a` and `-b` (each answers `w1` or `w2`); custom domain `share-e2e-spk13b60d.d.foundation`; one Access app; on the personal account one tunnel and one CNAME `share-e2e-spk13b60d.han.ws`. The minted token ran the deploys, the first bind, the refused in-place PUT, the DELETE then PUT of the first pass, and every (g) call; the Toolkit token ran the `override_existing_origin` passes, the timed DELETE then PUT, (i), and the cleanup. Edge probes: node 22 `fetch` every 100 ms with keep-alive (about 160 ms per sample), plus `dig` against 1.1.1.1 and the zone's authoritative server every 200 ms during the first pass. + +| Q | Command (shape) | Observed | +|---|---|---| +| (f) | `PUT accounts//workers/domains {hostname, service:"-b", zone_id, environment:"production"}` while the hostname is bound to `-a` | refused: `success:false`, code `100116`, "Hostname ... already in use by other custom domain. Either delete it, try a different hostname, or use the option 'override_existing_origin' to override."; the binding stays on `-a` | +| (f) | the same PUT with `"override_existing_origin":true`, three times (`-a` to `-b`, back, again) | `success:true`, same domain id, `service` switched; 308 samples over 50 s, 0 non-200; the new Worker answered 1.1 s, 1.1 s, and 3.6 s after the PUT returned | +| (f) | `DELETE workers/domains/`, then the plain PUT to `-a` | DELETE 200, PUT 200 (same domain id came back); the old Worker kept answering about 1.1 s after the DELETE returned, then 4 samples of `522` over 0.7 s, then `w1`; total failed window under 1 s; 210 samples, 4 non-200 | +| (f) | `dig @1.1.1.1` and `dig @.ns.cloudflare.com` on the hostname during the first DELETE then PUT | `NOERROR` on every sample, never `NXDOMAIN`: the domain's DNS record outlives the API gap, so no negative cache forms | +| (g) | `POST access/apps` with destinations `/ab12cd{,/*}`, then `PUT access/apps/` with the full `GET` result plus two destinations on `x.` | `success:true`; `aud` and `id` unchanged; four destinations; `self_hosted_domains` follows the destinations; policies unchanged (only their `updated_at` moved) | +| (g) | `PUT access/apps/` with only `name`, `type`, `destinations` | `success:true`, `aud` unchanged, the policy kept, but `app_launcher_visible` went from `false` back to the default `true`: a PUT resets every field it omits | +| (h) | the Toolkit token's id from `GET /user/tokens/verify`, its policies through the minter (`GET /user/tokens/`) | token `all-wuandkin-admin`, no expiry, four accounts; on the Dwarves account: Workers Scripts Write, Workers R2 Storage Write, Workers Routes Write, DNS Read and Write, Zone Read, Access: Apps and Policies Write, Access: Organizations Read, Cloudflare Tunnel Write, Account Analytics Read (zone groups on `zone.*`). TASK-1a already used it for the route POST, PUT, DELETE and the Access app POST, DELETE | +| (i) | with the Toolkit token on the personal account: `POST cfd_tunnel`, `PUT .../configurations`, `GET .../token`, `POST zones//dns_records` CNAME, `PUT` of that record, both DELETEs | every call `success:true`; the same token policy covers the personal account with Cloudflare Tunnel Write and DNS Write | +| (k) | `GET accounts//subscriptions` on both accounts; `GET zones//workers/routes` | Dwarves LLC: `Workers Paid`, `R2 Paid`; Han Ngo: `Workers Paid`, `R2 Paid`, Analytics Engine; both zones `Free Website`. Every route object carries `request_limit_fail_open` (both existing routes `false`); TASK-1a set it to `true` with a route PUT and read it back | + +Answers: + +- (f) The plain PUT never rebinds. `PUT workers/domains` with `override_existing_origin: true` rebinds in place with no failed request at a 160 ms sample rate, so step 14 uses it and no gap exists to bound. DELETE then PUT costs under 1 s of 522 and never NXDOMAIN; it stays the fallback only if the override flag ever stops working. +- (g) A PUT keeps the AUD, the id, and the policies when it sends the full `GET` result. It resets any omitted field to its default, so step 10, step 16, and the alias rollback must PUT the full read-back with only `destinations` changed. +- (h) Yes. The Toolkit token holds Workers Routes Edit and Access Apps Edit on the Dwarves account, plus every other scope of the tenant admin set. The admin role uses it. It is broad (four accounts, no expiry); the minted one-day token with only the tenant admin set also ran every (f) and (g) call, so the run-scoped fallback in the token table works. +- (i) Yes. The Toolkit token edits tunnels and DNS on the personal account. The round-1 warning still prefers a run-scoped one-day token for the migrate switch (TASK-11b). +- (k) Both accounts are on Workers Paid, so no daily request limit applies and the route never hits the free plan's failure mode. TASK-4 sets `request_limit_fail_open: true` on the route anyway: it costs nothing on a paid plan and keeps machine links up if an account ever drops to free. + +Cleanup, checked through the API afterwards on both accounts: Workers, custom domains, Access apps, service tokens, tunnels, routes, and DNS records with names starting `share-e2e-spk` all 0; buckets starting `share-e2e` 0; user tokens starting `share-e2e-spk` 0 and the minted token's id answers 404; `dig` at each zone's authoritative server answers NXDOMAIN for both throwaway names. diff --git a/docs/specs/SPEC-008-one-host-per-tenant.md b/docs/specs/SPEC-008-one-host-per-tenant.md index 5c1326c..2729c58 100644 --- a/docs/specs/SPEC-008-one-host-per-tenant.md +++ b/docs/specs/SPEC-008-one-host-per-tenant.md @@ -109,11 +109,11 @@ It runs on the origin only (`host_ok`, else `run this on the origin, `), | 7 | with `--alias`: every gated cloud record's Access app (`GET access/apps/`) | an app whose name is not `share `, or no Apps Edit scope | | 8 | deploy the Worker (bindings below), subdomain and previews off with the read-back (SPEC-007 step 10) | read-back not `false,false` | | 9 | pointer records: `PUT m/` with `If-None-Match: *` for every local row | a 412 on an id step 6 saw free: die, the route is not attached yet | -| 10 | with `--alias`: each step-7 app gains `/` and `//*` (PUT keeps every field and the AUD; TASK-1b(g)); the gate probe on the new destinations passes three rounds (SPEC-004) | gate timeout: the added destinations are removed, die | +| 10 | with `--alias`: each step-7 app gains `/` and `//*` (the PUT sends the full read-back with only `destinations` changed, which keeps every field and the AUD; an omitted field resets to its default; TASK-1b(g)); the gate probe on the new destinations passes three rounds (SPEC-004) | gate timeout: the added destinations are removed, die | | 11 | write `bucket=`, `r2_endpoint=`, `storage_default=` (default `local`), and `aliases=` into the config, so every later die leaves a state `--no-r2` and a rerun can read | | | 12 | marker `PUT share.json` with `If-Match`: `{"v":1,"host":"","aliases":[""]}` | 412 | -| 13 | `POST zones//workers/routes {pattern:"/*", script:}` | an API error | -| 14 | with `--alias`: `PUT workers/domains {hostname:, service:}` (TASK-1b(f): rebind in place, or DELETE then PUT with the gap measured) | an API error | +| 13 | `POST zones//workers/routes {pattern:"/*", script:, request_limit_fail_open:true}` (TASK-1b(k): both accounts are on Workers Paid, so the flag only matters after a drop to free) | an API error | +| 14 | with `--alias`: `PUT workers/domains {hostname:, service:, override_existing_origin:true}` (TASK-1b(f): rebinds in place with no failed request; the plain PUT answers `100116`) | an API error | | 15 | live checks: `/healthz` three times in a row with this CLI's `X-Share-Worker` pair and `X-Share-Tunnel: 1`; a probe file through the tunnel leg; with `--alias`: `https:///healthz` answers 301 to `https:///healthz` | timeout: die naming the leg. Without `--alias` the named rollback is `--no-r2`. With `--alias` the die prints the alias rollback list (`## Migration`, Dwarves), never `--no-r2` alone | | 16 | with `--alias`: each step-7 app drops its `` destinations; the local profile whose `hostname` is `` hands its `r2-own` lines to this profile | | @@ -172,7 +172,7 @@ Checks run in this order. Rows marked "as SPEC-007" are unchanged. | 7 | `GET m/` returns a `v:1` record with no `storage` | SPEC-007 rows: version, id, prefix, and expiry checks (404 on any), the JWT for a gated record (404), then GET or HEAD served from R2 (other methods 405), hits counted | | 8 | a `v:2` record whose `storage` is exactly `machine`; no record; or `BUCKET.get` itself throws or exceeds 2 s | `PASS` set: pass through; else 404 | | 8b | any other record: not JSON, a `v` above 2, `v:2` with another `storage`, `v:1` with a `storage` key | 404, never passed through | -| 9 | pass through | `fetch(request)`, any method, the body streamed, the original headers (the Access JWT and cookie included). The origin's answer goes back rewrapped (`new Response(r.body, r)`, so `no-store` and `noindex` can be set on headers a `fetch` answer holds immutable), with status and body unchanged; a 101 WebSocket answer goes back untouched (TASK-1a(d)); except a 502, 503 that is not the origin's own, 520 to 527, or 530 (TASK-1a(e) pins the set): `503`, `Retry-After: 60`, `Cache-Control: no-store`, `X-Robots-Tag: noindex, nofollow`, body `the machine serving this link is offline; try again later`. No hit is counted | +| 9 | pass through | `fetch(request)`, any method, the body streamed, the original headers (the Access JWT and cookie included). The origin's answer goes back rewrapped (`new Response(r.body, r)`, so `no-store` and `noindex` can be set on headers a `fetch` answer holds immutable), with status and body unchanged; a 101 WebSocket answer goes back untouched (TASK-1a(d)); except a 502, a 503 without `cf-cache-status` (the origin's own answers carry it, edge errors do not), 520 to 527, or 530 (TASK-1a(e) pins the set; Cloudflare already replaces an origin's own 502 with its edge page): `503`, `Retry-After: 60`, `Cache-Control: no-store`, `X-Robots-Tag: noindex, nofollow`, body `the machine serving this link is offline; try again later`. No hit is counted | Callers of `/healthz` read the cloud leg from the headers, never from the status alone: a `503` carrying a well-formed `X-Share-Worker` pair and `X-Share-Tunnel: 0` means the Worker is up and the origin is down. `r2_healthz` sets `hz_code=200` for it and a new `hz_tunnel=0`, so a member's join (`r2_wait_healthz join`), a gated `add` on a member, and an r2 `state` (`ready: true`) keep working while the origin is off, which is the case R2 exists for. Only the origin's own `status` and `state` report the tunnel leg, as they do today. @@ -305,8 +305,8 @@ SPEC-007's model holds: an admin token from the environment for setup work, neve | Role | Scopes | Source | |---|---|---| -| admin, `setup --r2`, `--no-r2` | SPEC-007's minimal admin set (Workers Scripts Edit on the account, DNS Read and Zone Read on the zone, Workers R2 Storage Edit), plus Workers Routes Edit on the zone (the route), plus Access Apps and Policies Edit and Access Organizations Read (the alias fold's destination edits and the `TEAM` binding) | `op://Toolkit/cf-api-token/credential` when TASK-1b(h) shows it holds every scope on the tenant's account; else a run-scoped token minted through `op://Toolkit/cf-tokens-admin` (`POST /user/tokens`, `expires_on` one day) | -| `migrate` switch | Cloudflare Tunnel Edit, DNS Edit, Zone Read on the tenant's zone | `op://Toolkit/cf-api-token/credential` when TASK-1b(i) shows it edits tunnels and DNS on the personal account; else a run-scoped token as above | +| admin, `setup --r2`, `--no-r2` | SPEC-007's minimal admin set (Workers Scripts Edit on the account, DNS Read and Zone Read on the zone, Workers R2 Storage Edit), plus Workers Routes Edit on the zone (the route), plus Access Apps and Policies Edit and Access Organizations Read (the alias fold's destination edits and the `TEAM` binding) | `op://Toolkit/cf-api-token/credential` (TASK-1b(h): it holds every scope on both accounts); a run-scoped token minted through `op://Toolkit/cf-tokens-admin` (`POST /user/tokens`, `expires_on` one day, the set in this row) also works and is the narrower choice | +| `migrate` switch | Cloudflare Tunnel Edit, DNS Edit, Zone Read on the tenant's zone | a run-scoped one-day token as above (the round-1 warning); `op://Toolkit/cf-api-token/credential` also edits tunnels and DNS on the personal account (TASK-1b(i)) | | origin publisher (Mini, `dfoundation`) | Workers R2 Storage Bucket Item Write on `share-dfoundation` only, plus SPEC-004's Access scopes (gated adds), plus Zone Read, plus Account Analytics Read (`hits` of a cloud row) | minted once through `cf-tokens-admin`, `expires_on` set, stored with `share --profile dfoundation api-token`. `api-token` and every bucket call key the r2 branch on `bucket=` (today they key on `backend == r2`), so `r2_publisher_check` refuses an admin or account-wide R2 token on the origin too, when stored and when first used from the environment | | member publisher | as SPEC-007 | as SPEC-007 (DEC-007 there: one per teammate) | @@ -380,8 +380,8 @@ A tunnel profile with no `bucket=` (every R2-off tenant) keeps every file, outpu ## Task Breakdown -- [ ] TASK-1a: edge spike on a throwaway hostname, one Worker, one tunnel, one Access app; answers in `docs/implementation-notes/one-host-per-tenant.md`. (a) A route Worker's `fetch(request)` and a `fetch` of `https:///healthz` reach the tunnel origin without running the Worker again. (b) Access answers an unauthenticated request to a gated path with its 302 before the route Worker runs; an admitted request reaches the Worker with `Cf-Access-Jwt-Assertion`. (c) The pass-through of an admitted request reaches the origin. (d) A POST and a WebSocket upgrade pass through to a live dev server. (e) The status codes a pass-through sees with the connector down, and a header that tells Caddy's own 502 apart. AC: each answered; (a) or (b) failing blocks TASK-3 and amends `### Approaches considered`. -- [ ] TASK-1b: account spike with throwaway tokens and names. (f) Rebinding an existing custom domain to another Worker: in place, or DELETE then PUT, and the gap. (g) `PUT access/apps/` with added destinations keeps the AUD. (h) Whether `op://Toolkit/cf-api-token` holds Workers Routes Edit and Access Apps Edit on the Dwarves account. (i) Whether it edits tunnels and DNS on the personal account. (k) The Workers plan on each account and the route's failure mode at the request limit. AC: each answered, the token table amended to the source each role uses. +- [x] TASK-1a: edge spike on a throwaway hostname, one Worker, one tunnel, one Access app; answers in `docs/implementation-notes/one-host-per-tenant.md`. (a) A route Worker's `fetch(request)` and a `fetch` of `https:///healthz` reach the tunnel origin without running the Worker again. (b) Access answers an unauthenticated request to a gated path with its 302 before the route Worker runs; an admitted request reaches the Worker with `Cf-Access-Jwt-Assertion`. (c) The pass-through of an admitted request reaches the origin. (d) A POST and a WebSocket upgrade pass through to a live dev server. (e) The status codes a pass-through sees with the connector down, and a header that tells Caddy's own 502 apart. AC: each answered; (a) or (b) failing blocks TASK-3 and amends `### Approaches considered`. +- [x] TASK-1b: account spike with throwaway tokens and names. (f) Rebinding an existing custom domain to another Worker: in place, or DELETE then PUT, and the gap. (g) `PUT access/apps/` with added destinations keeps the AUD. (h) Whether `op://Toolkit/cf-api-token` holds Workers Routes Edit and Access Apps Edit on the Dwarves account. (i) Whether it edits tunnels and DNS on the personal account. (k) The Workers plan on each account and the route's failure mode at the request limit. AC: each answered, the token table amended to the source each role uses. - [ ] TASK-2a: per-link storage on the origin: config keys, `--cloud`, `--local`, their refusals, `rand_id` across both legs, the pointer write and its delete on `rm` and `prune`. Depends on TASK-1a. AC: rows 2, 4, 5. - [ ] TASK-2b: storage dispatch for `rm`, `refresh`, `hits` with the member refusals, the reconcile, the sweep rule for pointers, and the origin's cloud expiry through its own reader. Depends on TASK-2a. AC: rows 3, 6, 26, 27. - [ ] TASK-3: Worker v3 (`ALIASES`, `PASS`, two-leg `/healthz`, the record branches, the rewrap, the offline page, the method rule, `WORKER_RECORD_V` 2), `r2_healthz`'s header reading, and `tests/worker.mjs` with a stub whose responses hold immutable headers. Depends on TASK-1a. AC: rows 7 to 10, 29. From 3ea7dfe3acbf7f9f91e0111a08df8733faeaa025 Mon Sep 17 00:00:00 2001 From: Han Ngo Date: Thu, 1 Oct 2026 18:02:26 +0700 Subject: [PATCH 07/55] feat: per-link storage on an origin with R2 on A tunnel profile with bucket= now takes --cloud and --local per add, with storage_default as the tenant default. A local add reserves its id with a v:2 machine pointer before any byte is served or any Access app exists, and fails closed when R2 refuses it. rand_id checks the local index and the bucket. rm deletes the origin's own pointer after the row. api-token runs the publisher refusals on bucket=. Suite: 1112 ok, 0 FAIL. --- bin/share | 153 +++++++++++++++--- .../one-host-per-tenant.md | 16 ++ docs/specs/SPEC-008-one-host-per-tenant.md | 2 +- tests/share.sh | 96 +++++++++++ 4 files changed, 240 insertions(+), 27 deletions(-) diff --git a/bin/share b/bin/share index 763510c..e4a3034 100755 --- a/bin/share +++ b/bin/share @@ -9,6 +9,7 @@ # share add live proxy at https://// # share add ... --host . give the share its own hostname # share add ... --access group:|email:[,...]|domain: a login gate (Cloudflare Access) on this link +# share add --cloud|--local R2 on: this link in the bucket, or on this machine (default: storage_default) # share api-token [--cmd '' | --check] the Cloudflare API token --access needs: prefilled form + paste, or point at one you have # share ls list shares: link, size, source, expiry # share refresh re-copy from the source path (same link) @@ -70,6 +71,8 @@ fi hosts="${SHARE_HOSTS:-$(cfg hosts || true)}" tunnel="${SHARE_TUNNEL:-1}" # 0 = local-only (tests) mode="$(cfg mode || true)" # "quick" = trycloudflare, no domain; empty = named tunnel +r2on=0 # a tunnel profile with bucket= is a tenant origin with R2 on: local links get pointer records, --cloud adds go to the bucket +[[ $backend != r2 && -n "$(cfg bucket || true)" ]] && r2on=1 api="https://api.cloudflare.com/client/v4" this_host() { local h; h="$(uname -n)"; echo "${h%%.*}"; } @@ -152,23 +155,28 @@ rand_id() { # loops past a collision with an existing row or an orphaned pub/"* ]] && continue # leftover objects under this id: never hand it out + if [[ $backend == r2 ]]; then + r2_id_taken "$id" && continue ! grep -q "^$id"$'\t' "$pending" 2>/dev/null && { echo "$id"; return; } continue fi # an id named in access-pending has an Access app waiting: a new share must never land behind it - [[ -z "$(row "$id")" && ! -e $pub/$id ]] && ! grep -q "^$id"$'\t' "$pending" 2>/dev/null && { echo "$id"; return; } + if [[ -n "$(row "$id")" || -e $pub/$id ]] || grep -q "^$id"$'\t' "$pending" 2>/dev/null; then continue; fi + [[ $r2on == 1 ]] && r2_id_taken "$id" && continue # an origin with R2 on: one namespace with the bucket + echo "$id"; return done } +r2_id_taken() { # r2_id_taken : a live GET and a prefix scan; anything but 200/404 or empty fails closed + r2_call GET "m/$1" || die "the r2 record check needs a publisher token (see above)" + case $cf_code in + 200) return 0 ;; + 404) ;; + *) die "the r2 record check for $1 failed (HTTP $cf_code); no id was picked" ;; + esac + r2_call LIST "o/$1." + [[ $cf_code == 200 ]] || die "the r2 prefix check for $1 failed (HTTP $cf_code); no id was picked" + [[ $cf_body == *""* ]] # leftover objects under this id: never hand it out +} has_opt() { case " $2 " in *" $1 "*) return 0 ;; esac; return 1; } opt_val() { # opt_val : the value of a key=value token, nothing when absent local rest="${2#*"$1"=}" @@ -248,7 +256,7 @@ share_url() { # share_url [opts]: sets share_url_out; the link depen fi path="$2" local base="$pub/$1" - [[ $backend != r2 ]] || base="${r2_link_base:-}" # r2: the stage during an add; a listing has none, and the Worker 308s a folder to its slash + [[ $backend != r2 && -z ${r2_link_base:-} ]] || base="${r2_link_base:-}" # r2 or a cloud add on an origin: the stage during an add; a listing has none, and the Worker 308s a folder to its slash if [[ -z $base ]]; then : elif [[ -d $base/$2 ]]; then path="$2/" elif [[ $2 == *.md && -f $base/${2%.md}.html ]]; then path="${2%.md}.html" @@ -447,19 +455,32 @@ parse_target() { # parse_target : "live " for a port/localhost shape, } cmd_add() { - local ttl="${SHARE_TTL:-$(cfg ttl || true)}"; ttl="${ttl:-30d}" noindex="" host_fqdn="" target="" ntargets=0 access="" - local usage="usage: share add [--ttl 7d] [--host .] [--no-index] [--access ] " + local ttl="${SHARE_TTL:-$(cfg ttl || true)}"; ttl="${ttl:-30d}" noindex="" host_fqdn="" target="" ntargets=0 access="" storage="" + local usage="usage: share add [--ttl 7d] [--host .] [--no-index] [--access ] [--cloud|--local] " while [[ $# -gt 0 ]]; do case $1 in --ttl) ttl="${2:-}"; shift 2 || die "$usage" ;; --host) host_fqdn="${2:-}"; shift 2 || die "$usage" ;; --access) access="${2:-}"; shift 2 || die "$usage"; [[ -n $access ]] || die "$access_usage" ;; --no-index) noindex=1; shift ;; + --cloud|--local) [[ -z $storage || $storage == "${1#--}" ]] || die "$usage"; storage="${1#--}"; shift ;; --*) die "$usage" ;; *) target="$1"; ntargets=$((ntargets + 1)); shift ;; esac done [[ $ntargets == 1 ]] || die "$usage" + if [[ $backend != r2 && ( $r2on == 1 || -n $storage ) ]]; then # the storage of this link, settled before any write + local pt0; pt0="$(parse_target "$target")" || die "$usage" + if [[ $storage == cloud ]]; then + [[ -z $host_fqdn && $pt0 == snapshot ]] || die "--cloud: live links and --host stay on the origin's tunnel" + [[ $r2on == 1 ]] || die "R2 is off for ${host_name:-this profile}; the tenant admin enables it with '$me setup ${host_name:-} --r2 --bucket '" + elif [[ -z $storage && $r2on == 1 && -z $host_fqdn && $pt0 == snapshot ]]; then + storage="$(cfg storage_default || true)"; storage="${storage:-local}" + [[ $storage == local || $storage == cloud ]] || die "storage_default=$storage in $config is not local or cloud" + fi + elif [[ $backend == r2 && $storage == local ]]; then + die "$host_name serves local links from its origin; this machine publishes cloud links only" + fi [[ -z $host_fqdn || $mode != quick ]] || die "--host needs a named tunnel: '$me teardown', then '$me setup '" if [[ -n $access ]]; then # every refusal before any write access_parse "$access" @@ -468,7 +489,7 @@ cmd_add() { fi local pt kind tport="" src name id exp=0 url opts="" secs pt="$(parse_target "$target")" || die "$usage" - if [[ $backend == r2 ]]; then # every r2 refusal before any write + if [[ $backend == r2 || $storage == cloud ]]; then # every r2 refusal before any write; a cloud add on an origin runs the same path [[ $pt == snapshot && -z $host_fqdn ]] || die "live shares and --host need a tunnel profile; this profile serves from R2" need_host cmd_add_r2 "$(ttl_secs "$ttl")" "$noindex" "$target" "$access" @@ -502,6 +523,7 @@ cmd_add() { host_check "$host_fqdn" opts="${opts:+$opts }host=$host_fqdn" fi + if [[ $r2on == 1 ]]; then r2_token_check; fi # R2 on: a local link needs its pointer, so no token means nothing is written id="$(rand_id)" [[ $secs -gt 0 ]] && exp=$(($(date +%s) + secs)) mk_root; mkdir -p "$pub" @@ -512,9 +534,11 @@ cmd_add() { curl -s -o /dev/null --max-time 2 "http://127.0.0.1:$tport/" || echo "share: nothing answers on 127.0.0.1:$tport yet" >&2 else stage_copy "$src" "$opts" - # a gated copy stays outside pub until the gate is observed; warn_private says "public to anyone with the link", which a gated link is not - if [[ -z $access ]]; then stage_publish "$id"; warn_private "$src" "$id"; fi fi + # R2 on: the pointer reserves the id in the bucket before any byte is served and before the Access app; a 412 takes a fresh id + if [[ $r2on == 1 ]]; then r2_pointer_put "$kind" "$name" "$src" "$exp" "$opts" "$access"; fi + # a gated copy stays outside pub until the gate is observed; warn_private says "public to anyone with the link", which a gated link is not + if [[ $kind == snapshot && -z $access ]]; then stage_publish "$id"; warn_private "$src" "$id"; fi if [[ -n $access ]]; then access_create "$id" "$host_fqdn" # intent line, the app, its id into the line, read-back opts="${opts:+$opts }access=$access_app access_rule=$access_rule" @@ -621,6 +645,7 @@ cmd_rm() { # cmd_rm [prune]: prune passes through with no credential, so ex caddy_reload || reloaded=0 lock_release index echo "unpublished $1" + if [[ ${2:-} == prune ]]; then r2_pointer_drop "$1" "${prune_mode:-soft}"; else r2_pointer_drop "$1" soft; fi [[ -n $app ]] || return 0 if [[ $reloaded == 0 ]] && has_opt live "$opts"; then # the running Caddy still proxies //*: the app is the only gate left die "Caddy did not reload, so the live route for $1 may still answer; its Access app stays in access-pending; fix Caddy (see $root/caddy.log), then: $me prune" @@ -640,9 +665,9 @@ cmd_prune() { # cmd_prune [quiet|listing]: unpublish every share past its expiry # never runs `op read`. bare (the prune verb): the full resolver, soft on a failing api_token_cmd. A token for another # account warns and skips the sweep in every mode: expiry is never blocked. case ${1:-} in - quiet) api_token="" ;; - listing) api_token_resolve env ;; - *) api_token_resolve soft ;; + quiet) api_token="" prune_mode="off" ;; + listing) api_token_resolve env; prune_mode="env" ;; + *) api_token_resolve soft; prune_mode="soft" ;; esac if [[ -n $api_token && -s $pending && -n $host_name ]]; then access_account soft && access_sweep @@ -1449,7 +1474,7 @@ api_token_file="$config_dir/api-token" api_token_key() { echo "share-api${profile:+.$profile}:$host_name"; } dry_log() { # an r2 profile logs beside its bucket calls, so one log orders PUTs, PROBEs, and app calls [[ ${SHARE_ACCESS_DRY:-0} == 1 ]] || return 0 - if [[ $backend == r2 ]]; then echo "$*" >>"$root/r2-calls.log"; else echo "$*" >>"$root/access-calls.log"; fi + if [[ $backend == r2 || $r2on == 1 ]]; then echo "$*" >>"$root/r2-calls.log"; else echo "$*" >>"$root/access-calls.log"; fi } hostname_ok() { [[ $1 =~ ^[a-z0-9]([a-z0-9.-]*[a-z0-9])?\.[a-z][a-z]+$ ]]; } @@ -1995,7 +2020,7 @@ cmd_api_token() { # cmd_api_token [--cmd '' | --check]: store a token c --cmd) [[ $# -eq 2 && -n $2 ]] || die "$usage" case $2 in *$'\n'*|*$'\r'*) die "$usage" ;; esac # a line break would inject a second config line - if [[ $backend == r2 ]]; then # the command's token is checked before the command is stored + if [[ $backend == r2 || $r2on == 1 ]]; then # the command's token is checked before the command is stored tok="$(eval "$2" 2>/dev/null)" || tok="" [[ -n $tok ]] || die "the command printed no token; run it by hand to see why; nothing was stored" r2_publisher_check "$tok"; tok="" @@ -2010,6 +2035,9 @@ cmd_api_token() { # cmd_api_token [--cmd '' | --check]: store a token c if [[ $backend == r2 ]]; then echo "Create the publisher token in the Cloudflare dashboard: the form is prefilled with Zone: Read and the name;" echo " add Workers R2 Storage Bucket Item Write with the resource bucket $(cfg bucket || true) only (a link cannot preset a bucket), set an expiry, click Create." + elif [[ $r2on == 1 ]]; then # an origin with R2 on: the Access scopes stay required, the bucket scope is added by hand + echo "Create the token in the Cloudflare dashboard: the form is prefilled with the Access scopes, pick the account that owns $host_name;" + echo " add Workers R2 Storage Bucket Item Write with the resource bucket $(cfg bucket || true) only (a link cannot preset a bucket), set an expiry, click Create." else echo "Create the token in the Cloudflare dashboard: the form is prefilled, pick the account that owns $host_name, click Create." fi @@ -2020,7 +2048,7 @@ cmd_api_token() { # cmd_api_token [--cmd '' | --check]: store a token c IFS= read -r tok || true fi [[ -n $tok ]] || die "no token entered; rerun: $me api-token" - [[ $backend != r2 ]] || r2_publisher_check "$tok" + if [[ $backend == r2 || $r2on == 1 ]]; then r2_publisher_check "$tok"; fi api_token_store "$tok" # direct call: a bad token dies here, never reaches the preflight echo "token: $api_token_store_out" tok="" ;; @@ -2036,6 +2064,14 @@ cmd_api_token() { # cmd_api_token [--cmd '' | --check]: store a token c access_preflight || echo "share: ungated adds work with this token; gated adds and their removal also need the Access scopes above" >&2 return 0 fi + if [[ $r2on == 1 ]]; then # an origin with R2 on: the same publisher refusals, and the Access scopes stay required (SPEC-004) + if [[ ${1:-} == --check ]]; then + api_token_resolve + [[ -n $api_token ]] || die "no token for $host_name; store one: $me api-token" + r2_publisher_check "$api_token" + fi + echo "share: a publisher token for bucket $(cfg bucket || true): not an admin token, no other bucket in reach" >&2 + fi access_preflight } r2_publisher_check() { # r2_publisher_check [env]: before a publisher token is stored or used from CLOUDFLARE_API_TOKEN, refuse one that edits the Worker (admin) or lists another bucket (account-wide R2) @@ -2313,6 +2349,65 @@ r2_release_prefix() { # r2_release_prefix : the EXIT trap's r2 part return 0 } +share_type() { # share_type : the file-type word for a link's source, first match wins; the extension match ignores case + if [[ -d $1 ]]; then [[ -f $1/index.html ]] && echo site || echo folder; return 0; fi + case "$(basename "$1" | tr '[:upper:]' '[:lower:]')" in + *.html|*.htm) echo site ;; + *.md|*.markdown) echo markdown ;; + *.pdf) echo pdf ;; + *.png|*.jpg|*.jpeg|*.gif|*.webp|*.svg|*.heic|*.ico|*.avif) echo image ;; + *.mp4|*.mov|*.webm|*.m4v|*.mkv) echo video ;; + *.mp3|*.wav|*.m4a|*.aac|*.flac|*.ogg) echo audio ;; + *.zip|*.tar|*.gz|*.tgz|*.bz2|*.xz|*.7z|*.rar|*.dmg) echo archive ;; + *.txt|*.csv|*.json|*.yaml|*.yml|*.toml|*.xml|*.log|*.sh|*.py|*.js|*.mjs|*.ts|*.go|*.rs|*.rb|*.swift|*.java|*.c|*.h|*.css|*.sql) echo text ;; + *) echo other ;; + esac +} + +r2_token_ok() { # r2_token_ok off|env|soft: a publisher token resolves, never a die; env reads the environment only, so a listing runs no command + [[ $1 != off && ${SHARE_API_TOKEN_OFF:-0} != 1 ]] || return 1 + if [[ -n ${SHARE_R2_TOKEN:-} ]]; then : + elif [[ $1 == env ]]; then [[ -n ${CLOUDFLARE_API_TOKEN:-} ]] || return 1 + elif [[ -z "$(cfg r2_token_cmd || true)" ]]; then api_token_resolve soft; [[ -n $api_token ]] || return 1 + fi + [[ ${SHARE_R2_DRY:-0} == 1 ]] && return 0 + ( r2_creds ) >/dev/null 2>&1 || return 1 # an admin token from the environment dies in r2_publisher_check: here that only means no + r2_creds +} + +r2_pointer_put() { # r2_pointer_put [rule]: the origin's v:2 machine record for cmd_add's $id, If-None-Match: * + # a 412 means the bucket holds the id: cmd_add's id moves to a fresh one; any other answer dies before anything is served + local po="" t rec w i + for w in $5; do case $w in noindex|live) po="${po:+$po }$w" ;; esac; done + [[ -z ${6:-} ]] || po="${po:+$po }gated" # a flag, never the rule: every member reads this object + if [[ $1 == live ]]; then t=site; else t="$(share_type "$3")"; fi + for i in 1 2 3 4 5; do + rec="$(mktemp "$root/.r2rec.XXXXXX")" + jq -nc --arg id "$id" --arg name "$2" --arg by "$(r2_by)" --arg added "$(date +%F)" --argjson exp "$4" --arg opts "$po" --arg type "$t" \ + '{v: 2, id: $id, storage: "machine", name: $name, by: $by, added: $added, expires: $exp, opts: $opts, type: $type}' >"$rec" + r2_call PUT "m/$id" "$rec" -H 'If-None-Match: *' || true + rm -f "$rec" + case $cf_code in + 200) return 0 ;; + 412) id="$(rand_id)" || exit 1 ;; + *) die "R2 did not take the pointer record m/$id (HTTP $cf_code${cf_err:+, $cf_err}); $host_name has R2 on, so a local link needs one; nothing was published" ;; + esac + done + die "R2 answered 412 for five ids in a row; nothing was published; rerun the same $me add" +} + +r2_pointer_drop() { # r2_pointer_drop off|env|soft: after a local row is gone, its machine pointer goes too; without a token it stays (the Worker passes it to Caddy, which 404s) + [[ $r2on == 1 ]] || return 0 + r2_token_ok "$2" || { [[ $2 == off ]] || echo "share: the pointer m/$1 stays until '$me prune' runs with the publisher token" >&2; return 0; } + r2_call GET "m/$1" || true + [[ $cf_code == 200 ]] || return 0 # 404: nothing to delete; anything else: the reconcile retries + # only this origin's own pointer: a cloud record on the same id is another link (a shadow), never deleted here + [[ "$(jq -r 'if .v == 2 and .storage == "machine" then "machine" else "" end' <<<"$cf_body" 2>/dev/null)" == machine ]] || return 0 + r2_call DELETE "m/$1" || true + [[ $cf_code == 20? || $cf_code == 404 ]] || echo "share: deleting the pointer m/$1 answered HTTP $cf_code; '$me prune' retries it" >&2 + return 0 +} + cmd_add_r2() { # cmd_add_r2 [rule]: stage, upload to a fresh prefix, then PUT m/ with If-None-Match: * is the publish # gated (a rule, already parsed): the app and its gate sit between the upload and the record, and the record carries the app's aud local secs=$1 opts="" src name id exp=0 prefix rec url nonce gated="${4:-}" @@ -2351,8 +2446,8 @@ cmd_add_r2() { # cmd_add_r2 [rule]: stage, upload to a fi rec="$(mktemp "$root/.r2rec.XXXXXX")" jq -nc --arg id "$id" --arg name "$name" --arg src "$src" --arg added "$(date +%F)" --argjson exp "$exp" \ - --arg opts "$opts" --arg prefix "$prefix" --arg by "$(r2_by)" --arg aud "${gated:+$access_aud}" \ - '{v: 1, id: $id, name: $name, src: $src, added: $added, expires: $exp, opts: $opts, prefix: $prefix, by: $by} + --arg opts "$opts" --arg prefix "$prefix" --arg by "$(r2_by)" --arg aud "${gated:+$access_aud}" --arg type "$(share_type "$src")" \ + '{v: 1, id: $id, name: $name, src: $src, added: $added, expires: $exp, opts: $opts, prefix: $prefix, by: $by, type: $type} + (if $aud == "" then {} else {aud: $aud} end)' >"$rec" r2_call PUT "m/$id" "$rec" -H 'If-None-Match: *' rm -f "$rec" @@ -2774,7 +2869,7 @@ r2_creds() { # r2_creds: the publisher pair into r2_key_id/r2_secret; the token r2_call() { # r2_call METHOD KEY [ARG] [curl args...]: one S3 call on cf_try's globals; LIST takes [continuation]; 429 and 5xx retried, Retry-After capped at 30s cf_body="" cf_code=000 cf_err="" r2_etag="" - [[ $backend == r2 ]] || { cf_err=backend; return 1; } + [[ $backend == r2 || $r2on == 1 ]] || { cf_err=backend; return 1; } if [[ ${SHARE_R2_DRY:-0} == 1 ]]; then if [[ $tunnel != 0 || $host_name != *.test ]]; then echo "share: SHARE_R2_DRY=1 is a test seam for SHARE_TUNNEL=0 and a .test hostname only; the r2 call was refused; unset it" >&2 @@ -2812,6 +2907,7 @@ r2_dry() { # r2_dry METHOD KEY [ARG]: a directory-backed bucket for tests; one " mkdir -p "$d" "$root" if [[ ${SHARE_R2_DRY_PAUSE:-} == "$meth $key" ]]; then local w=0 + : >"$d/.paused" # a test waits for this, then looks at the world while the call is held while [[ ! -f $d/.resume && $w -lt 600 ]]; do sleep 0.05; w=$((w + 1)); done fi printf '%s %s\n' "$meth" "$key" >>"$root/r2-calls.log" @@ -2819,6 +2915,11 @@ r2_dry() { # r2_dry METHOD KEY [ARG]: a directory-backed bucket for tests; one " f="$d/$key" case $meth in PUT) + [[ ${SHARE_R2_DRY_PUT:-} == 500 ]] && { cf_code=500 cf_err=InternalError; return 0; } + if [[ ${SHARE_R2_DRY_PUT:-} == race-once && $key == m/* && ! -f $d/.fx/race.done ]]; then # another publisher takes this id just before the PUT + mkdir -p "$d/.fx" "${f%/*}"; : >"$d/.fx/race.done" + printf '{"v":1,"id":"%s","name":"theirs.txt","src":"/x","added":"2026-10-01","expires":0,"opts":"","prefix":"o/%s.00000000/","by":"peer"}\n' "${key#m/}" "${key#m/}" >"$f" + fi for h in "${@:4}"; do case "$(printf '%s' "$h" | tr '[:upper:]' '[:lower:]')" in -h) ;; diff --git a/docs/implementation-notes/one-host-per-tenant.md b/docs/implementation-notes/one-host-per-tenant.md index f408fb7..6937c8c 100644 --- a/docs/implementation-notes/one-host-per-tenant.md +++ b/docs/implementation-notes/one-host-per-tenant.md @@ -105,3 +105,19 @@ Answers: - (k) Both accounts are on Workers Paid, so no daily request limit applies and the route never hits the free plan's failure mode. TASK-4 sets `request_limit_fail_open: true` on the route anyway: it costs nothing on a paid plan and keeps machine links up if an account ever drops to free. Cleanup, checked through the API afterwards on both accounts: Workers, custom domains, Access apps, service tokens, tunnels, routes, and DNS records with names starting `share-e2e-spk` all 0; buckets starting `share-e2e` 0; user tokens starting `share-e2e-spk` 0 and the minted token's id answers 404; `dig` at each zone's authoritative server answers NXDOMAIN for both throwaway names. + +## Build deltas + +Deviations from the spec, per task. A task with none is not listed. + +### TASK-2a (per-link storage on the origin) + +- An origin with R2 on is a tunnel profile whose config holds `bucket=`; the process flag `r2on` keys every bucket call (`r2_call`), the dry log (`dry_log` writes `r2-calls.log`, so one log orders bucket PUTs, probes, and Access calls), `rand_id`'s bucket leg, and `api-token`'s publisher refusals. `backend` stays the tunnel, so a cloud add on the origin runs `cmd_add_r2` unchanged; `share_url` reads `r2_link_base` whenever it is set, and the origin keeps its own `r2-own` file. +- `--cloud` with a port or `--host` dies with `--cloud: live links and --host stay on the origin's tunnel` on every tunnel profile, R2 on or off, before the R2-off line. `--cloud --local` is the usage line. A bad `storage_default` dies naming the config. +- A local add on an R2-on origin runs `r2_token_check` before `rand_id`, so no token means no stage, no id, no write. +- The pointer PUT retries a 412 with a fresh `rand_id` up to five ids, then dies. The `type` is `site` for a live link, else the source's type. +- Cloud records written by `cmd_add_r2` now carry `type` too (the spec's "new cloud records store `type` at add time"); `v:1` readers ignore it. +- `rm` deletes the pointer only after a `GET m/` shows `v:2` with `storage:"machine"`: a cloud record on the same id (a shadow) is never deleted from the origin. Token modes: `rm` and bare `prune` use the soft resolver; `ls`'s listing prune only an environment token; serve's hourly prune never. An admin token from the environment counts as no token there (its `r2_publisher_check` refusal is probed in a subshell), so a listing never dies on it. +- `api-token` on an R2-on origin: `--cmd`, the paste, and `--check` run `r2_publisher_check`; the paste names the bucket scope beside the prefilled Access form; `access_preflight` stays required. Not built yet: the 14-day `expires_on` warning (round-1 warning), left for TASK-11a's token work. +- Test seams: `SHARE_R2_DRY_PUT=500` (every PUT answers 500), `SHARE_R2_DRY_PUT=race-once` (the first conditional PUT of an `m/` key finds a peer's record written just before it, so `If-None-Match: *` answers 412), and `SHARE_R2_DRY_PAUSE` now drops `.paused` in the dry bucket, so row 4 inspects `pub/` while the pointer PUT is held instead of racing a watcher. +- Left for TASK-2b: on the origin, `rm`, `refresh`, and `hits` of a cloud id still read the local index only (`no share with id`). diff --git a/docs/specs/SPEC-008-one-host-per-tenant.md b/docs/specs/SPEC-008-one-host-per-tenant.md index 2729c58..e548f5f 100644 --- a/docs/specs/SPEC-008-one-host-per-tenant.md +++ b/docs/specs/SPEC-008-one-host-per-tenant.md @@ -382,7 +382,7 @@ A tunnel profile with no `bucket=` (every R2-off tenant) keeps every file, outpu - [x] TASK-1a: edge spike on a throwaway hostname, one Worker, one tunnel, one Access app; answers in `docs/implementation-notes/one-host-per-tenant.md`. (a) A route Worker's `fetch(request)` and a `fetch` of `https:///healthz` reach the tunnel origin without running the Worker again. (b) Access answers an unauthenticated request to a gated path with its 302 before the route Worker runs; an admitted request reaches the Worker with `Cf-Access-Jwt-Assertion`. (c) The pass-through of an admitted request reaches the origin. (d) A POST and a WebSocket upgrade pass through to a live dev server. (e) The status codes a pass-through sees with the connector down, and a header that tells Caddy's own 502 apart. AC: each answered; (a) or (b) failing blocks TASK-3 and amends `### Approaches considered`. - [x] TASK-1b: account spike with throwaway tokens and names. (f) Rebinding an existing custom domain to another Worker: in place, or DELETE then PUT, and the gap. (g) `PUT access/apps/` with added destinations keeps the AUD. (h) Whether `op://Toolkit/cf-api-token` holds Workers Routes Edit and Access Apps Edit on the Dwarves account. (i) Whether it edits tunnels and DNS on the personal account. (k) The Workers plan on each account and the route's failure mode at the request limit. AC: each answered, the token table amended to the source each role uses. -- [ ] TASK-2a: per-link storage on the origin: config keys, `--cloud`, `--local`, their refusals, `rand_id` across both legs, the pointer write and its delete on `rm` and `prune`. Depends on TASK-1a. AC: rows 2, 4, 5. +- [x] TASK-2a: per-link storage on the origin: config keys, `--cloud`, `--local`, their refusals, `rand_id` across both legs, the pointer write and its delete on `rm` and `prune`. Depends on TASK-1a. AC: rows 2, 4, 5. - [ ] TASK-2b: storage dispatch for `rm`, `refresh`, `hits` with the member refusals, the reconcile, the sweep rule for pointers, and the origin's cloud expiry through its own reader. Depends on TASK-2a. AC: rows 3, 6, 26, 27. - [ ] TASK-3: Worker v3 (`ALIASES`, `PASS`, two-leg `/healthz`, the record branches, the rewrap, the offline page, the method rule, `WORKER_RECORD_V` 2), `r2_healthz`'s header reading, and `tests/worker.mjs` with a stub whose responses hold immutable headers. Depends on TASK-1a. AC: rows 7 to 10, 29. - [ ] TASK-4: `setup --r2` and `--no-r2` without `--alias` (steps 1 to 3, 5, 6, 8, 9, 11, 13, 15) and the rerun convergence. Depends on TASK-2a, TASK-3, TASK-1b(h). AC: rows 11, 12, 13, 32. diff --git a/tests/share.sh b/tests/share.sh index e8ac229..7093195 100755 --- a/tests/share.sh +++ b/tests/share.sh @@ -2752,6 +2752,102 @@ out=$(r2j SHARE_R2_DRY_HEALTHZ=down SHARE_R2_WAIT=1 2>&1); rc=$? check "join, Worker down: exit 1, no config" "1 0" "$rc $([[ -f $jconf ]] && echo 1 || echo 0)" rm -f "$s2conf" +echo "=== tenant: per-link storage on the origin (rows 2, 4, 5) ===" +T2H="$WORK/tenant-home"; DRYT="$WORK/tenant-bucket"; mkdir -p "$T2H/.config/share/profiles/org" "$T2H/.config/share/profiles/off" "$DRYT" +torg="$T2H/.config/share/profiles/org/config"; troot="$T2H/share/profiles/org"; tlog="$troot/r2-calls.log"; toffroot="$T2H/share/profiles/off" +printf 'hostname=org.example.test\ntunnel_id=tid-org\ntunnel_name=share-org-example-test\nhosts=not-this-host\nport=%s\nbucket=ok-bucket\nr2_endpoint=https://acct.example.r2.cloudflarestorage.com\nstorage_default=local\n' $((base + 40)) >"$torg" +printf 'hostname=off.example.test\ntunnel_id=tid-off\ntunnel_name=share-off-example-test\nhosts=not-this-host\nport=%s\n' $((base + 42)) >"$T2H/.config/share/profiles/off/config" +tn() { # tn : a tunnel origin under its own HOME on a dry bucket, Access dry; hosts names no machine, so nothing serves + local p=$1; shift + env -u SHARE_ROOT -u SHARE_CONFIG_DIR -u SHARE_PORT -u SHARE_HOSTNAME -u XDG_CONFIG_HOME -u SHARE_PROFILE -u SHARE_BACKEND -u SHARE_HOSTS \ + HOME="$T2H" SHARE_TUNNEL=0 SHARE_R2_DRY=1 SHARE_R2_DRY_DIR="$DRYT" SHARE_R2_TOKEN="${TN_TOK-drytoken}" \ + SHARE_ACCESS_DRY=1 SHARE_ACCESS_POLL=0 CLOUDFLARE_API_TOKEN="${TN_CF-faketoken}" bash "$SH" --profile "$p" "$@" +} +tline() { grep -n -- "$1" "$tlog" | head -1 | cut -d: -f1; } +trow() { awk -F'\t' -v id="$1" '$1 == id' "$2/index.tsv" 2>/dev/null; } +tnrows() { [[ -f $1/index.tsv ]] && grep -c . "$1/index.tsv" || echo 0; } +tputs() { grep -c '^PUT ' "$tlog" 2>/dev/null || true; } +mkdir -p "$WORK/tn"; printf '%%PDF-1.4\n' >"$WORK/tn/Report.PDF"; tf="$WORK/tn/Report.PDF" +lport=$((base + 45)) +# R2 off: today's add, no bucket call; --cloud is refused with the admin's command +out=$(SHARE_TEST_IDS=f00001 tn off add "$tf" 2>&1); rc=$? +check "row 2: R2 off, add f is local" "0 1 1" "$rc $(trow f00001 "$toffroot" | grep -c .) $([[ -d $toffroot/pub/f00001 ]] && echo 1 || echo 0)" +out=$(SHARE_TEST_IDS=f00002 tn off add --local "$tf" 2>&1); rc=$? +check "row 2: R2 off, add --local f is local" "0 1" "$rc $(trow f00002 "$toffroot" | grep -c .)" +out=$(SHARE_TEST_IDS=f00003 tn off add --cloud "$tf" 2>&1); rc=$? +check "row 2: R2 off, add --cloud f is refused" "1" "$rc" +check "row 2: R2 off, the refusal names the admin's command" "1" "$(grep -c "R2 is off for off.example.test; the tenant admin enables it with 'share --profile off setup off.example.test --r2 --bucket '" <<<"$out")" +check "row 2: R2 off, no bucket call and no new row" "0 2" "$([[ -e $toffroot/r2-calls.log ]] && echo 1 || echo 0) $(tnrows "$toffroot")" +# R2 on, storage_default=local +mkdir -p "$troot"; : >"$tlog" +out=$(SHARE_TEST_IDS=a00001 tn org add "$tf" 2>&1); rc=$? +check "row 2: R2 on, add f is local" "0 1 1" "$rc $(trow a00001 "$troot" | grep -c .) $([[ -d $troot/pub/a00001 ]] && echo 1 || echo 0)" +check "row 2: R2 on, add f writes its pointer" "2 machine Report.PDF pdf " "$(jq -r '"\(.v) \(.storage) \(.name) \(.type) \(.opts)"' "$DRYT/m/a00001" 2>/dev/null)" +check "row 2: the pointer's by and added" "1 $(date +%F)" "$(jq -r '.by' "$DRYT/m/a00001" | grep -c '^[a-z0-9.-]*$') $(jq -r .added "$DRYT/m/a00001")" +out=$(SHARE_TEST_IDS=a00002 tn org add --cloud "$tf" 2>&1); rc=$? +check "row 2: R2 on, add --cloud f is cloud" "0 0 0 1 pdf" "$rc $(trow a00002 "$troot" | grep -c .) $([[ -e $troot/pub/a00002 ]] && echo 1 || echo 0) $(jq -r .v "$DRYT/m/a00002" 2>/dev/null) $(jq -r .type "$DRYT/m/a00002" 2>/dev/null)" +check "row 2: the cloud link" "https://org.example.test/a00002/Report.PDF" "$(grep '^https://' <<<"$out")" +check "row 2: the cloud upload sits under its own prefix" "1" "$(find "$DRYT/o" -path '*/a00002.*/Report.PDF' | grep -c .)" +out=$(SHARE_TEST_IDS=a00003 tn org add "$lport" 2>&1); rc=$? +check "row 2: R2 on, add is live with a pointer" "0 1 2 live site" "$rc $(trow a00003 "$troot" | grep -c .) $(jq -r '"\(.v) \(.opts) \(.type)"' "$DRYT/m/a00003" 2>/dev/null)" +nrows="$(tnrows "$troot")"; nput="$(tputs)" +out=$(SHARE_TEST_IDS=a00009 tn org add --cloud "$lport" 2>&1); rc=$? +check "row 2: R2 on, add --cloud is refused" "1 1" "$rc $(grep -c "live links and --host stay on the origin's tunnel" <<<"$out")" +out=$(SHARE_TEST_IDS=a00009 tn org add --cloud --host x.example.test "$tf" 2>&1); rc=$? +check "row 2: R2 on, add --cloud --host is refused" "1 1" "$rc $(grep -c "live links and --host stay on the origin's tunnel" <<<"$out")" +out=$(SHARE_TEST_IDS=a00009 tn org add --cloud --local "$tf" 2>&1); rc=$? +check "row 2: R2 on, add --cloud --local is a usage error" "1 1" "$rc $(grep -c 'usage: share add' <<<"$out")" +check "row 2: the refusals log no PUT and write no row" "$nput $nrows 0" "$(tputs) $(tnrows "$troot") $([[ -e $DRYT/m/a00009 ]] && echo 1 || echo 0)" +# R2 on, storage_default=cloud +sed -i.bak 's/^storage_default=local$/storage_default=cloud/' "$torg" && rm -f "$torg.bak" +out=$(SHARE_TEST_IDS=a00004 tn org add "$tf" 2>&1); rc=$? +check "row 2: default cloud, add f is cloud" "0 0 1" "$rc $(trow a00004 "$troot" | grep -c .) $(jq -r .v "$DRYT/m/a00004" 2>/dev/null)" +out=$(SHARE_TEST_IDS=a00005 tn org add --local "$tf" 2>&1); rc=$? +check "row 2: default cloud, add --local f is local with a pointer" "0 1 2" "$rc $(trow a00005 "$troot" | grep -c .) $(jq -r .v "$DRYT/m/a00005" 2>/dev/null)" +sed -i.bak 's/^storage_default=cloud$/storage_default=local/' "$torg" && rm -f "$torg.bak" +# row 4: the pointer is the first write; a 412 takes a fresh id; the record carries the gated flag, never the rule or the host +: >"$tlog"; rm -rf "$DRYT/.fx" +out=$(SHARE_TEST_IDS="b00001 b00002" SHARE_R2_DRY_PUT=race-once tn org add --access email:a@example.test "$tf" 2>&1); rc=$? +check "row 4: the gated add exits 0 on the second id" "0 1 0" "$rc $(trow b00002 "$troot" | grep -c .) $(trow b00001 "$troot" | grep -c .)" +check "row 4: GET m/ and the prefix scan precede the pointer PUT" "1" "$([[ -n $(tline '^PUT m/b00001$') && $(tline '^GET m/b00001$') -lt $(tline '^LIST o/b00001\.$') && $(tline '^LIST o/b00001\.$') -lt $(tline '^PUT m/b00001$') ]] && echo 1 || echo 0)" +check "row 4: the 412 leaves the other publisher's record (If-None-Match: *)" "1 theirs.txt" "$(jq -r '"\(.v) \(.name)"' "$DRYT/m/b00001")" +check "row 4: the fresh id is checked, then reserved" "1" "$([[ $(tline '^PUT m/b00001$') -lt $(tline '^GET m/b00002$') && $(tline '^GET m/b00002$') -lt $(tline '^PUT m/b00002$') ]] && echo 1 || echo 0)" +check "row 4: the pointer: v:2, machine, gated, no rule, no host" "2 machine gated 0" "$(jq -r '"\(.v) \(.storage) \(.opts)"' "$DRYT/m/b00002") $(grep -c 'access\|host=\|example.test' "$DRYT/m/b00002" || true)" +check "row 4: a gated add's pointer PUT precedes POST app" "1" "$([[ -n $(tline '^POST app$') && $(tline '^PUT m/b00002$') -lt $(tline '^POST app$') ]] && echo 1 || echo 0)" +: >"$tlog"; rm -f "$DRYT/.resume" "$DRYT/.paused" +(SHARE_TEST_IDS=c00001 SHARE_R2_DRY_PAUSE="PUT m/c00001" tn org add "$tf" >/dev/null 2>&1; echo $? >"$WORK/tn/c1.rc") & +for _ in $(seq 1 200); do [[ -f $DRYT/.paused ]] && break; sleep 0.05; done +check "row 4: held at the pointer PUT, pub/ does not exist yet" "1 0" "$([[ -f $DRYT/.paused ]] && echo 1 || echo 0) $([[ -e $troot/pub/c00001 ]] && echo 1 || echo 0)" +: >"$DRYT/.resume"; wait "$!"; rm -f "$DRYT/.resume" "$DRYT/.paused" +check "row 4: released, the add publishes" "0 1" "$(cat "$WORK/tn/c1.rc") $([[ -d $troot/pub/c00001 ]] && echo 1 || echo 0)" +# row 5: the pointer PUT fails: nothing is served, no row, no app +for g in "" "--access email:a@example.test"; do + : >"$tlog" + # shellcheck disable=SC2086 # $g is zero or two words on purpose + out=$(SHARE_TEST_IDS=d00001 SHARE_R2_DRY_PUT=500 tn org add $g "$tf" 2>&1); rc=$? + check "row 5: pointer PUT 500 ${g:+(gated) }exits 1 naming R2" "1 1" "$rc $(grep -c 'R2 did not take the pointer record m/d00001 (HTTP 500' <<<"$out")" + check "row 5: ${g:+(gated) }no pub/, no row, no app, no stage" "0 0 0 0" "$([[ -e $troot/pub/d00001 ]] && echo 1 || echo 0) $(trow d00001 "$troot" | grep -c .) $(grep -c '^POST app$' "$tlog") $(find "$troot" -maxdepth 1 -name '.stage.*' | grep -c .)" +done +# rm of a local row deletes its own pointer; never a cloud record on the same id; without a token the pointer stays +: >"$tlog" +out=$(tn org rm a00001 2>&1); rc=$? +check "rm: a local row's pointer goes after the row" "0 0 1" "$rc $([[ -e $DRYT/m/a00001 ]] && echo 1 || echo 0) $(grep -c '^DELETE m/a00001$' "$tlog")" +cp "$DRYT/m/a00002" "$WORK/tn/cloud-a00002"; jq -c '.id = "a00005"' "$WORK/tn/cloud-a00002" >"$DRYT/m/a00005" # a cloud record shadows the local a00005 +: >"$tlog" +out=$(tn org rm a00005 2>&1); rc=$? +check "rm: a cloud record on the same id is never deleted" "0 1 0" "$rc $(jq -r .v "$DRYT/m/a00005") $(grep -c '^DELETE m/a00005$' "$tlog")" +: >"$tlog" +out=$(TN_TOK="" TN_CF="" tn org rm a00003 2>&1); rc=$? +check "rm: with no token the pointer stays and is named" "0 2 1" "$rc $(jq -r .v "$DRYT/m/a00003") $(grep -c 'the pointer m/a00003 stays' <<<"$out")" +# api-token on an origin keys the publisher refusals on bucket=, and the Access preflight still runs +: >"$tlog" +out=$(SHARE_R2_DRY_ROLE=deny tn org api-token --check 2>&1); rc=$? +check "api-token, R2-on origin: a publisher token passes and the Access preflight runs" "0 1 1" "$rc $(grep -c 'a publisher token for bucket ok-bucket' <<<"$out") $(grep -c '^GET orgs$' "$tlog")" +mkdir -p "$DRYT/.cf"; echo '{}' >"$DRYT/.cf/script.json" +out=$(tn org api-token --check 2>&1); rc=$? +check "api-token, R2-on origin: an admin token is refused" "1 1" "$rc $(grep -c 'it is an admin token' <<<"$out")" +rm -f "$DRYT/.cf/script.json" + echo "=== worker (tests/worker.mjs) ===" if command -v node >/dev/null; then wout="$(node "$(dirname "$SH")/../tests/worker.mjs" 2>&1)"; wrc=$? From 36ad8abbe689fdfde89deb71ab14a7435e4b8ab1 Mon Sep 17 00:00:00 2001 From: Han Ngo Date: Thu, 1 Oct 2026 18:03:12 +0700 Subject: [PATCH 08/55] docs: proof of done for the first tenant batch Green suite run, the Worker tests, and two negative controls (pointer after publish, pointer failure as a warning), each red and then green after restore. --- docs/verification/one-host-per-tenant.md | 45 ++++++++++++++++++++++++ 1 file changed, 45 insertions(+) create mode 100644 docs/verification/one-host-per-tenant.md diff --git a/docs/verification/one-host-per-tenant.md b/docs/verification/one-host-per-tenant.md new file mode 100644 index 0000000..e78410c --- /dev/null +++ b/docs/verification/one-host-per-tenant.md @@ -0,0 +1,45 @@ +# Proof of done: one host per tenant, batch 1 + +Date: 2026-10-01 +Branch: docs/one-host-per-tenant +Spec: docs/specs/SPEC-008-one-host-per-tenant.md + +Batch 1 is TASK-1a and TASK-1b (live spikes; commands and answers in `docs/implementation-notes/one-host-per-tenant.md`) and TASK-2a (per-link storage on the origin, rows 2, 4, 5). + +## Green run + +``` +Command: SHARE_TEST_PORT_BASE=28787 gtimeout 900 bash tests/share.sh +Exit: 0 +Checks: 1112 ok, 0 FAIL, 0 SKIP (PASS), at 3ea7dfe +Section: === tenant: per-link storage on the origin (rows 2, 4, 5) ===, 35 checks +Tail: ok no real Keychain share item changed during the run + PASS +Verdict: PASS +``` + +``` +Command: node tests/worker.mjs +Exit: 0 +Tail: ok r28 malformed record aud + PASS +Verdict: PASS +``` + +`shellcheck bin/share install.sh tests/share.sh tests/e2e.sh tests/e2e-r2.sh demo/render.sh mac/*.sh` and `/bin/bash -n bin/share` are clean on the same tree. + +## Negative controls + +Each patch was applied to `bin/share` in the worktree, the tenant section was run on its own (the section's lines from `tests/share.sh` under a minimal `check` harness), then `git checkout -- bin/share` restored the file and the same run went green (35 ok, 0 FAIL). + +| Patch | Run | Red rows | +|---|---|---| +| write the pointer after `stage_publish` (the pointer line moved below the publish line in `cmd_add`) | exit with `fails=4` | row 4 "held at the pointer PUT, pub/ does not exist yet" (expected `1 0`, got `1 1`); row 5 "no pub/" (got `1 0 0 0`, twice) | +| let a pointer PUT failure warn instead of die (`r2_pointer_put`'s catch-all prints and returns 0) | exit with `fails=4` | row 5 "exits 1 naming R2" (got `0 0`, ungated and gated); row 5 "no pub/, no row, no app" (got `1 1 0 0`, and `1 1 1 0` gated: the app was created) | +| restore (`git checkout -- bin/share`) | `fails=0` | none | + +Dry trace for the first control: the row 4 run holds the add at `PUT m/c00001` through `SHARE_R2_DRY_PAUSE`, whose `.paused` marker tells the test it is inside the held call; with the patch, `stage_publish` has already moved the stage to `pub/c00001`, so the check reads `1` where it wants `0`. + +## Live spikes + +TASK-1a and TASK-1b ran against throwaway Cloudflare objects only (names `share-e2e-spk584462` and `share-e2e-spk13b60d`). Every object was deleted and checked from outside: the API listed none of them on either account afterwards, the minted token's id answered 404, and each zone's authoritative server answered NXDOMAIN for both names. From 6df031855e898f55d4ee1a9c333aadb0958bd751 Mon Sep 17 00:00:00 2001 From: Han Ngo Date: Thu, 1 Oct 2026 18:37:14 +0700 Subject: [PATCH 09/55] feat: storage dispatch, reconcile, and pointer sweep on an origin On an origin with R2 on, rm, refresh, and hits of an id with no local row take the r2 path through the origin's own bucket reader, which never assigns or writes index.tsv. A member's rm and refresh of a machine link die before any write. The origin's interactive ls and prune expire cloud rows, sweep orphan uploads, and reconcile pointers: orphan pointers over 10 minutes old go, a row with no pointer gets one, a shadowed id is named. The orphan sweep reads a machine pointer as readable. The access sweep decides per id. api-token warns 14 days before the publisher token expires. Suite: 1151 ok, 0 FAIL. --- bin/share | 175 +++++++++++++++++++++++++++++++++++++++---------- tests/share.sh | 143 ++++++++++++++++++++++++++++++++++++++++ 2 files changed, 284 insertions(+), 34 deletions(-) diff --git a/bin/share b/bin/share index e4a3034..8ed2126 100755 --- a/bin/share +++ b/bin/share @@ -82,10 +82,11 @@ running() { # the pid must still be a share serve: an unclean exit leaves the fi local pid; pid="$(cat "$pidfile")" kill -0 "$pid" 2>/dev/null && [[ $(ps -o command= -p "$pid" 2>/dev/null) == *share*serve* ]] } -rows() { # the only index reader: a forged row (bad id, main or malformed host=, a bad access= or access_rule=) never reaches a path, Caddyfile, or Cloudflare call - [[ -f $index ]] || return 0 +rows() { rows_in "$index" "$backend"; } # the only index reader +rows_in() { # rows_in : a forged row (bad id, main or malformed host=, a bad access= or access_rule=) never reaches a path, Caddyfile, or Cloudflare call + [[ -f $1 ]] || return 0 # no {n} intervals: mawk lacks them; the host regex is setup's hostname check; the rule grammar is access_parse's - awk -F'\t' -v main="$host_name" -v be="$backend" ' + awk -F'\t' -v main="$host_name" -v be="$2" ' function hostname(h) { return h ~ /^[a-z0-9]([a-z0-9.-]*[a-z0-9])?\.[a-z][a-z]+$/ } function uuid(u) { return length(u) == 36 && u !~ /[^0-9a-f-]/ && substr(u, 9, 1) == "-" && substr(u, 14, 1) == "-" && substr(u, 19, 1) == "-" && substr(u, 24, 1) == "-" && gsub(/-/, "-", u) == 4 } function rule(r, k, v, m, e, j, at) { @@ -127,9 +128,11 @@ rows() { # the only index reader: a forged row (bad id, main or malformed host=, } if (app != rl || !hp || !hb) next # a gated row carries both, or it is a hand edit; an r2 row needs its prefix and host print - }' "$index" + }' "$1" } row() { rows | awk -F'\t' -v id="$1" '$1 == id'; } +crows() { rows_in "${r2_snap_file:-/dev/null}" r2; } # the cloud rows of this process's bucket read; on an origin its own file, never $index +crow() { crows | awk -F'\t' -v id="$1" '$1 == id'; } id_of() { # accept an id or a pasted share link: https://host//name -> ; a bare https:/// resolves to the host row case ${1:-} in *://*) @@ -563,6 +566,7 @@ cmd_add() { [[ -n $access ]] && pending_drop "$id" "$access_app" held_fqdn="" held_access_id="" lock_release index # before cmd_start: serve's startup render takes the lock itself + [[ $r2on == 0 ]] || lock_release "add-$id" share_url "$id" "$name" "$opts" url="$share_url_out" echo "$url" @@ -599,6 +603,7 @@ access_col() { local r; r="$(opt_val access_rule "$1" || true)"; [[ -n $r ]] && cmd_refresh() { [[ $backend != r2 ]] || { cmd_refresh_r2 "$@"; return; } + if [[ $r2on == 1 && -z "$(row "${1:-}")" ]]; then cmd_refresh_r2 "$@"; return; fi # an origin with R2 on: an id with no local row is a cloud link local r src opts r="$(row "${1:-}")"; [[ -n $r ]] || die "no share with id '${1:-}'" opts="$(awk -F'\t' '{print $6}' <<<"$r")" @@ -618,6 +623,7 @@ cmd_refresh() { cmd_rm() { # cmd_rm [prune]: prune passes through with no credential, so expiry is never blocked (DEC-016) [[ $backend != r2 ]] || { cmd_rm_r2 "$@"; return; } + if [[ $r2on == 1 && -z "$(row "${1:-}")" ]]; then cmd_rm_r2 "$@"; return; fi # an origin with R2 on: an id with no local row is a cloud link local r opts fqdn="" app="" tmp reloaded=1 r="$(row "${1:-}")"; [[ -n $r ]] || die "no share with id '${1:-}'" opts="$(awk -F'\t' '{print $6}' <<<"$r")" @@ -660,6 +666,11 @@ cmd_rm() { # cmd_rm [prune]: prune passes through with no credential, so ex cmd_prune() { # cmd_prune [quiet|listing]: unpublish every share past its expiry; sweep waiting Access apps when a token resolves if [[ $backend == r2 ]]; then r2_prune "${1:-}"; return 0; fi + if [[ $r2on == 1 && ${1:-} != quiet ]]; then # an origin with R2 on, interactive: cloud expiry, the sweep, and the reconcile through its own reader + local pm=soft; [[ ${1:-} != listing ]] || pm="env" + if r2_token_ok "$pm"; then ( r2_prune "${1:-}"; r2_reconcile ) || echo "share: the cloud links of $host_name were not checked (see above); local links were" >&2 + elif [[ -z ${1:-} ]]; then echo "share: the cloud links and pointers of $host_name were not checked: no publisher token ($me api-token)" >&2; fi + fi # quiet (serve, status): no token from any source, so neither touches Access; an expired gated share defers its # app. listing (ls): only an exported CLOUDFLARE_API_TOKEN, never api_token_cmd or the Keychain, so a listing # never runs `op read`. bare (the prune verb): the full resolver, soft on a failing api_token_cmd. A token for another @@ -684,6 +695,7 @@ cmd_prune() { # cmd_prune [quiet|listing]: unpublish every share past its expiry cmd_hits() { [[ $backend != r2 ]] || { cmd_hits_r2 "$@"; return; } + if [[ $r2on == 1 && -z "$(row "${1:-}")" ]]; then cmd_hits_r2 "$@"; return; fi # an origin with R2 on: an id with no local row is a cloud link local r opts fqdn="" log="$root/access.log" r="$(row "${1:-}")"; [[ -n $r ]] || die "no share with id '${1:-}'" opts="$(awk -F'\t' '{print $6}' <<<"$r")" @@ -1932,7 +1944,7 @@ access_sweep() { # every pending line: a live owner is skipped, a finished add's # (orphaned bytes trashed first), then each app deleted with no lock held. A line that is not done stays claimed by # this process; once it exits, the next sweep sees a dead owner and retries. [[ -s $pending ]] || return 0 - local id app pid st created tmp reload=0 claimed="" uuid mcode="" + local id app pid st created tmp reload=0 claimed="" uuid mcode="" r2side lock_take index tmp="$(mktemp "$root/.pending.XXXXXX")" while IFS=$'\t' read -r id app pid st created; do @@ -1970,7 +1982,10 @@ access_sweep() { # every pending line: a live owner is skipped, a finished add's continue fi fi - if [[ $backend == r2 ]]; then # never the snapshot: a record naming this app is a finished add; a 404 frees the app; anything else keeps the line + # an origin with R2 on decides per id: a line with no local row may be a gated --cloud add, which takes the r2 rule + r2side=0; [[ $backend == r2 || ( $r2on == 1 && -z "$(row "$id")" ) ]] && r2side=1 + mcode="" + if [[ $r2side == 1 ]]; then # never the snapshot: a record naming this app is a finished add; a 404 frees the app; anything else keeps the line r2_call GET "m/$id"; mcode=$cf_code if [[ $cf_code == 200 && "$(jq -r '.opts // ""' <<<"$cf_body" 2>/dev/null | tr ' ' '\n' | sed -n 's/^access=//p')" == "$uuid" ]]; then lock_take index; pending_drop "$id" "$app"; lock_release index @@ -1981,7 +1996,7 @@ access_sweep() { # every pending line: a live owner is skipped, a finished add's if access_delete "$uuid" "$id"; then lock_take index; pending_drop "$id" "$app"; lock_release index # the upload of an add that never published: its own nonce only, never another publisher's prefix - if [[ $backend == r2 && $mcode == 404 && $access_deleted_nonce =~ ^[0-9a-f]{8}$ ]]; then + if [[ $r2side == 1 && $mcode == 404 && $access_deleted_nonce =~ ^[0-9a-f]{8}$ ]]; then (r2_delete_prefix "o/$id.$access_deleted_nonce/") >/dev/null 2>&1 || true fi fi @@ -2061,6 +2076,7 @@ cmd_api_token() { # cmd_api_token [--cmd '' | --check]: store a token c r2_publisher_check "$api_token" fi echo "share: a publisher token for bucket $(cfg bucket || true): not an admin token, no other bucket in reach" >&2 + r2_expiry_note access_preflight || echo "share: ungated adds work with this token; gated adds and their removal also need the Access scopes above" >&2 return 0 fi @@ -2071,9 +2087,27 @@ cmd_api_token() { # cmd_api_token [--cmd '' | --check]: store a token c r2_publisher_check "$api_token" fi echo "share: a publisher token for bucket $(cfg bucket || true): not an admin token, no other bucket in reach" >&2 + r2_expiry_note fi access_preflight } +r2_expiry_note() { # r2_expiry_note: the publisher token's expires_on from /user/tokens/verify; a warning 14 days ahead, since an expired token stops every add (on an origin with R2 on, local adds too) + local e left + api_token_resolve + [[ -n $api_token ]] || return 0 + r2_api GET /user/tokens/verify + [[ $cf_code == 200 ]] || return 0 + e="$(jq -r '.result.expires_on // empty' <<<"$cf_body" 2>/dev/null)" || return 0 + [[ -n $e ]] || return 0 + left="$(jq -rn --arg e "$e" '(($e | sub("\\.[0-9]+Z$"; "Z") | fromdateiso8601) - now) / 86400 | floor' 2>/dev/null)" || return 0 + [[ $left =~ ^-?[0-9]+$ ]] || return 0 + if [[ $left -lt 0 ]]; then + echo "share: the publisher token expired on ${e%%T*}; every add to $host_name fails until a new one is stored: $me api-token" >&2 + elif [[ $left -le 14 ]]; then + echo "share: the publisher token expires on ${e%%T*} (in $left days); then every add to $host_name fails; mint a new one and store it: $me api-token" >&2 + fi + return 0 +} r2_publisher_check() { # r2_publisher_check [env]: before a publisher token is stored or used from CLOUDFLARE_API_TOKEN, refuse one that edits the Worker (admin) or lists another bucket (account-wide R2) local ep a worker="share-${host_name//./-}" bucket others tail="nothing was stored" [[ ${2:-} != env ]] || tail="the token came from CLOUDFLARE_API_TOKEN; unset it or store a publisher token with $me api-token; nothing was changed" @@ -2375,27 +2409,68 @@ r2_token_ok() { # r2_token_ok off|env|soft: a publisher token resolves, never a r2_creds } -r2_pointer_put() { # r2_pointer_put [rule]: the origin's v:2 machine record for cmd_add's $id, If-None-Match: * - # a 412 means the bucket holds the id: cmd_add's id moves to a fresh one; any other answer dies before anything is served - local po="" t rec w i +r2_pointer_write() { # r2_pointer_write : one v:2 machine record, If-None-Match: *; the answer in cf_code + local po="" w rec for w in $5; do case $w in noindex|live) po="${po:+$po }$w" ;; esac; done - [[ -z ${6:-} ]] || po="${po:+$po }gated" # a flag, never the rule: every member reads this object + [[ -z $6 ]] || po="${po:+$po }gated" # a flag, never the rule: every member reads this object + rec="$(mktemp "$root/.r2rec.XXXXXX")" + jq -nc --arg id "$1" --arg name "$2" --arg by "$(r2_by)" --arg added "$7" --argjson exp "$4" --arg opts "$po" --arg type "$3" \ + '{v: 2, id: $id, storage: "machine", name: $name, by: $by, added: $added, expires: $exp, opts: $opts, type: $type}' >"$rec" + r2_call PUT "m/$1" "$rec" -H 'If-None-Match: *' || true + rm -f "$rec" +} + +r2_pointer_put() { # r2_pointer_put [rule]: the origin's v:2 machine record for cmd_add's $id, If-None-Match: * + # a 412 means the bucket holds the id: cmd_add's id moves to a fresh one; any other answer dies before anything is served. + # The add lock on the id tells the reconcile that this pointer has a row on the way. + local t i if [[ $1 == live ]]; then t=site; else t="$(share_type "$3")"; fi for i in 1 2 3 4 5; do - rec="$(mktemp "$root/.r2rec.XXXXXX")" - jq -nc --arg id "$id" --arg name "$2" --arg by "$(r2_by)" --arg added "$(date +%F)" --argjson exp "$4" --arg opts "$po" --arg type "$t" \ - '{v: 2, id: $id, storage: "machine", name: $name, by: $by, added: $added, expires: $exp, opts: $opts, type: $type}' >"$rec" - r2_call PUT "m/$id" "$rec" -H 'If-None-Match: *' || true - rm -f "$rec" + lock_take "add-$id" 5 + r2_pointer_write "$id" "$2" "$t" "$4" "$5" "${6:-}" "$(date +%F)" case $cf_code in 200) return 0 ;; - 412) id="$(rand_id)" || exit 1 ;; + 412) lock_release "add-$id"; id="$(rand_id)" || exit 1 ;; *) die "R2 did not take the pointer record m/$id (HTTP $cf_code${cf_err:+, $cf_err}); $host_name has R2 on, so a local link needs one; nothing was published" ;; esac done die "R2 answered 412 for five ids in a row; nothing was published; rerun the same $me add" } +add_held() { local p; p="$(readlink "$root/.lock-add-$1" 2>/dev/null)" && [[ -n $p ]] && kill -0 "$p" 2>/dev/null; } # a live add holds this id's pointer + +r2_reconcile() { # after r2_read, on the origin's interactive ls and prune: an orphan pointer goes, a row with no pointer gets one, a shadowed id is named + local now cut ids id e lm name src date exp opts t + now="$(date +%s)"; cut="$(r2_ago $((now - 600)))" + ids="$(rows | cut -f1)" + while IFS=$'\t' read -r id e; do # one origin per tenant: every machine pointer is this machine's + [[ -n $id ]] || continue + grep -qx "$id" <<<"$ids" && continue + lm="$(awk -F'\t' -v k="m/$id" '$1 == k {print $2; exit}' <<<"$r2_mmods")" + [[ -n $lm && ${lm:0:19} < $cut ]] || continue # under 10 minutes old: an add may not have written its row yet + pending_has_id "$id" && continue # a gated add inside its wait + add_held "$id" && continue + r2_call DELETE "m/$id" || true + if [[ $cf_code == 20? || $cf_code == 404 ]]; then echo "removed orphan pointer m/$id" + else echo "share: deleting the orphan pointer m/$id answered HTTP $cf_code; the next $me prune retries" >&2; fi + done <<<"$r2_machine" + while IFS=$'\t' read -r id name src date exp opts; do + [[ -n $id ]] || continue + [[ $exp == 0 || $exp -gt $now ]] || continue # an expired row is the local prune's + grep -q "^$id"$'\t' <<<"$r2_machine" && continue + if ! grep -qx "$id" <<<"$r2_mkeys"; then + if has_opt live "$opts"; then t=site; else t="$(share_type "$pub/$id/$name")"; fi + r2_pointer_write "$id" "$name" "$t" "$exp" "$opts" "$(opt_val access "$opts" || true)" "$date" + case $cf_code in + 200) echo "share: wrote the missing pointer m/$id" >&2; continue ;; + 412) ;; + *) echo "share: writing the pointer m/$id answered HTTP $cf_code; the next $me ls or prune retries" >&2; continue ;; + esac + fi + echo "share: $id is shadowed by a cloud link; rm one of them" >&2 # never deleted here: either side may be the link someone holds + done < <(rows) +} + r2_pointer_drop() { # r2_pointer_drop off|env|soft: after a local row is gone, its machine pointer goes too; without a token it stays (the Worker passes it to Caddy, which 404s) [[ $r2on == 1 ]] || return 0 r2_token_ok "$2" || { [[ $2 == off ]] || echo "share: the pointer m/$1 stays until '$me prune' runs with the publisher token" >&2; return 0; } @@ -2616,7 +2691,7 @@ r2_fetch_records() { # r2_fetch_records : parallel-GET every key in r2_lis [[ $code == 200 ]] && printf '%s' "$cf_body" >"$f" fi case $code in - 200) r2_record_row "$key" "$(cat "$f")" >>"$snap"; r2_record_raw "$key" "$(cat "$f")" ;; + 200) r2_take "$snap" "$key" "$(cat "$f")" ;; 404) ;; *) rm -rf "$wd"; die "r2 snapshot: GET $key answered HTTP $code" ;; esac @@ -2624,19 +2699,23 @@ r2_fetch_records() { # r2_fetch_records : parallel-GET every key in r2_lis rm -rf "$wd" } -r2_snapshot() { # r2_snapshot: every m/ record into a mktemp file that $index then names, so rows() stays the only reader +r2_snapshot() { r2_read; index="$r2_snap_file"; } # an r2 profile: the bucket read is its index, so rows() stays the only reader + +r2_read() { # r2_read: every m/ record into this process's own file (r2_snap_file, read through crows), the sweep view, and the machine pointers; never assigns $index local snap k mkdir -p "$root" snap="$(mktemp "$root/.r2-index.XXXXXX")" r2_snap_file="$snap"; trap release_locks EXIT - r2_refs="" r2_sweep_block="" + r2_refs="" r2_sweep_block="" r2_machine="" r2_list "m/" + r2_list_out="$(grep -E '^m/[0-9a-f]{6}$' <<<"$r2_list_out" || true)" # a key is read, shown, or deleted only in the shape share writes + r2_mkeys="${r2_list_out//m\//}" r2_mmods="$r2_list_mods" if [[ ${SHARE_R2_DRY:-0} == 1 ]]; then while IFS= read -r k; do - [[ $k == m/* ]] || continue + [[ -n $k ]] || continue r2_call GET "$k" case $cf_code in - 200) r2_record_row "$k" "$cf_body" >>"$snap"; r2_record_raw "$k" "$cf_body" ;; + 200) r2_take "$snap" "$k" "$cf_body" ;; 404) ;; *) die "r2 snapshot: GET $k answered HTTP $cf_code" ;; esac @@ -2644,14 +2723,24 @@ r2_snapshot() { # r2_snapshot: every m/ record into a mktemp file that $index th else r2_fetch_records "$snap" || die "r2 snapshot: a record fetch failed; nothing was listed" fi - index="$snap" +} + +r2_take() { # r2_take : one record into the cloud rows, the sweep view, and r2_machine ("\t" per v:2 machine pointer) + local e + r2_record_row "$2" "$3" >>"$1"; r2_record_raw "$2" "$3" + e="$(jq -r --arg id "${2#m/}" 'select(.v == 2 and .storage == "machine" and .id == $id and (.expires | type == "number" and . >= 0 and . < 1e11)) | .expires | floor' <<<"$3" 2>/dev/null)" || return 0 + [[ -z $e ]] || r2_machine+="${2#m/}"$'\t'"$e"$'\n' + return 0 } r2_record_raw() { # r2_record_raw : the orphan sweep's own view; the prefix into r2_refs, or the first key this CLI cannot read into r2_sweep_block + # a cloud record (v:1) names its upload; an origin's machine pointer (v:2, storage machine, no prefix) is readable and names none local p - p="$(jq -r --argjson v "$WORKER_RECORD_V" 'if type == "object" and (.prefix | type == "string") and (.v | type == "number") and .v <= $v then .prefix else error("unreadable") end' <<<"$2" 2>/dev/null)" || + p="$(jq -r 'if type == "object" and .v == 1 and (.prefix | type == "string") then .prefix + elif type == "object" and .v == 2 and .storage == "machine" and (has("prefix") | not) then "" + else error("unreadable") end' <<<"$2" 2>/dev/null)" || { r2_sweep_block="${r2_sweep_block:-$1}"; return 0; } - r2_refs+="$p"$'\n' + [[ -z $p ]] || r2_refs+="$p"$'\n' } r2_ago() { date -u -r "$1" +%Y-%m-%dT%H:%M:%S 2>/dev/null || date -u -d "@$1" +%Y-%m-%dT%H:%M:%S; } # epoch -> the ISO form S3's LastModified starts with @@ -2681,15 +2770,16 @@ r2_sweep_orphans() { # bare prune: every o/./ no record names, whose } r2_prune() { # r2_prune [quiet|listing]: the snapshot; unless quiet, every expired row through rm, a gated one only with an Access-proven token; a bare prune then sweeps orphan uploads - r2_snapshot + # on an origin with R2 on (cmd_prune calls it first) the cloud rows come from its own reader and never touch $index; cmd_prune runs the access sweep there + if [[ $backend == r2 ]]; then r2_snapshot; else r2_read; fi [[ ${1:-} != quiet ]] || return 0 - local now id opts expired gate_ok=0 + local now id opts expired gate_ok=0 e now="$(date +%s)" - expired="$(rows | awk -F'\t' -v now="$now" '$5 > 0 && $5 < now {print $1 "\t" $6}')" + expired="$(crows | awk -F'\t' -v now="$now" '$5 > 0 && $5 < now {print $1 "\t" $6}')" if [[ -s $pending || $expired == *access=* ]]; then # listing: the environment only, never op read; bare: the full resolver, soft if [[ ${1:-} == listing ]]; then api_token_resolve env; else api_token_resolve soft; fi [[ -n $api_token ]] && access_account soft && access_apps_edit_ok && gate_ok=1 - [[ $gate_ok == 0 ]] || access_sweep + [[ $gate_ok == 0 || $backend != r2 ]] || access_sweep fi while IFS=$'\t' read -r id opts; do [[ -n $id ]] || continue @@ -2697,16 +2787,27 @@ r2_prune() { # r2_prune [quiet|listing]: the snapshot; unless quiet, every expir echo "share: expired gated share $id waits for a publisher with the Access token" >&2 continue fi - cmd_rm "$id" prune | sed 's/$/ (expired)/' || true # a listing never dies on one row + cmd_rm_r2 "$id" prune | sed 's/$/ (expired)/' || true # a listing never dies on one row done <<<"$expired" + if [[ $backend == r2 ]]; then # a member: a machine pointer past its expiry goes; the origin's own prune unpublishes the link + while IFS=$'\t' read -r id e; do + [[ -n $id && $e -gt 0 && $e -lt $now ]] || continue + r2_call DELETE "m/$id" || true + if [[ $cf_code == 20? || $cf_code == 404 ]]; then echo "removed expired pointer m/$id"; fi + done <<<"$r2_machine" + fi [[ -n ${1:-} ]] || r2_sweep_orphans } cmd_rm_r2() { # cmd_rm_r2 [prune]: the record, a fresh read proving it gone, then every nonce's objects, then a gated share's app # any publisher may rm any share; a gated one needs a token that passes the Apps Edit probe first (prune: r2_prune proved it) local r opts tmp app - [[ -n ${r2_snap_file:-} ]] || r2_snapshot - r="$(row "${1:-}")"; [[ -n $r ]] || die "no share with id '${1:-}'" + [[ -n ${r2_snap_file:-} ]] || { if [[ $backend == r2 ]]; then r2_snapshot; else r2_read; fi; } + r="$(crow "${1:-}")" + if [[ -z $r ]]; then # a member never removes the origin's machine link: the origin keeps serving it and its reconcile writes the pointer back + [[ $backend == r2 ]] && grep -q "^${1:-x}"$'\t' <<<"$r2_machine" && die "$1 is served from the tenant's origin; remove it there" + die "no share with id '${1:-}'" + fi opts="$(awk -F'\t' '{print $6}' <<<"$r")" app="$(opt_val access "$opts" || true)" if [[ -n $app ]]; then @@ -2723,7 +2824,7 @@ cmd_rm_r2() { # cmd_rm_r2 [prune]: the record, a fresh read proving it gone r2_call GET "m/$1" # a lost DELETE must not look done; a gated line and its app stay [[ $cf_code == 404 ]] || die "m/$1 still answers HTTP $cf_code after its delete (HTTP $dcode); its objects${app:+ and its Access app} were kept; rerun $me rm $1" tmp="$(mktemp "$root/.r2-index.XXXXXX")" - awk -F'\t' -v id="$1" '$1 != id' "$index" >"$tmp" && mv -f "$tmp" "$index" # this process's listing drops the row too + awk -F'\t' -v id="$1" '$1 != id' "$r2_snap_file" >"$tmp" && mv -f "$tmp" "$r2_snap_file" # this process's listing drops the row too; on an origin never $index r2_own_drop "$1" (r2_delete_prefix "o/$1.") || echo "share: some objects under o/$1. remain; nothing serves them, and a $me prune removes them after 24 h" >&2 echo "unpublished $1" @@ -2744,6 +2845,8 @@ cmd_refresh_r2() { # cmd_refresh_r2 : this install's own share only; a new p *) die "reading m/$1 answered HTTP $cf_code; nothing was changed" ;; esac body=$cf_body etag=$r2_etag + [[ $backend != r2 || "$(jq -r 'if .v == 2 and .storage == "machine" then 1 else 0 end' <<<"$body" 2>/dev/null)" != 1 ]] || + die "$1 is served from the tenant's origin; remove it there" [[ -n "$(r2_record_row "m/$1" "$body")" && -n $etag ]] || die "no share with id '$1'" oprefix="$(jq -r '.prefix' <<<"$body")" own="$(r2_own_get "$1" || true)" @@ -2776,6 +2879,10 @@ cmd_refresh_r2() { # cmd_refresh_r2 : this install's own share only; a new p cmd_hits_r2() { # cmd_hits_r2 : one Analytics Engine SQL call; the id is checked before it can reach the query [[ ${1:-} =~ ^[0-9a-f]{6}$ ]] || die "no share with id '${1:-}'" local ds="share_${host_name//[.-]/_}" ep + if [[ $backend != r2 ]]; then # an origin: only a sound cloud record reaches the query; a member keeps one account call and no bucket read + r2_call GET "m/$1" || true + [[ $cf_code == 200 && -n "$(r2_record_row "m/$1" "$cf_body")" ]] || die "no share with id '$1'" + fi [[ $ds =~ ^[a-z0-9_]+$ ]] || die "bad hostname '$host_name' for a dataset name" ep="$(cfg r2_endpoint || true)"; acct="${ep#https://}"; acct="${acct%%.*}" [[ -n $ep && $acct =~ ^[A-Za-z0-9-]+$ ]] || die "the profile config has no r2_endpoint; rerun $me setup $host_name --backend r2 --bucket $(cfg bucket || true)" @@ -2978,7 +3085,7 @@ r2_api_dry() { # the account API under SHARE_R2_DRY: state files in $SHARE_R2_DR printf 'API %s %s\n' "$1" "$p" >>"$root/r2-calls.log" cf_code=200 cf_err="" cf_body='{"success":true,"result":[]}' case "$1 $p" in - "GET /user/tokens/verify") cf_body='{"success":true,"result":{"id":"dry-token-id","status":"active"}}' ;; + "GET /user/tokens/verify") cf_body="$(jq -nc --arg e "${SHARE_R2_DRY_EXPIRES:-}" '{success: true, result: ({id: "dry-token-id", status: "active"} + (if $e == "" then {} else {expires_on: $e} end))}')" ;; "GET /zones?name="*) n="${p#*name=}"; n="${n%%&*}" [[ $n == "${host_name#*.}" ]] && cf_body="{\"success\":true,\"result\":[{\"id\":\"zone-dry\",\"name\":\"$n\",\"account\":{\"id\":\"acct-dry\"}}]}" ;; diff --git a/tests/share.sh b/tests/share.sh index 7093195..6281e67 100755 --- a/tests/share.sh +++ b/tests/share.sh @@ -2848,6 +2848,149 @@ out=$(tn org api-token --check 2>&1); rc=$? check "api-token, R2-on origin: an admin token is refused" "1 1" "$rc $(grep -c 'it is an admin token' <<<"$out")" rm -f "$DRYT/.cf/script.json" +echo "=== tenant: storage dispatch, member refusals, reconcile, sweep (rows 3, 6, 26, 27) ===" +rm -rf "$DRYT" "$troot"; mkdir -p "$DRYT" "$troot"; : >"$tlog" +TMH="$WORK/tenant-member"; mkdir -p "$TMH/.config/share/profiles/org" +printf 'backend=r2\nhostname=org.example.test\nzone=example.test\nbucket=ok-bucket\nport=r2\nr2_endpoint=https://acct.example.r2.cloudflarestorage.com\nr2_key_id=keyid42\n' >"$TMH/.config/share/profiles/org/config" +tm() { # tm : a member of the same tenant, an r2 profile on the origin's dry bucket + env -u SHARE_ROOT -u SHARE_CONFIG_DIR -u SHARE_PORT -u SHARE_HOSTNAME -u XDG_CONFIG_HOME -u SHARE_PROFILE -u SHARE_BACKEND -u SHARE_HOSTS \ + HOME="$TMH" SHARE_TUNNEL=0 SHARE_R2_DRY=1 SHARE_R2_DRY_DIR="$DRYT" SHARE_R2_TOKEN=drytoken \ + SHARE_ACCESS_DRY=1 SHARE_ACCESS_POLL=0 CLOUDFLARE_API_TOKEN=faketoken bash "$SH" --profile org "$@" +} +mlog="$TMH/share/profiles/org/r2-calls.log"; mkdir -p "$TMH/share/profiles/org" +mdel() { grep -c "^DELETE m/$1\$" "$2" 2>/dev/null || true; } +md5of() { [[ -f $1 ]] && r2_md5_of "$1" || echo none; } +r2_md5_of() { if command -v md5 >/dev/null; then md5 -q "$1"; else md5sum "$1" | cut -d' ' -f1; fi; } +printf 'hello\n' >"$WORK/tn/note.txt"; tn2="$WORK/tn/note.txt" +out=$(SHARE_TEST_IDS=e10001 tn org add "$tn2" 2>&1); rc=$? +check "row 3: the origin's local add, with its pointer" "0 2" "$rc $(jq -r .v "$DRYT/m/e10001" 2>/dev/null)" +# row 3: a member publishes cloud links only and never removes, refreshes, or counts a machine link +out=$(SHARE_TEST_IDS=e20001 tm add "$tn2" 2>&1); rc=$? +check "row 3: member add f is cloud" "0 1" "$rc $(jq -r .v "$DRYT/m/e20001" 2>/dev/null)" +out=$(SHARE_TEST_IDS=e20002 tm add --local "$tn2" 2>&1); rc=$? +check "row 3: member add --local f is refused" "1 1" "$rc $(grep -c 'org.example.test serves local links from its origin; this machine publishes cloud links only' <<<"$out")" +out=$(SHARE_TEST_IDS=e20003 tm add "$lport" 2>&1); rc=$? +check "row 3: member add is refused" "1 0" "$rc $([[ -e $DRYT/m/e20003 ]] && echo 1 || echo 0)" +: >"$mlog" +out=$(tm rm e10001 2>&1); rc=$? +check "row 3: member rm of a machine row is refused" "1 1" "$rc $(grep -c "e10001 is served from the tenant's origin; remove it there" <<<"$out")" +check "row 3: no DELETE m/, the pointer stays" "0 2" "$(mdel e10001 "$mlog") $(jq -r .v "$DRYT/m/e10001")" +: >"$mlog" +out=$(tm refresh e10001 2>&1); rc=$? +check "row 3: member refresh of a machine row is refused" "1 1 0" "$rc $(grep -c "e10001 is served from the tenant's origin; remove it there" <<<"$out") $(grep -c '^PUT\|^DELETE' "$mlog")" +: >"$mlog" +out=$(tm hits e10001 2>&1); rc=$? +check "row 3: member hits keeps one account call and no bucket read (SPEC-007 row 13)" "0 0 1" "$rc $(grep -c '^GET' "$mlog") $(grep -c '^SQL' "$mlog")" +: >"$mlog" +out=$(tm rm e20001 2>&1); rc=$? +check "row 3: member rm of a cloud row runs the r2 path" "0 1 0" "$rc $(mdel e20001 "$mlog") $([[ -e $DRYT/m/e20001 ]] && echo 1 || echo 0)" +# row 27: on the origin each verb dispatches on the row's storage +out=$(SHARE_TEST_IDS=e30001 tn org add --cloud "$tn2" 2>&1); rc=$? +check "row 27: the origin's cloud add" "0 1" "$rc $(jq -r .v "$DRYT/m/e30001" 2>/dev/null)" +idx0="$(md5of "$troot/index.tsv")"; cad0="$(md5of "$troot/Caddyfile")" +mkdir -p "$DRYT/.cf"; printf '{"meta":[],"data":[{"hits":"3","visitors":"2","last":"2026-10-01 10:00:00"}],"rows":1}' >"$DRYT/.cf/sql.json" +: >"$tlog" +out=$(tn org hits e30001 2>&1); rc=$? +check "row 27: hits of a cloud row reads Analytics Engine" "0 1 1" "$rc $(grep -c "^SQL .*index1 = 'e30001'" "$tlog") $(grep -c '^3 hits, 2 visitors' <<<"$out")" +: >"$tlog" +out=$(tn org hits e10001 2>&1); rc=$? +check "row 27: hits of a machine row reads the access log" "0 0 0 hits" "$rc $(grep -c '^SQL' "$tlog") $out" +rm -f "$DRYT/.cf/sql.json" +out=$(tn org hits e9e9e9 2>&1); rc=$? +check "row 27: hits of an unknown id" "1 1" "$rc $(grep -c "no share with id 'e9e9e9'" <<<"$out")" +: >"$tlog"; p0="$(jq -r .prefix "$DRYT/m/e30001")" +out=$(tn org refresh e30001 2>&1); rc=$? +check "row 27: refresh of a cloud row swaps its prefix" "0 1 1" "$rc $(grep -c '^refreshed e30001 from' <<<"$out") $([[ $(jq -r .prefix "$DRYT/m/e30001") != "$p0" ]] && echo 1 || echo 0)" +check "row 27: the cloud refresh ran S3 calls under If-Match" "1" "$(grep -c '^PUT m/e30001$' "$tlog")" +: >"$tlog" +out=$(tn org refresh e10001 2>&1); rc=$? +check "row 27: refresh of a machine row re-copies locally, no S3 write" "0 1 0" "$rc $(grep -c '^refreshed e10001 from' <<<"$out") $(grep -c '^PUT\|^DELETE' "$tlog")" +: >"$tlog" +out=$(tn org rm e30001 2>&1); rc=$? +check "row 27: rm of a cloud row takes the r2 path" "0 1 0 1" "$rc $(mdel e30001 "$tlog") $([[ -e $DRYT/m/e30001 ]] && echo 1 || echo 0) $(grep -c '^unpublished e30001$' <<<"$out")" +check "row 27: the cloud upload is gone" "0" "$(find "$DRYT/o" -path '*/e30001.*' -type f | grep -c .)" +check "row 27: the cloud rm never wrote index.tsv or the Caddyfile" "$idx0 $cad0" "$(md5of "$troot/index.tsv") $(md5of "$troot/Caddyfile")" +: >"$tlog" +out=$(tn org rm e10001 2>&1); rc=$? +check "row 27: rm of a machine row takes the tunnel path plus the pointer DELETE" "0 1 0 0" "$rc $(mdel e10001 "$tlog") $(trow e10001 "$troot" | grep -c .) $([[ -e $troot/pub/e10001 ]] && echo 1 || echo 0)" +# the origin's cloud expiry runs through its own reader: a cloud row expires, index.tsv is never written +out=$(SHARE_TEST_IDS=e40001 tn org add "$tn2" 2>&1) +jq -nc '{v: 1, id: "e00001", name: "f.txt", src: "/elsewhere/f.txt", added: "2026-09-01", expires: 1000, opts: "", prefix: "o/e00001.00000001/", by: "peer"}' >"$DRYT/m/e00001" +mkdir -p "$DRYT/o/e00001.00000001"; printf 'x\n' >"$DRYT/o/e00001.00000001/f.txt" +idx0="$(md5of "$troot/index.tsv")"; cad0="$(md5of "$troot/Caddyfile")" +out=$(tn org ls 2>&1); rc=$? +check "origin ls: an expired cloud record expires" "0 1 0" "$rc $(grep -c '^unpublished e00001 (expired)$' <<<"$out") $([[ -e $DRYT/m/e00001 ]] && echo 1 || echo 0)" +check "origin ls: index.tsv and the Caddyfile are untouched by the cloud expiry" "$idx0 $cad0" "$(md5of "$troot/index.tsv") $(md5of "$troot/Caddyfile")" +check "origin ls: the local row still lists" "1" "$(grep -c 'id=e40001' <<<"$out")" +# a member's prune deletes a machine pointer past its expiry, never a live one +jq -nc '{v: 2, id: "e50001", storage: "machine", name: "old.txt", by: "mac-mini", added: "2026-09-01", expires: 1000, opts: "", type: "text"}' >"$DRYT/m/e50001" +: >"$mlog" +out=$(tm prune 2>&1); rc=$? +check "member prune: the expired pointer goes, the live one stays" "0 1 1 2" "$rc $(grep -c '^removed expired pointer m/e50001$' <<<"$out") $(mdel e50001 "$mlog") $(jq -r .v "$DRYT/m/e40001")" +# row 6: the origin's reconcile on ls +rm -rf "$DRYT" "$troot"; mkdir -p "$DRYT" "$troot" +out=$(SHARE_TEST_IDS=f10001 tn org add "$tn2" 2>&1); rm -f "$DRYT/m/f10001" # a local row with no pointer (an older CLI added it) +out=$(SHARE_TEST_IDS=f10002 tn org add "$tn2" 2>&1) # a local row whose id holds a cloud record (a pre-existing collision) +jq -nc '{v: 1, id: "f10002", name: "x.txt", src: "/x", added: "2026-09-01", expires: 0, opts: "", prefix: "o/f10002.00000002/", by: "peer"}' >"$DRYT/m/f10002" +orphan() { jq -nc --arg id "$1" --arg by "$2" '{v: 2, id: $id, storage: "machine", name: "gone.txt", by: $by, added: "2026-09-01", expires: 0, opts: "", type: "text"}' >"$DRYT/m/$1"; aged "$3" "$DRYT/m/$1"; } +orphan f20001 "$(uname -n | cut -d. -f1 | tr '[:upper:]' '[:lower:]')" 660 +orphan f20002 other-mac 660 +orphan f20003 other-mac 60 +orphan f20004 other-mac 660; printf 'f20004\t-:0000abcd\t0\t-\t%s\n' "$(date +%s)" >>"$troot/access-pending" +orphan f20005 other-mac 660; ln -s "$$" "$troot/.lock-add-f20005" +: >"$tlog" +out=$(tn org ls 2>&1); rc=$? +check "row 6: ls exits 0" "0" "$rc" +check "row 6: both old orphan pointers deleted" "1 1 2" "$(mdel f20001 "$tlog") $(mdel f20002 "$tlog") $(grep -c '^removed orphan pointer m/f2000[12]$' <<<"$out")" +check "row 6: the young, the pending, and the held one kept" "2 2 2 0" "$(jq -r .v "$DRYT/m/f20003") $(jq -r .v "$DRYT/m/f20004") $(jq -r .v "$DRYT/m/f20005") $(grep -c '^DELETE m/f2000[345]$' "$tlog")" +check "row 6: a pointer written for the bare row (If-None-Match, its own added date)" "2 machine note.txt text $(date +%F)" "$(jq -r '"\(.v) \(.storage) \(.name) \(.type) \(.added)"' "$DRYT/m/f10001" 2>/dev/null)" +check "row 6: the shadow is named, nothing written or deleted" "1 1 0 0" "$(grep -c 'f10002 is shadowed by a cloud link; rm one of them' <<<"$out") $(jq -r .v "$DRYT/m/f10002") $(grep -c '^PUT m/f10002$' "$tlog") $(mdel f10002 "$tlog")" +rm -f "$troot/.lock-add-f20005" +# rm and prune of local rows: the pointer goes with a token, stays without one +out=$(SHARE_TEST_IDS=f30001 tn org add "$tn2" 2>&1); out=$(SHARE_TEST_IDS=f30002 tn org add "$tn2" 2>&1) +awk -F'\t' 'BEGIN {OFS = "\t"} $1 == "f30001" || $1 == "f30002" {$5 = 1000} {print}' "$troot/index.tsv" >"$troot/index.tmp" && mv "$troot/index.tmp" "$troot/index.tsv" +out=$(TN_TOK="" TN_CF="" tn org prune 2>&1); rc=$? +check "row 6: prune with no token expires both rows, the pointers stay" "0 0 2 2" "$rc $(trow f30001 "$troot" | grep -c .) $(jq -r .v "$DRYT/m/f30001") $(jq -r .v "$DRYT/m/f30002")" +check "row 6: prune with no token says the cloud side was not checked" "1" "$(grep -c 'were not checked: no publisher token' <<<"$out")" +out=$(SHARE_TEST_IDS=f30003 tn org add "$tn2" 2>&1) +awk -F'\t' 'BEGIN {OFS = "\t"} $1 == "f30003" {$5 = 1000} {print}' "$troot/index.tsv" >"$troot/index.tmp" && mv "$troot/index.tmp" "$troot/index.tsv" +: >"$tlog" +out=$(tn org prune 2>&1); rc=$? +check "row 6: prune with a token deletes the expired row's pointer" "0 1 0" "$rc $(mdel f30003 "$tlog") $([[ -e $DRYT/m/f30003 ]] && echo 1 || echo 0)" +# row 26: pointers never block the orphan sweep, on the origin and on a member +rm -rf "$DRYT" "$troot"; mkdir -p "$DRYT" "$troot" +out=$(SHARE_TEST_IDS=d10001 tn org add "$tn2" 2>&1) +mkdir -p "$DRYT/o/d20001.00000001"; printf 'x\n' >"$DRYT/o/d20001.00000001/f.txt"; aged 90000 "$DRYT/o/d20001.00000001/f.txt" +: >"$tlog" +out=$(tn org prune 2>&1); rc=$? +check "row 26: origin prune with a pointer present sweeps the old upload" "0 1 0" "$rc $(grep -c '^removed orphan upload o/d20001.00000001/$' <<<"$out") $(grep -c 'orphan sweep skipped' <<<"$out")" +mkdir -p "$DRYT/o/d20002.00000002"; printf 'x\n' >"$DRYT/o/d20002.00000002/f.txt"; aged 90000 "$DRYT/o/d20002.00000002/f.txt" +out=$(tm prune 2>&1); rc=$? +check "row 26: member prune with a pointer present sweeps the old upload" "0 1 0" "$rc $(grep -c '^removed orphan upload o/d20002.00000002/$' <<<"$out") $(grep -c 'orphan sweep skipped' <<<"$out")" +printf 'not json\n' >"$DRYT/m/d30001" +mkdir -p "$DRYT/o/d20003.00000003"; printf 'x\n' >"$DRYT/o/d20003.00000003/f.txt"; aged 90000 "$DRYT/o/d20003.00000003/f.txt" +: >"$tlog" +out=$(tn org prune 2>&1); rc=$? +check "row 26: a record that is not JSON skips the sweep and is named" "0 0 1" "$rc $(grep -c '^DELETE o/' "$tlog") $(grep -c 'orphan sweep skipped: m/d30001' <<<"$out")" +rm -f "$DRYT/m/d30001" +# the access sweep decides per id: a stale line of a published gated cloud link keeps its app +uuid="00000000-0000-4000-8000-0000000c0001" +jq -nc --arg u "$uuid" --arg a "$aud64" '{v: 1, id: "c00c01", name: "f.txt", src: "/x", added: "2026-09-01", expires: 0, opts: ("access=" + $u + " access_rule=email:a@example.test"), prefix: "o/c00c01.0000000c/", by: "peer", aud: $a}' >"$DRYT/m/c00c01" +mkdir -p "$troot/.access-dry"; jq -nc --arg u "$uuid" '{id: $u, name: "share c00c01 org.example.test 0000000c", aud: "x"}' >"$troot/.access-dry/$uuid.json" +printf 'c00c01\t%s\t0\t-\t%s\n' "$uuid" "$(date +%s)" >>"$troot/access-pending" +: >"$tlog" +out=$(tn org prune 2>&1); rc=$? +check "sweep: a published gated cloud link's stale line is dropped, its app kept" "0 0 1 0" "$rc $(grep -c "^DELETE app $uuid" "$tlog") $([[ -f $troot/.access-dry/$uuid.json ]] && echo 1 || echo 0) $(grep -c '^c00c01' "$troot/access-pending")" +rm -f "$DRYT/m/c00c01" +# api-token warns 14 days before the publisher token expires +fut() { date -u -v+"$1"d +%Y-%m-%dT%H:%M:%SZ 2>/dev/null || date -u -d "+$1 days" +%Y-%m-%dT%H:%M:%SZ; } +out=$(SHARE_R2_DRY_ROLE=deny SHARE_R2_DRY_EXPIRES="$(fut 5)" tn org api-token --check 2>&1); rc=$? +check "expiry: an origin token five days from expiry warns" "0 1" "$rc $(grep -Ec 'the publisher token expires on [0-9-]{10} \(in [45] days\); then every add to org.example.test fails' <<<"$out")" +out=$(SHARE_R2_DRY_ROLE=deny SHARE_R2_DRY_EXPIRES="$(fut 60)" tn org api-token --check 2>&1); rc=$? +check "expiry: sixty days out says nothing" "0 0" "$rc $(grep -c 'the publisher token expire' <<<"$out")" +out=$(SHARE_R2_DRY_ROLE=deny SHARE_R2_DRY_EXPIRES="2026-01-01T00:00:00Z" tm api-token --check 2>&1); rc=$? +check "expiry: a member token already expired says so" "0 1" "$rc $(grep -c 'the publisher token expired on 2026-01-01' <<<"$out")" + echo "=== worker (tests/worker.mjs) ===" if command -v node >/dev/null; then wout="$(node "$(dirname "$SH")/../tests/worker.mjs" 2>&1)"; wrc=$? From b26797d7fa00244c8927330e20240460f87c0cb2 Mon Sep 17 00:00:00 2001 From: Han Ngo Date: Thu, 1 Oct 2026 18:40:30 +0700 Subject: [PATCH 10/55] docs: record the dispatch and reconcile proof for the tenant spec TASK-2b ticked; deltas logged (member hits keeps SPEC-007 row 13, the add lock, the reader). Negative controls for rows 3 and 26 ran red then green on tree 25dd9e9. --- .../one-host-per-tenant.md | 13 ++++++++++ docs/specs/SPEC-008-one-host-per-tenant.md | 2 +- docs/verification/one-host-per-tenant.md | 25 +++++++++++++++++++ 3 files changed, 39 insertions(+), 1 deletion(-) diff --git a/docs/implementation-notes/one-host-per-tenant.md b/docs/implementation-notes/one-host-per-tenant.md index 6937c8c..cb593ee 100644 --- a/docs/implementation-notes/one-host-per-tenant.md +++ b/docs/implementation-notes/one-host-per-tenant.md @@ -121,3 +121,16 @@ Deviations from the spec, per task. A task with none is not listed. - `api-token` on an R2-on origin: `--cmd`, the paste, and `--check` run `r2_publisher_check`; the paste names the bucket scope beside the prefilled Access form; `access_preflight` stays required. Not built yet: the 14-day `expires_on` warning (round-1 warning), left for TASK-11a's token work. - Test seams: `SHARE_R2_DRY_PUT=500` (every PUT answers 500), `SHARE_R2_DRY_PUT=race-once` (the first conditional PUT of an `m/` key finds a peer's record written just before it, so `If-None-Match: *` answers 412), and `SHARE_R2_DRY_PAUSE` now drops `.paused` in the dry bucket, so row 4 inspects `pub/` while the pointer PUT is held instead of racing a watcher. - Left for TASK-2b: on the origin, `rm`, `refresh`, and `hits` of a cloud id still read the local index only (`no share with id`). + +### TASK-2b (dispatch, reconcile, sweep, origin cloud expiry) + +- Fixes TASK-2a's leftover: on an origin with R2 on, `rm`, `refresh`, and `hits` of an id with no local row take the r2 path. A local row wins on a shadowed id. +- The bucket reader is `r2_read`: one mktemp file per process (`r2_snap_file`), read through `crows`; it never assigns or writes `$index`. `r2_snapshot` (members) is `r2_read` plus the old `index` rebind. `cmd_rm_r2` and `r2_prune` read and rewrite the reader's file only. Keys outside `^m/[0-9a-f]{6}$` are dropped before any GET, on members too. +- `hits` on a member does not refuse a machine row. SPEC-007 row 13 pins member `hits` to one account call and no bucket read, and a refusal needs a `GET m/`. The spec test wins over the round-2 build rule; a member's `hits` of a machine id prints the Analytics count (0: the Worker counts no pass-through). The origin's `hits` of a cloud id reads `m/` first, as row 27 asks. +- A member's `rm` and `refresh` of a machine row die after the read that identifies the row (the snapshot for `rm`, `GET m/` for `refresh`) and before any write. +- No add lock existed. `r2_pointer_put` now takes `.lock-add-` before the pointer PUT; `cmd_add` releases it after the row write, the EXIT trap on a die. The reconcile skips an id whose lock names a live pid. +- The origin's interactive pass runs in a subshell before the local expiry: cloud expiry, the orphan sweep (bare prune only, as SPEC-007), then the reconcile. A bucket failure prints one line and the local expiry still runs. The reconcile skips expired local rows (the local prune's) and prints its shadow and backfill lines on stderr. `access_sweep` runs once, in `cmd_prune`. +- `r2_record_raw` reads the cloud shape as `v:1` exactly instead of `v <= WORKER_RECORD_V`, so TASK-3's bump to 2 keeps a `v:2` record without `storage:"machine"` unreadable for the sweep. +- A member's bare prune and `ls` delete a machine pointer whose `expires` is past (pointer rule 3). +- The 14-day warning: `api-token` (`--check`, the paste, `--cmd`) on an r2 member and on an R2-on origin reads `expires_on` from `/user/tokens/verify` and warns at 14 days or fewer, or once expired. Not on every add (`r2_key_id` in the config skips the verify call there). Test seam: `SHARE_R2_DRY_EXPIRES`. + diff --git a/docs/specs/SPEC-008-one-host-per-tenant.md b/docs/specs/SPEC-008-one-host-per-tenant.md index e548f5f..f9f369a 100644 --- a/docs/specs/SPEC-008-one-host-per-tenant.md +++ b/docs/specs/SPEC-008-one-host-per-tenant.md @@ -383,7 +383,7 @@ A tunnel profile with no `bucket=` (every R2-off tenant) keeps every file, outpu - [x] TASK-1a: edge spike on a throwaway hostname, one Worker, one tunnel, one Access app; answers in `docs/implementation-notes/one-host-per-tenant.md`. (a) A route Worker's `fetch(request)` and a `fetch` of `https:///healthz` reach the tunnel origin without running the Worker again. (b) Access answers an unauthenticated request to a gated path with its 302 before the route Worker runs; an admitted request reaches the Worker with `Cf-Access-Jwt-Assertion`. (c) The pass-through of an admitted request reaches the origin. (d) A POST and a WebSocket upgrade pass through to a live dev server. (e) The status codes a pass-through sees with the connector down, and a header that tells Caddy's own 502 apart. AC: each answered; (a) or (b) failing blocks TASK-3 and amends `### Approaches considered`. - [x] TASK-1b: account spike with throwaway tokens and names. (f) Rebinding an existing custom domain to another Worker: in place, or DELETE then PUT, and the gap. (g) `PUT access/apps/` with added destinations keeps the AUD. (h) Whether `op://Toolkit/cf-api-token` holds Workers Routes Edit and Access Apps Edit on the Dwarves account. (i) Whether it edits tunnels and DNS on the personal account. (k) The Workers plan on each account and the route's failure mode at the request limit. AC: each answered, the token table amended to the source each role uses. - [x] TASK-2a: per-link storage on the origin: config keys, `--cloud`, `--local`, their refusals, `rand_id` across both legs, the pointer write and its delete on `rm` and `prune`. Depends on TASK-1a. AC: rows 2, 4, 5. -- [ ] TASK-2b: storage dispatch for `rm`, `refresh`, `hits` with the member refusals, the reconcile, the sweep rule for pointers, and the origin's cloud expiry through its own reader. Depends on TASK-2a. AC: rows 3, 6, 26, 27. +- [x] TASK-2b: storage dispatch for `rm`, `refresh`, `hits` with the member refusals, the reconcile, the sweep rule for pointers, and the origin's cloud expiry through its own reader. Depends on TASK-2a. AC: rows 3, 6, 26, 27. - [ ] TASK-3: Worker v3 (`ALIASES`, `PASS`, two-leg `/healthz`, the record branches, the rewrap, the offline page, the method rule, `WORKER_RECORD_V` 2), `r2_healthz`'s header reading, and `tests/worker.mjs` with a stub whose responses hold immutable headers. Depends on TASK-1a. AC: rows 7 to 10, 29. - [ ] TASK-4: `setup --r2` and `--no-r2` without `--alias` (steps 1 to 3, 5, 6, 8, 9, 11, 13, 15) and the rerun convergence. Depends on TASK-2a, TASK-3, TASK-1b(h). AC: rows 11, 12, 13, 32. - [ ] TASK-5: `--alias` (steps 4, 7, 10, 12, 14, 16), the `--no-r2` alias refusal, the printed rollback list. Depends on TASK-4 and TASK-1b(f)(g). AC: rows 14, 30. diff --git a/docs/verification/one-host-per-tenant.md b/docs/verification/one-host-per-tenant.md index e78410c..59d63f9 100644 --- a/docs/verification/one-host-per-tenant.md +++ b/docs/verification/one-host-per-tenant.md @@ -43,3 +43,28 @@ Dry trace for the first control: the row 4 run holds the add at `PUT m/c00001` t ## Live spikes TASK-1a and TASK-1b ran against throwaway Cloudflare objects only (names `share-e2e-spk584462` and `share-e2e-spk13b60d`). Every object was deleted and checked from outside: the API listed none of them on either account afterwards, the minted token's id answered 404, and each zone's authoritative server answered NXDOMAIN for both names. + +# Batch 2 + +Batch 2 is TASK-2b, TASK-3, and TASK-4, each committed and checked on its own. + +## TASK-2b: dispatch, reconcile, sweep (rows 3, 6, 26, 27) + +``` +Command: SHARE_TEST_PORT_BASE=28787 gtimeout 900 bash tests/share.sh +Exit: 0 +Checks: 1151 ok, 0 FAIL, 0 SKIP (PASS), at 6df0318 +Section: === tenant: storage dispatch, member refusals, reconcile, sweep (rows 3, 6, 26, 27) ===, 39 checks +Verdict: PASS +``` + +`node tests/worker.mjs` PASS, and `shellcheck bin/share install.sh tests/share.sh tests/e2e.sh tests/e2e-r2.sh demo/render.sh mac/*.sh` clean, on the same tree. + +Negative controls: each patch went into a copy of tree `25dd9e9` (commit 6df0318), the tenant sections ran alone, then the unpatched copy ran green (0 FAIL). + +| Patch | Red rows | +|---|---| +| let a member `rm` a machine row (the refusal in `cmd_rm_r2` becomes a `DELETE m/`) | row 3 "member rm of a machine row is refused" (got `0 0`), "no DELETE m/, the pointer stays" (got `1`), "member refresh ... refused" (got `1 0 0`: the pointer was gone, so the message changed), row 27's machine rm (no pointer left to delete); `fails=4` | +| let the sweep count a machine pointer as unreadable (`r2_record_raw`'s machine branch raises) | row 26 origin and member sweeps (got `0 0 1`: skipped, warning printed), the not-JSON leg (got `0 0 0`: the warning named the pointer instead); `fails=3` | + +Dry trace for the first control: the member's `rm e10001` reads the snapshot, finds `e10001` in `r2_machine`, and with the patch logs `DELETE m/e10001`; the check reads one DELETE line in the member's `r2-calls.log` where it wants none. From 2fcbdb11b20550fcb2c400f67abe7d9d2865a269 Mon Sep 17 00:00:00 2001 From: Han Ngo Date: Thu, 1 Oct 2026 19:09:06 +0700 Subject: [PATCH 11/55] feat: tenant Worker v3 in front of the tunnel With PASS set the Worker runs as a route on the tenant host: a v:1 cloud record serves from R2 as before, a v:2 machine pointer, a miss, a bucket error or a 2 s stall pass through to the origin, and any other record answers 404. The pass-through rewraps the origin's answer, returns a 101 untouched, and answers edge errors (502, 520 to 527, 530, a 503 without cf-cache-status, a thrown fetch) with a 503 offline page. An ALIASES host answers 301 to the same path on HOST. /healthz probes the origin and sets X-Share-Tunnel. r2_healthz reads a 503 with a sound pair and X-Share-Tunnel: 0 as Worker up. WORKER_VERSION 3, WORKER_RECORD_V 2. Suite: 1211 ok, 0 FAIL; worker.mjs PASS. --- bin/share | 122 ++++++++++++++++++++++++---------- tests/share.sh | 22 ++++++ tests/worker.mjs | 170 ++++++++++++++++++++++++++++++++++++++++++++++- 3 files changed, 279 insertions(+), 35 deletions(-) diff --git a/bin/share b/bin/share index 8ed2126..a7dd288 100755 --- a/bin/share +++ b/bin/share @@ -2146,17 +2146,18 @@ access_open_url() { # the platform opener, backgrounded; skipped over ssh or wit # port=r2 sentinel: an older share dies on that port at load, so it can never # write pub/ or an Access app against an r2 profile. -WORKER_VERSION=2 # bump on every Worker source change; deploys refuse a downgrade without --force -WORKER_SHA=fdae03ca85c7 # first 12 hex of the emitted source's sha256; the suite fails when it drifts +WORKER_VERSION=3 # bump on every Worker source change; deploys refuse a downgrade without --force +WORKER_SHA=ed4784bd9005 # first 12 hex of the emitted source's sha256; the suite fails when it drifts WORKER_COMPAT_DATE=2026-09-30 # moves only with a WORKER_VERSION bump -WORKER_RECORD_V=1 # the record schema this source understands; a newer record answers 404 +WORKER_RECORD_V=2 # the record schema this source understands: v:1 cloud records, v:2 machine pointers; a newer record answers 404 worker_meta() { printf '%s\n' "$WORKER_VERSION" "$WORKER_SHA" "$WORKER_COMPAT_DATE" "$WORKER_RECORD_V"; } # one line, four fields, for the deploy and sweep paths worker_js() { # worker_js : the Worker source to $1; never a heredoc inside $( ) (macOS /bin/bash 3.2) cat >"$1" <<'SHARE_WORKER_JS' // share Worker: serves R2-backed snapshots on one hostname, a gated record only -// inside a valid Cloudflare Access session for that record's app. -const RECV = 1; +// inside a valid Cloudflare Access session for that record's app. With PASS set (a tenant +// with R2 on) it runs as a route in front of the tenant's tunnel: a cloud record is served +// from R2, a machine pointer or a miss passes through to the origin, and an alias host 301s. const TYPES = { html: "text/html", css: "text/css", js: "text/javascript", mjs: "text/javascript", json: "application/json", txt: "text/plain", md: "text/markdown", csv: "text/csv", @@ -2229,31 +2230,73 @@ const hex = async (s) => { return [...d].map((b) => b.toString(16).padStart(2, "0")).join(""); }; +const passed = new WeakSet(); // answers from the origin: no hit counted, headers already set +const OFFLINE = "the machine serving this link is offline; try again later"; + +async function pass(request) { // the route is skipped for a same-host fetch, so this reaches the tunnel (spike TASK-1a) + let r; + try { r = await fetch(request); } catch { r = null; } + if (r && r.status === 101) { passed.add(r); return r; } // a WebSocket upgrade goes back untouched + // an edge error carries no cf-cache-status; the origin's own answers do (spike TASK-1a(e)) + const down = !r || r.status === 502 || r.status === 530 || (r.status >= 520 && r.status <= 527) || + (r.status === 503 && !r.headers.has("cf-cache-status")); + const out = down + ? new Response(OFFLINE, { status: 503, headers: { "Retry-After": "60", "Content-Type": "text/plain; charset=utf-8" } }) + : new Response(r.body, r); // rewrapped: a fetch answer's headers are immutable + out.headers.set("Cache-Control", "no-store"); + out.headers.set("X-Robots-Tag", "noindex, nofollow"); + passed.add(out); + return out; +} + +const capped = (p, ms) => { // a bucket read that hangs counts as a failed one + let t; + return Promise.race([p, new Promise((_, rej) => { t = setTimeout(() => rej(new Error("slow")), ms); })]).finally(() => clearTimeout(t)); +}; + async function handle(request, env) { const u = new URL(request.url); + const tail = request.url.slice(request.url.indexOf(u.hostname) + u.hostname.length).replace(/^:\d+/, ""); + const getish = request.method === "GET" || request.method === "HEAD"; + const aliases = (env.ALIASES || "").split(",").map((a) => a.trim()).filter(Boolean); + if (aliases.includes(u.hostname)) { + if (!getish) return new Response(null, { status: 405 }); + return new Response(null, { status: 301, headers: { Location: "https://" + env.HOST + tail.split("#")[0] } }); + } if (u.hostname !== env.HOST) return new Response(null, { status: 404 }); - if (request.method !== "GET" && request.method !== "HEAD") return new Response(null, { status: 405 }); - const raw = request.url.slice(request.url.indexOf(u.hostname) + u.hostname.length).replace(/^:\d+/, "").split(/[?#]/)[0] || "/"; + if (!env.PASS && !getish) return new Response(null, { status: 405 }); + const raw = tail.split(/[?#]/)[0] || "/"; if (/%2f|%5c|%2e|%0[0-9a-f]|%1[0-9a-f]/i.test(raw) || raw.includes("//")) { return new Response(null, { status: 400 }); } if (raw === "/healthz") { - return new Response("ok", { - status: 200, - headers: { - "X-Share-Worker": (env.VERSION || "") + " " + (env.SHA || ""), - "X-Share-Gate": env.TEAM ? "1" : "0", - }, - }); + if (!getish) return new Response(null, { status: 405 }); + const headers = { + "X-Share-Worker": (env.VERSION || "") + " " + (env.SHA || ""), + "X-Share-Gate": env.TEAM ? "1" : "0", + }; + if (!env.PASS) return new Response("ok", { status: 200, headers }); + let up = false; // the tunnel leg: the origin's own /healthz, through the same skipped route + try { + const r = await fetch("https://" + env.HOST + "/healthz", { signal: AbortSignal.timeout(3000) }); + up = r.status === 200 && (await r.text()).trim() === "ok"; + } catch { up = false; } + headers["X-Share-Tunnel"] = up ? "1" : "0"; + return new Response(up ? "ok" : "tunnel down", { status: up ? 200 : 503, headers }); } const segs = u.pathname.split("/"); const id = segs[1]; - if (!/^[0-9a-f]{6}$/.test(id)) return new Response(null, { status: 404 }); - const mo = await env.BUCKET.get("m/" + id); - if (mo === null) return new Response(null, { status: 404 }); + if (!/^[0-9a-f]{6}$/.test(id)) return env.PASS ? pass(request) : new Response(null, { status: 404 }); + let mo; + try { mo = await capped(env.BUCKET.get("m/" + id), 2000); } catch { mo = null; } + if (mo === null) return env.PASS ? pass(request) : new Response(null, { status: 404 }); let rec; try { rec = await mo.json(); } catch { return new Response(null, { status: 404 }); } - if (!rec || rec.v > RECV || rec.id !== id || typeof rec.prefix !== "string" || + if (rec && typeof rec === "object" && rec.v === 2 && rec.storage === "machine") { + return env.PASS ? pass(request) : new Response(null, { status: 404 }); + } + // a cloud record is v:1 with no storage key; any other shape answers 404 and never reaches the origin + if (!rec || typeof rec !== "object" || rec.v !== 1 || "storage" in rec || rec.id !== id || typeof rec.prefix !== "string" || !new RegExp("^o/" + id + "\\.[0-9a-f]{8}/$").test(rec.prefix) || (rec.expires > 0 && Date.now() / 1000 > rec.expires)) { return new Response(null, { status: 404 }); @@ -2263,6 +2306,7 @@ async function handle(request, env) { try { ok = await gatedOk(request, env, rec); } catch { ok = false; } if (!ok) return new Response(null, { status: 404 }); } + if (!getish) return new Response(null, { status: 405 }); const rest = segs.slice(2); if (rest.length === 0) return new Response(null, { status: 308, headers: { Location: u.origin + "/" + id + "/" } }); const cond = { range: request.headers, onlyIf: request.headers }; @@ -2313,6 +2357,7 @@ export default { let resp; try { resp = await handle(request, env); } catch { resp = new Response(null, { status: 404 }); } + if (passed.has(resp)) return resp; resp.headers.set("Cache-Control", "no-store"); resp.headers.set("X-Robots-Tag", "noindex, nofollow"); if (resp.status < 400 && url.pathname !== "/healthz" && @@ -3129,27 +3174,36 @@ r2_api_dry() { # the account API under SHARE_R2_DRY: state files in $SHARE_R2_DR cf_err="$(jq -r '.errors[0].code // empty' <<<"$cf_body" 2>/dev/null || true)" } -r2_healthz() { # r2_healthz [secs]: hz_code (200 only for a "200 ok" body), hz_pair (X-Share-Worker), hz_gate (X-Share-Gate: 1 when TEAM is set) from https:///healthz - hz_code=000 hz_pair="" hz_gate=0 - if [[ ${SHARE_R2_DRY:-0} == 1 ]]; then - local d="${SHARE_R2_DRY_DIR:-$root/r2-dry}/.cf" - printf 'HEALTHZ\n' >>"$root/r2-calls.log" - [[ ${SHARE_R2_DRY_HEALTHZ:-} != down && -f $d/domain.json && -f $d/script.json ]] || return 0 - hz_code=200 - hz_pair="$(jq -r '[(.bindings[] | select(.name == "VERSION") | .text), (.bindings[] | select(.name == "SHA") | .text)] | join(" ")' "$d/script.json")" - hz_gate="$(jq -r 'if any(.bindings[]; .name == "TEAM" and (.text // "") != "") then 1 else 0 end' "$d/script.json")" - return 0 - fi +r2_healthz() { # r2_healthz [secs]: hz_code (200 only for a "200 ok" body), hz_pair (X-Share-Worker), hz_gate (X-Share-Gate: 1 when TEAM is set), hz_tunnel (X-Share-Tunnel) from https:///healthz + # a tenant Worker answers 503 with its pair and X-Share-Tunnel: 0 while the origin is off: the Worker is up, so hz_code is 200 and hz_tunnel 0 + hz_code=000 hz_pair="" hz_gate=0 hz_tunnel="" local h b h="$(mktemp "${TMPDIR:-/tmp}/share-hz.XXXXXX")" - # DNS over HTTPS first, as live_check does: a new name's NXDOMAIN from before its record existed sits in the local cache - b="$(curl -sS --max-time "${1:-5}" --doh-url https://1.1.1.1/dns-query -D "$h" "https://$host_name/healthz" 2>/dev/null)" || - b="$(curl -sS --max-time "${1:-5}" -D "$h" "https://$host_name/healthz" 2>/dev/null)" || true - hz_code="$(awk 'NR == 1 {print $2}' "$h")"; hz_code="${hz_code:-000}" - [[ $hz_code != 200 || $b == ok ]] || hz_code=000 + if [[ ${SHARE_R2_DRY:-0} == 1 ]]; then # the dry Worker's answer as a header file and a body, read by the same lines below + local d="${SHARE_R2_DRY_DIR:-$root/r2-dry}/.cf" pair gate + printf 'HEALTHZ\n' >>"$root/r2-calls.log" + if [[ ${SHARE_R2_DRY_HEALTHZ:-} != down && -f $d/domain.json && -f $d/script.json ]]; then + pair="$(jq -r '[(.bindings[] | select(.name == "VERSION") | .text), (.bindings[] | select(.name == "SHA") | .text)] | join(" ")' "$d/script.json")" + gate="$(jq -r 'if any(.bindings[]; .name == "TEAM" and (.text // "") != "") then 1 else 0 end' "$d/script.json")" + case ${SHARE_R2_DRY_HEALTHZ:-} in + tunnel-down) printf 'HTTP/2 503 \r\nx-share-worker: %s\r\nx-share-gate: %s\r\nx-share-tunnel: 0\r\n\r\n' "$pair" "$gate" >"$h"; b="tunnel down" ;; + 503-bare) printf 'HTTP/2 503 \r\n\r\n' >"$h"; b="" ;; + *) printf 'HTTP/2 200 \r\nx-share-worker: %s\r\nx-share-gate: %s\r\n\r\n' "$pair" "$gate" >"$h"; b="ok" ;; + esac + fi + else + # DNS over HTTPS first, as live_check does: a new name's NXDOMAIN from before its record existed sits in the local cache + b="$(curl -sS --max-time "${1:-5}" --doh-url https://1.1.1.1/dns-query -D "$h" "https://$host_name/healthz" 2>/dev/null)" || + b="$(curl -sS --max-time "${1:-5}" -D "$h" "https://$host_name/healthz" 2>/dev/null)" || true + fi + hz_code="$(awk 'NR == 1 {sub(/\r$/, ""); print $2}' "$h")"; hz_code="${hz_code:-000}" hz_pair="$(awk 'tolower($1) == "x-share-worker:" {sub(/\r$/, ""); print $2 " " $3; exit}' "$h")" [[ "$(awk 'tolower($1) == "x-share-gate:" {sub(/\r$/, ""); print $2; exit}' "$h")" == 1 ]] && hz_gate=1 + hz_tunnel="$(awk 'tolower($1) == "x-share-tunnel:" {sub(/\r$/, ""); print $2; exit}' "$h")" rm -f "$h" + if [[ $hz_code == 503 && $hz_tunnel == 0 && $hz_pair =~ ^[0-9]+\ [0-9a-f]{12}$ ]]; then hz_code=200 # read from the headers, never the status alone + elif [[ $hz_code == 200 && ${b:-} != ok ]]; then hz_code=000; fi + return 0 } r2_wait_healthz() { # r2_wait_healthz admin|join: three 200s in a row within SHARE_R2_WAIT; admin also needs this CLI's pair; a new name waits for its certificate diff --git a/tests/share.sh b/tests/share.sh index 6281e67..d7de85f 100755 --- a/tests/share.sh +++ b/tests/share.sh @@ -2991,6 +2991,28 @@ check "expiry: sixty days out says nothing" "0 0" "$rc $(grep -c 'the publisher out=$(SHARE_R2_DRY_ROLE=deny SHARE_R2_DRY_EXPIRES="2026-01-01T00:00:00Z" tm api-token --check 2>&1); rc=$? check "expiry: a member token already expired says so" "0 1" "$rc $(grep -c 'the publisher token expired on 2026-01-01' <<<"$out")" +echo "=== tenant: the Worker is up while the origin is off (row 29) ===" +# the dry Worker answers 503 with this CLI's pair and X-Share-Tunnel: 0; r2_healthz reads the headers, never the status alone +rm -rf "$DRYA"; mkdir -p "$DRYA/.cf"; rm -f "$r2root/r2-own" +printf '{"hostname":"r2x.example.test","service":"share-r2x-example-test"}\n' >"$DRYA/.cf/domain.json" +gteam team.cloudflareaccess.com +for mode in tunnel-down 503-bare; do + out=$(SHARE_R2_DRY_HEALTHZ=$mode r2a state 2>/dev/null); rc=$? + check "row 29 ($mode): state" "$([[ $mode == tunnel-down ]] && echo '0 true' || echo '0 false')" "$rc $(jq -r .ready <<<"$out")" + greset; printf 'pass\npass\npass\n' >"$gfix" + out=$(SHARE_R2_DRY_HEALTHZ=$mode SHARE_TEST_IDS=ab2901 r2g add --access email:A@x.io "$WORK/g.txt" 2>&1); rc=$? + if [[ $mode == tunnel-down ]]; then + check "row 29 (tunnel-down): a member's gated add publishes" "0 https://r2x.example.test/ab2901/g.txt 1" "$rc $(grep '^https://' <<<"$out") $(jq -r .v "$DRYA/m/ab2901" 2>/dev/null)" + else + check "row 29 (503-bare): the gated add fails before any write" "1 1 0" "$rc $(grep -c 'healthz answered 503; nothing was published' <<<"$out") $(grep -c '^PUT' "$rlog")" + fi + s_fresh; echo dwarves.cloudflareaccess.com >"$DRYS/.cf/team"; out=$(r2s 2>&1) + rm -f "$jconf"; mkdir -p "${jlog%/*}"; : >"$jlog" + out=$(r2j SHARE_R2_DRY_HEALTHZ=$mode SHARE_R2_WAIT=1 2>&1); rc=$? + check "row 29 ($mode): a member joins" "$([[ $mode == tunnel-down ]] && echo '0 1' || echo '1 0')" "$rc $([[ -f $jconf ]] && echo 1 || echo 0)" +done +rm -f "$jconf" "$s2conf" + echo "=== worker (tests/worker.mjs) ===" if command -v node >/dev/null; then wout="$(node "$(dirname "$SH")/../tests/worker.mjs" 2>&1)"; wrc=$? diff --git a/tests/worker.mjs b/tests/worker.mjs index 16d4f98..a849d2c 100644 --- a/tests/worker.mjs +++ b/tests/worker.mjs @@ -1,7 +1,8 @@ // tests/worker.mjs: unit-drive the share Worker emitted from bin/share against // in-memory bindings. Prints ok/FAIL lines like tests/share.sh; exit 1 on any FAIL. // Rows covered: 17 (serving shapes), 18 (path/record/Host refusals), 19 (hits), -// 28 (the gated-record JWT check), plus the WORKER_SHA/WORKER_VERSION self-checks +// 28 (the gated-record JWT check), the tenant rows 7 to 10 (PASS, ALIASES, the pass-through, the two-leg +// /healthz, against a stubbed origin fetch whose answers hold immutable headers), plus the WORKER_SHA/WORKER_VERSION self-checks // and `node --check` on the emitted source. // Integration mode, `node tests/worker.mjs --dir --host ...`: // the same Worker over a BUCKET loaded from a SHARE_R2_DRY_DIR, one " " line per path. @@ -307,5 +308,172 @@ const gated = async (worker, env, headers = {}, path = `/${ID}/f.txt`) => check("r28 malformed record aud", 404, await gated(w4, e4, await good())); } +// --- tenant rows 7 to 10: PASS (a route in front of the tunnel), ALIASES, the two-leg /healthz --- +// the stub stands in for the origin: it records every fetch and answers from a fixture whose headers are immutable, as a real fetch answer's are +const frozen = (resp) => { + const h = resp.headers; + const ro = new Proxy(h, { get(t, k) { + if (k === "set" || k === "append" || k === "delete") return () => { throw new TypeError("immutable headers"); }; + const v = Reflect.get(t, k); return typeof v === "function" ? v.bind(t) : v; + } }); + Object.defineProperty(resp, "headers", { value: ro }); + return resp; +}; +let seen = [], answer = () => frozen(new Response("from origin", { status: 200, headers: { "cf-cache-status": "DYNAMIC" } })); +const stubOrigin = () => { + globalThis.fetch = async (req, init = {}) => { + const r = typeof req === "string" ? new Request(req, init) : req; + seen.push({ url: r.url, method: r.method, body: ["GET", "HEAD"].includes(r.method) ? "" : await r.clone().text(), upgrade: r.headers.get("upgrade") || "", signal: !!init.signal }); + return answer(r, init); + }; +}; +const tenantEnv = (bucket, over = {}) => envOf(bucket, { HOST: "s.test", PASS: "1", ALIASES: "", ...over }); +const tget = async (worker, env, path, opts = {}, host = "s.test") => call(worker, env, `https://${host}${path}`, opts); +const nsni = (r) => `${r.headers.get("cache-control")}|${r.headers.get("x-robots-tag")}`; +const MID = "b1c2d3"; +stubOrigin(); +{ + const worker = await loadWorker(); + const b = new Bucket(), env = tenantEnv(b); + putRec(b); b.put(`o/${ID}.${NONCE}/f.txt`, "cloud bytes"); + b.put("m/" + MID, JSON.stringify({ v: 2, id: MID, storage: "machine", name: "f.txt", by: "mac-mini", added: "2026-10-01", expires: 0, opts: "", type: "text" })); + seen = []; + let r = await tget(worker, env, `/${ID}/f.txt`); + check("r7 a cloud record serves R2 bytes, no pass-through", "200 cloud bytes 0", `${r.status} ${await bodyOf(r)} ${seen.length}`); + check("r7 the cloud answer carries no-store and noindex", "no-store|noindex, nofollow", nsni(r)); + const through = async (label, path) => { + seen = []; + const resp = await tget(worker, env, path); + check(`r7 ${label} passes through`, `200 from origin 1 https://s.test${path}`, `${resp.status} ${await bodyOf(resp)} ${seen.length} ${(seen[0] || {}).url}`); + check(`r7 ${label}: no-store and noindex on the rewrapped answer`, "no-store|noindex, nofollow", nsni(resp)); + }; + await through("a machine record", `/${MID}/f.txt`); + await through("no record", "/c0ffee/f.txt"); + await through("a non-hex first segment", "/assets/app.js"); + await through("the root", "/"); + const thrower = new Bucket(); thrower.get = async () => { throw new Error("R2 down"); }; + const et = tenantEnv(thrower); + seen = []; + r = await tget(worker, et, `/${ID}/f.txt`); + check("r7 the bucket throws: passes through", "200 1", `${r.status} ${seen.length}`); + const expired = new Bucket(), ee = tenantEnv(expired); + expired.put("m/" + ID, JSON.stringify(rec({ expires: 1 }))); expired.put(`o/${ID}.${NONCE}/f.txt`, "stale"); + seen = []; + r = await tget(worker, ee, `/${ID}/f.txt`); + check("r7 an expired cloud record is 404, never passed through", "404 0", `${r.status} ${seen.length}`); + const other = new Bucket(), eo = tenantEnv(other); + other.put("m/" + ID, JSON.stringify(rec({ prefix: `o/ffffff.${NONCE}/` }))); other.put(`o/ffffff.${NONCE}/f.txt`, "theirs"); + seen = []; + r = await tget(worker, eo, `/${ID}/f.txt`); + check("r7 a prefix naming another id is 404, never passed through", "404 0", `${r.status} ${seen.length}`); + for (const [label, body] of [["not JSON", "{"], ["v:3", JSON.stringify(rec({ v: 3 }))], ["v:2 cloud storage", JSON.stringify(rec({ v: 2, storage: "cloud" }))], + ["v:1 with a storage key", JSON.stringify(rec({ storage: "machine" }))]]) { + const ob = new Bucket(), oe = tenantEnv(ob); ob.put("m/" + ID, body); ob.put(`o/${ID}.${NONCE}/f.txt`, "x"); + seen = []; + r = await tget(worker, oe, `/${ID}/f.txt`); + check(`r7 ${label} is 404, never passed through`, "404 0", `${r.status} ${seen.length}`); + } + r = await tget(worker, env, `/${ID}/f.txt`, { method: "POST", body: "x" }); + check("r7 a cloud record refuses other methods", 405, r.status); + seen = []; + for (const p of [`/x/..%2F${MID}/f.txt`, `//${MID}/f.txt`, `/%2F${MID}/f.txt`, `/${MID}/a%2Ehtml`]) { + r = await tget(worker, env, p); + check(`r7 ${p} is 400 in front of the tunnel`, 400, r.status); + } + check("r7 no encoded path reached the origin", 0, seen.length); +} + +// --- row 8: what the pass-through hands back --- +{ + const worker = await loadWorker(); + const b = new Bucket(), env = tenantEnv(b); + b.put("m/" + MID, JSON.stringify({ v: 2, id: MID, storage: "machine", name: "f.txt", by: "mac-mini", added: "2026-10-01", expires: 0, opts: "live", type: "site" })); + const via = async (status, headers, body = "origin says") => { + answer = () => frozen(new Response(status === 204 ? null : body, { status, headers })); + seen = []; + return tget(worker, env, `/${MID}/f.txt`); + }; + let r = await via(200, { "cf-cache-status": "DYNAMIC", "x-origin": "1" }, "hello"); + check("r8 200 with a body goes back unchanged", "200 hello 1", `${r.status} ${await bodyOf(r)} ${r.headers.get("x-origin")}`); + r = await via(404, { "cf-cache-status": "DYNAMIC" }, "nope"); + check("r8 the origin's 404 goes back unchanged", "404 nope", `${r.status} ${await bodyOf(r)}`); + r = await via(503, { "cf-cache-status": "DYNAMIC" }, "caddy 503"); + check("r8 the origin's own 503 (cf-cache-status) goes back unchanged", "503 caddy 503", `${r.status} ${await bodyOf(r)}`); + const offline = async (label, resp) => { + check(`r8 ${label}: the 503 offline page`, "503 60 the machine serving this link is offline; try again later", + `${resp.status} ${resp.headers.get("retry-after")} ${await bodyOf(resp)}`); + check(`r8 ${label}: no-store and noindex`, "no-store|noindex, nofollow", nsni(resp)); + }; + await offline("530", await via(530, {}, "error code: 1033")); + await offline("502", await via(502, { server: "cloudflare" }, "502")); + await offline("an edge 503 (no cf-cache-status)", await via(503, {}, "edge")); + await offline("522", await via(522, {}, "timeout")); + answer = () => { throw new Error("connect failed"); }; + seen = []; + await offline("a thrown fetch", await tget(worker, env, `/${MID}/f.txt`)); + answer = () => frozen(new Response("posted", { status: 200, headers: { "cf-cache-status": "DYNAMIC" } })); + seen = []; + r = await tget(worker, env, `/${MID}/api`, { method: "POST", body: "payload-123" }); + check("r8 a POST is forwarded with its method and body", "200 POST payload-123", `${r.status} ${(seen[0] || {}).method} ${(seen[0] || {}).body}`); + const ws = { status: 101, webSocket: {}, headers: new Headers({ upgrade: "websocket" }) }; + answer = () => ws; + seen = []; + r = await worker.fetch(new Request(`https://s.test/${MID}/ws`, { headers: { upgrade: "websocket" } }), env, ctxOf()); + check("r8 the upgrade is forwarded and its 101 returned untouched", "true websocket", `${r === ws} ${(seen[0] || {}).upgrade}`); + check("r8 no Analytics Engine data point for any pass-through", 0, env.HITS.points.length); +} + +// --- row 9: aliases and a PASS-less Worker --- +{ + const worker = await loadWorker(); + const b = new Bucket(), env = tenantEnv(b, { ALIASES: "f.test" }); + seen = []; + let r = await tget(worker, env, "/ba6377/g/?a=1", {}, "f.test"); + check("r9 GET on the alias 301s to the same path and query on HOST", "301 https://s.test/ba6377/g/?a=1", `${r.status} ${r.headers.get("location")}`); + r = await tget(worker, env, "/ba6377/g/?a=1", { method: "HEAD" }, "f.test"); + check("r9 HEAD on the alias 301s", 301, r.status); + r = await tget(worker, env, "/ba6377/g/", { method: "POST", body: "x" }, "f.test"); + check("r9 POST on the alias is 405", 405, r.status); + r = await tget(worker, env, "/healthz", {}, "f.test"); + check("r9 /healthz on the alias 301s", "301 https://s.test/healthz", `${r.status} ${r.headers.get("location")}`); + r = await tget(worker, env, "/ba6377/g/", {}, "x.test"); + check("r9 another Host is 404", 404, r.status); + check("r9 no alias answer reached the origin", 0, seen.length); + check("r9 the alias 301 carries no-store and noindex", "no-store|noindex, nofollow", nsni(await tget(worker, env, "/a", {}, "f.test"))); + const np = tenantEnv(new Bucket(), { PASS: "" }); + seen = []; + r = await tget(worker, np, "/c0ffee/f.txt"); + check("r9 PASS empty: a miss is 404 with no fetch call", "404 0", `${r.status} ${seen.length}`); + np.BUCKET.put("m/" + MID, JSON.stringify({ v: 2, id: MID, storage: "machine", name: "f.txt", by: "m", added: "2026-10-01", expires: 0, opts: "", type: "text" })); + r = await tget(worker, np, `/${MID}/f.txt`); + check("r9 PASS empty: a machine pointer is 404 with no fetch call", "404 0", `${r.status} ${seen.length}`); +} + +// --- row 10: the two-leg /healthz --- +{ + const worker = await loadWorker(); + const env = tenantEnv(new Bucket()); + const hz = async () => { seen = []; const r = await tget(worker, env, "/healthz"); return r; }; + answer = () => frozen(new Response("ok", { status: 200 })); + let r = await hz(); + check("r10 origin up: 200 ok, X-Share-Tunnel 1, the pair", "200 ok 1 1 abc123def456 1", + `${r.status} ${await bodyOf(r)} ${r.headers.get("x-share-tunnel")} ${r.headers.get("x-share-worker")} ${r.headers.get("x-share-gate")}`); + check("r10 the probe fetched HOST's /healthz with a timeout signal", "https://s.test/healthz true", `${(seen[0] || {}).url} ${(seen[0] || {}).signal}`); + check("r10 no-store and noindex", "no-store|noindex, nofollow", nsni(r)); + answer = () => frozen(new Response("error code: 1033", { status: 530 })); + r = await hz(); + check("r10 origin 530: 503 tunnel down, X-Share-Tunnel 0, the pair", "503 tunnel down 0 1 abc123def456", + `${r.status} ${await bodyOf(r)} ${r.headers.get("x-share-tunnel")} ${r.headers.get("x-share-worker")}`); + answer = (req, init) => new Promise((_, rej) => init.signal.addEventListener("abort", () => rej(new Error("aborted")))); + const t0 = Date.now(), keep = setTimeout(() => {}, 6000); // node unrefs AbortSignal.timeout's timer; this keeps the loop alive meanwhile + r = await hz(); + clearTimeout(keep); + check("r10 origin hangs: 503 X-Share-Tunnel 0 after the 3 s cap", "503 0 true", `${r.status} ${r.headers.get("x-share-tunnel")} ${Date.now() - t0 < 5000}`); + const np = tenantEnv(new Bucket(), { PASS: "" }); + seen = []; + r = await tget(worker, np, "/healthz"); + check("r10 PASS empty: SPEC-007's answer, no probe", "200 ok null 0", `${r.status} ${await bodyOf(r)} ${r.headers.get("x-share-tunnel")} ${seen.length}`); +} + console.log(fails ? `${fails} FAILED` : "PASS"); process.exit(fails ? 1 : 0); From 268e9a9a36f80776227f13aa81ac6a06887d527f Mon Sep 17 00:00:00 2001 From: Han Ngo Date: Thu, 1 Oct 2026 19:23:06 +0700 Subject: [PATCH 12/55] docs: record the Worker v3 proof for the tenant spec TASK-3 ticked; deltas logged (the method rule with PASS empty, the record shapes, the healthz header file). Negative controls for rows 7, 9, and 29 ran red then green on tree a6ff281. --- .../one-host-per-tenant.md | 10 +++++++ docs/specs/SPEC-008-one-host-per-tenant.md | 2 +- docs/verification/one-host-per-tenant.md | 29 +++++++++++++++++++ 3 files changed, 40 insertions(+), 1 deletion(-) diff --git a/docs/implementation-notes/one-host-per-tenant.md b/docs/implementation-notes/one-host-per-tenant.md index cb593ee..ad7835e 100644 --- a/docs/implementation-notes/one-host-per-tenant.md +++ b/docs/implementation-notes/one-host-per-tenant.md @@ -134,3 +134,13 @@ Deviations from the spec, per task. A task with none is not listed. - A member's bare prune and `ls` delete a machine pointer whose `expires` is past (pointer rule 3). - The 14-day warning: `api-token` (`--check`, the paste, `--cmd`) on an r2 member and on an R2-on origin reads `expires_on` from `/user/tokens/verify` and warns at 14 days or fewer, or once expired. Not on every add (`r2_key_id` in the config skips the verify call there). Test seam: `SHARE_R2_DRY_EXPIRES`. + +### TASK-3 (Worker v3, r2_healthz) + +- The method rule: with `PASS` empty the Worker keeps SPEC-007's early 405 for any method but GET and HEAD, so SPEC-007 rows 17 to 19 and 28 run unchanged. With `PASS` set, the 405 applies only to what the Worker answers itself (`/healthz`, a cloud record, after its record and JWT checks); a pass-through takes any method. +- The record branches read `v == 1` with no `storage` key as a cloud record and `v == 2` with `storage == "machine"` as a pointer; everything else (a missing `v` included, which SPEC-007's `v > 1` test let through) answers 404 and never passes through. +- The offline page also answers a thrown `fetch`. Its body is plain text with `Content-Type: text/plain; charset=utf-8`. +- Hits: a pass-through is marked in a `WeakSet` and the outer handler returns it untouched, so no data point is written and a 101 keeps its `webSocket`. +- `r2_healthz` reads one header file for both the live call and the dry seam (`SHARE_R2_DRY_HEALTHZ=tunnel-down` and `503-bare` write the 503 a tenant Worker sends with and without its headers), and strips the status line's carriage return. +- `tests/worker.mjs`'s stub origin returns a real `Response` whose `headers` throw on `set`, `append`, and `delete`. Node unrefs `AbortSignal.timeout`'s timer, so the hang case keeps the event loop alive with its own timer. +- Not measured or built: the slow-R2 2 s cap has no test row (only the throw); the Cache API memo and the Worker-wall-time failure line from the round-1 and round-2 warnings wait for L3's numbers (TASK-9a). diff --git a/docs/specs/SPEC-008-one-host-per-tenant.md b/docs/specs/SPEC-008-one-host-per-tenant.md index f9f369a..8d5f0b6 100644 --- a/docs/specs/SPEC-008-one-host-per-tenant.md +++ b/docs/specs/SPEC-008-one-host-per-tenant.md @@ -384,7 +384,7 @@ A tunnel profile with no `bucket=` (every R2-off tenant) keeps every file, outpu - [x] TASK-1b: account spike with throwaway tokens and names. (f) Rebinding an existing custom domain to another Worker: in place, or DELETE then PUT, and the gap. (g) `PUT access/apps/` with added destinations keeps the AUD. (h) Whether `op://Toolkit/cf-api-token` holds Workers Routes Edit and Access Apps Edit on the Dwarves account. (i) Whether it edits tunnels and DNS on the personal account. (k) The Workers plan on each account and the route's failure mode at the request limit. AC: each answered, the token table amended to the source each role uses. - [x] TASK-2a: per-link storage on the origin: config keys, `--cloud`, `--local`, their refusals, `rand_id` across both legs, the pointer write and its delete on `rm` and `prune`. Depends on TASK-1a. AC: rows 2, 4, 5. - [x] TASK-2b: storage dispatch for `rm`, `refresh`, `hits` with the member refusals, the reconcile, the sweep rule for pointers, and the origin's cloud expiry through its own reader. Depends on TASK-2a. AC: rows 3, 6, 26, 27. -- [ ] TASK-3: Worker v3 (`ALIASES`, `PASS`, two-leg `/healthz`, the record branches, the rewrap, the offline page, the method rule, `WORKER_RECORD_V` 2), `r2_healthz`'s header reading, and `tests/worker.mjs` with a stub whose responses hold immutable headers. Depends on TASK-1a. AC: rows 7 to 10, 29. +- [x] TASK-3: Worker v3 (`ALIASES`, `PASS`, two-leg `/healthz`, the record branches, the rewrap, the offline page, the method rule, `WORKER_RECORD_V` 2), `r2_healthz`'s header reading, and `tests/worker.mjs` with a stub whose responses hold immutable headers. Depends on TASK-1a. AC: rows 7 to 10, 29. - [ ] TASK-4: `setup --r2` and `--no-r2` without `--alias` (steps 1 to 3, 5, 6, 8, 9, 11, 13, 15) and the rerun convergence. Depends on TASK-2a, TASK-3, TASK-1b(h). AC: rows 11, 12, 13, 32. - [ ] TASK-5: `--alias` (steps 4, 7, 10, 12, 14, 16), the `--no-r2` alias refusal, the printed rollback list. Depends on TASK-4 and TASK-1b(f)(g). AC: rows 14, 30. - [ ] TASK-6: one shared list: the separate bucket reader, merged display rows, the `v:2` reader's checks, `storage`, `type`, `by`, `r2`, `storage_default`, `cloud_error`, `cloud_more`, the listing token rule, the newest-25 fetch in `state`. Depends on TASK-2b. AC: rows 1, 15, 16, 17, 28. diff --git a/docs/verification/one-host-per-tenant.md b/docs/verification/one-host-per-tenant.md index 59d63f9..c9f386a 100644 --- a/docs/verification/one-host-per-tenant.md +++ b/docs/verification/one-host-per-tenant.md @@ -68,3 +68,32 @@ Negative controls: each patch went into a copy of tree `25dd9e9` (commit 6df0318 | let the sweep count a machine pointer as unreadable (`r2_record_raw`'s machine branch raises) | row 26 origin and member sweeps (got `0 0 1`: skipped, warning printed), the not-JSON leg (got `0 0 0`: the warning named the pointer instead); `fails=3` | Dry trace for the first control: the member's `rm e10001` reads the snapshot, finds `e10001` in `r2_machine`, and with the patch logs `DELETE m/e10001`; the check reads one DELETE line in the member's `r2-calls.log` where it wants none. + +## TASK-3: Worker v3 and r2_healthz (rows 7 to 10, 29) + +``` +Command: SHARE_TEST_PORT_BASE=28787 gtimeout 900 bash tests/share.sh +Exit: 0 +Checks: 1211 ok, 0 FAIL, 0 SKIP (PASS), at 2fcbdb1 +Section: === tenant: the Worker is up while the origin is off (row 29) ===, 6 checks +Verdict: PASS +``` + +``` +Command: node tests/worker.mjs +Exit: 0 +Checks: 134 ok, 0 FAIL (PASS), at 2fcbdb1; the tenant rows 7 to 10 add 58 +Verdict: PASS +``` + +`shellcheck` and `/bin/bash -n bin/share` clean on the same tree. One earlier full run on this tree failed `500-row index answers under 3s` once while the machine was busy; the rerun took 0.84 s and passed. + +Negative controls on a copy of tree `a6ff281` (commit 2fcbdb1): + +| Patch | Runner | Red | Green (unpatched) | +|---|---|---|---| +| the Worker passes an expired cloud record through instead of 404 | `node tests/worker.mjs` | `r7 an expired cloud record is 404, never passed through` (got `200 1`), plus the WORKER_SHA self-check | PASS | +| the Worker passes a miss through with `PASS` empty | `node tests/worker.mjs` | `r9 PASS empty: a miss is 404 with no fetch call` (got `101 1`: the stub's last answer came back) and the machine-pointer leg (got `404 1`), plus the WORKER_SHA self-check | PASS | +| read the healthz status alone in `r2_healthz` (the header branch never fires) | full suite | row 29 tunnel-down: `state` (got `0 false`), the gated add (got `1`), the join (got `1 0`) | row 29 green; the copy has no `.git`, so the two `v0.5.1 CLI` checks that run `git show` fail in both runs (and `profiles --json wrote nothing under HOME` once in the red run, a flake); the worktree run above is 0 FAIL | + +Dry trace for the third control: the dry Worker writes `HTTP/2 503` with the pair and `x-share-tunnel: 0`; with the patch `hz_code` stays 503, so `state` reports `ready: false`, the gated add dies at its healthz check, and the join's three-in-a-row wait times out. From 19926b19acbaac8841a22b902d57f1a8be512ba8 Mon Sep 17 00:00:00 2001 From: Han Ngo Date: Thu, 1 Oct 2026 19:36:08 +0700 Subject: [PATCH 13/55] feat: setup --r2 and --no-r2 on a tunnel tenant's origin setup --r2 --bucket runs on the origin with the admin token: every read and refusal first (the role, share's Worker and a private bucket, no route of another script, no local id holding a cloud record), then the marker on a new bucket, the Worker with PASS and ALIASES, workers.dev off, a pointer per local row under the index lock, the four config keys, a fail-open route, and both healthz legs. A rerun converges. --no-r2 deletes the route and drops the keys. SPEC-007's admin setup and a member purge refuse a tenant host. Dry seam only; nothing was created on Cloudflare. Suite: 1243 ok, 0 FAIL. --- bin/share | 284 +++++++++++++++++++++++++++++++++++++++++++++++-- tests/share.sh | 117 ++++++++++++++++++++ 2 files changed, 392 insertions(+), 9 deletions(-) diff --git a/bin/share b/bin/share index a7dd288..b951161 100755 --- a/bin/share +++ b/bin/share @@ -1317,6 +1317,7 @@ write_config() { # write_config ; keeps a token_cmd and [[ -n $zone_name ]] && echo "zone=$zone_name" [[ -n $keep ]] && echo "token_cmd=$keep" [[ -n $keep_api ]] && echo "api_token_cmd=$keep_api" + [[ -f $config ]] && grep -E '^(bucket|r2_endpoint|storage_default|aliases)=' "$config" || true # an origin with R2 on stays on through a tunnel rerun } >"$config.tmp" mv -f "$config.tmp" "$config" } @@ -3160,6 +3161,17 @@ r2_api_dry() { # the account API under SHARE_R2_DRY: state files in $SHARE_R2_DR if [[ ${SHARE_R2_DRY_DOMAINS:-} == 500 ]]; then cf_code=500 cf_body='{"success":false,"errors":[{"code":10013}]}' elif [[ -f $d/domain.json ]]; then cf_body="{\"success\":true,\"result\":[$(jq -c '{id: "dom-dry"} + .' "$d/domain.json")]}"; fi ;; "DELETE "*/workers/domains/*) rm -f "$d/domain.json" ;; + "GET /zones/"*/workers/routes) + if [[ ${SHARE_R2_DRY_ROUTES:-} == deny ]]; then cf_code=403 cf_body=$deny + else cf_body="{\"success\":true,\"result\":$(cat "$d/routes.json" 2>/dev/null || echo '[]')}"; fi ;; + "POST /zones/"*/workers/routes) + jq -c --argjson r "$3" '. + [$r + {id: "route-\(length + 1)"}]' "$d/routes.json" 2>/dev/null >"$d/routes.tmp" || jq -nc --argjson r "$3" '[$r + {id: "route-1"}]' >"$d/routes.tmp" + mv -f "$d/routes.tmp" "$d/routes.json"; cf_body='{"success":true,"result":{}}' ;; + "PUT /zones/"*/workers/routes/*) + jq -c --argjson r "$3" --arg id "${p##*/}" 'map(if .id == $id then $r + {id: $id} else . end)' "$d/routes.json" >"$d/routes.tmp" && mv -f "$d/routes.tmp" "$d/routes.json" + cf_body='{"success":true,"result":{}}' ;; + "DELETE /zones/"*/workers/routes/*) + jq -c --arg id "${p##*/}" 'map(select(.id != $id))' "$d/routes.json" >"$d/routes.tmp" && mv -f "$d/routes.tmp" "$d/routes.json" ;; "DELETE "*/workers/scripts/*) rm -f "$d/script.json" ;; "DELETE "*/r2/buckets/*) rm -f "$d/bucket" ;; "PUT "*/workers/domains) jq -c '{hostname, service}' <<<"$3" >"$d/domain.json"; cf_body='{"success":true,"result":{}}' ;; @@ -3182,13 +3194,16 @@ r2_healthz() { # r2_healthz [secs]: hz_code (200 only for a "200 ok" body), hz_p if [[ ${SHARE_R2_DRY:-0} == 1 ]]; then # the dry Worker's answer as a header file and a body, read by the same lines below local d="${SHARE_R2_DRY_DIR:-$root/r2-dry}/.cf" pair gate printf 'HEALTHZ\n' >>"$root/r2-calls.log" - if [[ ${SHARE_R2_DRY_HEALTHZ:-} != down && -f $d/domain.json && -f $d/script.json ]]; then + local routed=0 # a tenant Worker answers through its route on /*, an r2 profile's through its custom domain + [[ -f $d/routes.json ]] && jq -e --arg p "$host_name/*" 'any(.[]; .pattern == $p)' "$d/routes.json" >/dev/null 2>&1 && routed=1 + if [[ ${SHARE_R2_DRY_HEALTHZ:-} != down && ( -f $d/domain.json || $routed == 1 ) && -f $d/script.json ]]; then pair="$(jq -r '[(.bindings[] | select(.name == "VERSION") | .text), (.bindings[] | select(.name == "SHA") | .text)] | join(" ")' "$d/script.json")" gate="$(jq -r 'if any(.bindings[]; .name == "TEAM" and (.text // "") != "") then 1 else 0 end' "$d/script.json")" case ${SHARE_R2_DRY_HEALTHZ:-} in tunnel-down) printf 'HTTP/2 503 \r\nx-share-worker: %s\r\nx-share-gate: %s\r\nx-share-tunnel: 0\r\n\r\n' "$pair" "$gate" >"$h"; b="tunnel down" ;; 503-bare) printf 'HTTP/2 503 \r\n\r\n' >"$h"; b="" ;; - *) printf 'HTTP/2 200 \r\nx-share-worker: %s\r\nx-share-gate: %s\r\n\r\n' "$pair" "$gate" >"$h"; b="ok" ;; + *) local tl=""; jq -e 'any(.bindings[]; .name == "PASS" and .text == "1")' "$d/script.json" >/dev/null && tl=$'x-share-tunnel: 1\r\n' + printf 'HTTP/2 200 \r\nx-share-worker: %s\r\nx-share-gate: %s\r\n%s\r\n' "$pair" "$gate" "$tl" >"$h"; b="ok" ;; esac fi else @@ -3211,11 +3226,15 @@ r2_wait_healthz() { # r2_wait_healthz admin|join: three 200s in a row within SHA [[ ${SHARE_R2_DRY:-0} == 1 ]] && poll=0 while :; do r2_healthz - if [[ $hz_code == 200 && ( $1 == join || $hz_pair == "$WORKER_VERSION $WORKER_SHA" ) ]]; then + if [[ $hz_code == 200 && ( $1 == join || $hz_pair == "$WORKER_VERSION $WORKER_SHA" ) && ( $1 != tenant || $hz_tunnel == 1 ) ]]; then streak=$((streak + 1)); [[ $streak -ge 3 ]] && return 0 else streak=0 fi + if [[ $1 == tenant && $((SECONDS - start)) -ge $wait ]]; then # the config is written by now: the rollback is --no-r2 + local leg="the Worker leg"; [[ $hz_code == 200 && $hz_pair == "$WORKER_VERSION $WORKER_SHA" ]] && leg="the tunnel leg (X-Share-Tunnel: ${hz_tunnel:-none})" + die "https://$host_name/healthz did not answer 200 ok with this share's Worker and X-Share-Tunnel: 1 three times in a row within ${wait}s (last: $hz_code ${hz_pair:-no pair}); $leg is down; roll back with: $me setup $host_name --no-r2" + fi [[ $((SECONDS - start)) -lt $wait ]] || die "https://$host_name/healthz did not answer 200 ok three times in a row within ${wait}s (last: $hz_code ${hz_pair:-no pair}); the config was not written; rerun setup once the certificate is issued" sleep "$poll" @@ -3225,8 +3244,10 @@ r2_wait_healthz() { # r2_wait_healthz admin|join: three 200s in a row within SHA r2_deploy_meta() { # r2_deploy_meta [salt]: the multipart metadata; printf only, so the salt never reaches an argv local salt="" [[ -n ${3:-} ]] && salt=',{"type":"secret_text","name":"SALT","text":"'"$3"'"}' - printf '{"main_module":"share.js","compatibility_date":"%s","bindings":[{"type":"r2_bucket","name":"BUCKET","bucket_name":"%s"},{"type":"analytics_engine","name":"HITS","dataset":"%s"},{"type":"plain_text","name":"HOST","text":"%s"},{"type":"plain_text","name":"TEAM","text":"%s"},{"type":"plain_text","name":"VERSION","text":"%s"},{"type":"plain_text","name":"SHA","text":"%s"}%s],"keep_bindings":["secret_text"]}\n' \ - "$WORKER_COMPAT_DATE" "$r2_bucket" "$2" "$host_name" "$1" "$WORKER_VERSION" "$WORKER_SHA" "$salt" + local tenant="" # a tenant Worker (setup --r2) passes misses to the origin and 301s its aliases; an r2-only profile binds neither + [[ -z ${r2_pass:-} ]] || tenant=',{"type":"plain_text","name":"PASS","text":"1"},{"type":"plain_text","name":"ALIASES","text":"'"${r2_aliases:-}"'"}' + printf '{"main_module":"share.js","compatibility_date":"%s","bindings":[{"type":"r2_bucket","name":"BUCKET","bucket_name":"%s"},{"type":"analytics_engine","name":"HITS","dataset":"%s"},{"type":"plain_text","name":"HOST","text":"%s"},{"type":"plain_text","name":"TEAM","text":"%s"},{"type":"plain_text","name":"VERSION","text":"%s"},{"type":"plain_text","name":"SHA","text":"%s"}%s%s],"keep_bindings":["secret_text"]}\n' \ + "$WORKER_COMPAT_DATE" "$r2_bucket" "$2" "$host_name" "$1" "$WORKER_VERSION" "$WORKER_SHA" "$tenant" "$salt" } r2_deploy() { # r2_deploy : one multipart PUT; the first deploy adds the SALT secret, later ones keep it @@ -3292,6 +3313,219 @@ write_config_r2() { # write_config_r2 : the r2 profile config; keeps api mv -f "$config.tmp" "$config" } +tenant_admin() { # tenant_admin : setup --r2 and --no-r2 on the origin: the profile checks, the admin token, zone, and the admin's own S3 pair + [[ $mode != quick ]] || die "$host_name is a quick tunnel; R2 needs a named tunnel tenant: '$me teardown', then '$me setup '" + [[ -n $(cfg tunnel_id || true) && "$(cfg hostname || true)" == "$host_name" && $backend != r2 ]] || + die "this profile is not the tunnel origin of $host_name; run '$me setup $host_name' on the origin first" + host_ok || die "run this on the origin, $hosts" + local c + for c in jq curl; do command -v "$c" >/dev/null || die "missing: $c (brew install $c)"; done + api_token="${CLOUDFLARE_API_TOKEN:-}" + [[ -n $api_token ]] || die "setup --r2 and --no-r2 read the tenant admin token from CLOUDFLARE_API_TOKEN only (Workers Scripts Edit, Workers R2 Storage Edit, Workers Routes Edit, Zone Read, DNS Read, Access Organizations Read); nothing was changed" + r2_setup_zone + r2_endpoint="https://$acct.r2.cloudflarestorage.com" r2_bucket=$1 + r2_api GET /user/tokens/verify # the admin token's own S3 pair, as SPEC-007's setup uses it + r2_key_id="$(jq -r '.result.id // empty' <<<"$cf_body" 2>/dev/null || true)" + [[ $cf_code == 200 && -n $r2_key_id ]] || die "the token did not verify (HTTP $cf_code); nothing was changed" + r2_secret="$(printf '%s' "$api_token" | shasum -a 256 | cut -d' ' -f1)" + r2on=1 +} + +tenant_route() { # tenant_route : the routes on the zone; sets route_id and route_open for /* naming the worker; dies on a route of another script on this host + local w=$1 p sc + route_id="" route_open="" + r2_api GET "/zones/$zone_id/workers/routes" + [[ $cf_code == 200 ]] || die "reading Worker routes answered HTTP $cf_code (the token needs Workers Routes Edit); nothing was changed" + while IFS=$'\t' read -r p sc; do # any pattern whose host part matches this host and names another script would shadow or be shadowed by ours + [[ -n $p ]] || continue + # shellcheck disable=SC2053 # the route's host part is a glob on purpose + [[ $host_name == ${p%%/*} ]] || continue + [[ $sc == "$w" ]] || die "Worker route $p names script ${sc:-none}, not $w; refused, nothing was changed" + done < <(jq -r '.result[]? | [.pattern, (.script // "")] | @tsv' <<<"$cf_body") + route_id="$(jq -r --arg p "$host_name/*" --arg w "$w" '[.result[]? | select(.pattern == $p and .script == $w) | .id] | first // empty' <<<"$cf_body")" + route_open="$(jq -r --arg p "$host_name/*" --arg w "$w" '[.result[]? | select(.pattern == $p and .script == $w) | .request_limit_fail_open] | first // empty' <<<"$cf_body")" +} + +tenant_config() { # tenant_config [ ]: the four tenant keys in the tunnel config; none given drops them + local tmp; tmp="$(mktemp "$config_dir/.config.XXXXXX")" + grep -vE '^(bucket|r2_endpoint|storage_default|aliases)=' "$config" >"$tmp" || true + [[ $# == 0 ]] || printf 'bucket=%s\nr2_endpoint=%s\nstorage_default=%s\naliases=%s\n' "$1" "$2" "$3" "$4" >>"$tmp" + mv -f "$tmp" "$config" + if [[ ${SHARE_R2_DRY:-0} == 1 ]]; then if [[ $# == 0 ]]; then echo "CONFIG r2 off"; else echo "CONFIG r2 on"; fi >>"$root/r2-calls.log"; fi +} + +cmd_setup_tenant() { # cmd_setup_tenant : setup --r2 on the origin; every read and refusal before the first write; a rerun converges + local bucket=$1 sdef=$2 force=$3 worker="share-${host_name//./-}" dataset="share_${host_name//[.-]/_}" + [[ $bucket =~ ^[a-z0-9][a-z0-9-]{1,61}[a-z0-9]$ ]] || die "bad bucket name '$bucket' (3-63 chars of a-z 0-9 -; no leading or trailing -)" + [[ $dataset =~ ^[a-z0-9_]+$ ]] || die "bad hostname '$host_name' for a dataset name" + local cur; cur="$(cfg bucket || true)" + [[ -z $cur || $cur == "$bucket" ]] || die "R2 is on for $host_name with bucket $cur; '$me setup $host_name --no-r2' first" + [[ $tunnel == 0 ]] || running || die "$host_name is not serving on this machine; '$me start', then rerun; nothing was changed" + sdef="${sdef:-$(cfg storage_default || true)}"; sdef="${sdef:-local}" + tenant_admin "$bucket" + + # step 2: only an admin token reads the Worker's settings + local first=0 dep_v=0 dep_sha="" dep_team="" dep_pass="" dep_aliases="" + r2_api GET "/accounts/$acct/workers/scripts/$worker/settings" + case $cf_code in + 200) ;; + 404) first=1 ;; + 401|403) die "the token cannot read Worker $worker's settings: only the tenant admin enables R2 (an admin token in CLOUDFLARE_API_TOKEN); nothing was changed" ;; + *) die "reading Worker $worker answered HTTP $cf_code; nothing was changed" ;; + esac + echo "deploying as admin" + if [[ $first == 0 ]]; then # step 3, SPEC-007 step 4: share's Worker for this host and bucket, never a downgrade + jq -e --arg h "$host_name" --arg b "$bucket" '.result.bindings as $bs + | ($bs | any(.name == "HOST" and .text == $h)) and ($bs | any(.name == "BUCKET" and .bucket_name == $b)) + and ($bs | any(.name == "VERSION" and (.text | test("^[0-9]+$"))))' <<<"$cf_body" >/dev/null || + die "Worker $worker exists but is not share's Worker for $host_name on bucket $bucket (its bindings differ); refused, nothing was changed" + dep_v="$(jq -r '.result.bindings[] | select(.name == "VERSION") | .text' <<<"$cf_body")" + dep_sha="$(jq -r '[.result.bindings[] | select(.name == "SHA") | .text] | first // ""' <<<"$cf_body")" + dep_team="$(jq -r '[.result.bindings[] | select(.name == "TEAM") | .text] | first // ""' <<<"$cf_body")" + dep_pass="$(jq -r '[.result.bindings[] | select(.name == "PASS") | .text] | first // ""' <<<"$cf_body")" + dep_aliases="$(jq -r '[.result.bindings[] | select(.name == "ALIASES") | .text] | first // ""' <<<"$cf_body")" + [[ $dep_v -le $WORKER_VERSION || $force == 1 ]] || + die "Worker $worker runs version $dep_v, newer than this share's $WORKER_VERSION; upgrade share, or pass --force to downgrade it; nothing was changed" + fi + + # step 3, SPEC-007 step 5: a private bucket, empty or holding this host's marker + local bucket_new=0 marker="" + r2_api GET "/accounts/$acct/r2/buckets/$bucket" + case $cf_code in + 404) bucket_new=1 ;; + 200) + r2_api GET "/accounts/$acct/r2/buckets/$bucket/domains/managed" + [[ $cf_code == 200 ]] || die "reading bucket $bucket's r2.dev setting answered HTTP $cf_code; nothing was changed" + [[ "$(jq -r '.result.enabled' <<<"$cf_body")" == false ]] || die "bucket $bucket has its public r2.dev URL on; a share bucket must be private; refused, nothing was changed" + r2_api GET "/accounts/$acct/r2/buckets/$bucket/domains/custom" + [[ $cf_code == 200 ]] || die "reading bucket $bucket's custom domains answered HTTP $cf_code; nothing was changed" + [[ "$(jq '.result.domains | length' <<<"$cf_body")" == 0 ]] || die "bucket $bucket has a public custom domain; a share bucket must be private; refused, nothing was changed" + r2_call LIST "" + [[ $cf_code == 200 ]] || die "listing bucket $bucket answered HTTP $cf_code; nothing was changed" + local has_objects=0; [[ $cf_body == *""* ]] && has_objects=1 + r2_call GET share.json + case $cf_code in + 200) marker="$cf_body" + jq -e --arg h "$host_name" '.v == 1 and .host == $h' <<<"$marker" >/dev/null 2>&1 || + die "bucket $bucket's share.json names another hostname; refused, nothing was changed" ;; + 404) [[ $has_objects == 0 ]] || die "bucket $bucket holds objects and no share.json; it is not a share bucket; refused, nothing was changed" ;; + *) die "reading $bucket/share.json answered HTTP $cf_code; nothing was changed" ;; + esac ;; + *) die "reading bucket $bucket answered HTTP $cf_code (the token needs Workers R2 Storage: Edit); nothing was changed" ;; + esac + + # step 5: no route of another script on this host + tenant_route "$worker" + + local team="" # SPEC-007 step 7: the Access team for gated cloud links + r2_api GET "/accounts/$acct/access/organizations" + if [[ $cf_code == 200 ]]; then + team="$(jq -r '.result.auth_domain // empty' <<<"$cf_body")" + [[ $team =~ ^[a-z0-9-]+\.cloudflareaccess\.com$ ]] || team="" + else + team="$dep_team" + fi + + # step 6, under the index lock through step 11 so a concurrent add waits: the bucket snapshot against the local index + mkdir -p "$root" + lock_take index + local ids id clash="" + ids="$(rows | cut -f1)" + if [[ $bucket_new == 0 ]]; then + r2_read + for id in $ids; do + grep -qx "$id" <<<"$r2_mkeys" || continue + grep -q "^$id"$'\t' <<<"$r2_machine" || clash="${clash:+$clash }$id" + done + [[ -z $clash ]] || die "local links $clash share an id with a record in bucket $bucket (a cloud link); rm one of each pair, then rerun; nothing was changed" + fi + + # writes. A new bucket gets its marker before any pointer, so a die below leaves a bucket that names this host + if [[ $bucket_new == 1 ]]; then + r2_api POST "/accounts/$acct/r2/buckets" "{\"name\":\"$bucket\"}" + [[ $cf_code == 200 ]] || die "creating bucket $bucket answered HTTP $cf_code" + fi + if [[ -z $marker ]]; then + local mf; mf="$(mktemp "$root/.marker.XXXXXX")" + printf '{"v":1,"host":"%s"}\n' "$host_name" >"$mf" + r2_call PUT share.json "$mf" -H 'If-None-Match: *' + rm -f "$mf" + [[ $cf_code == 200 || $cf_code == 412 ]] || die "writing $bucket/share.json answered HTTP $cf_code" + if [[ $cf_code == 412 ]]; then + r2_call GET share.json + jq -e --arg h "$host_name" '.v == 1 and .host == $h' <<<"$cf_body" >/dev/null 2>&1 || die "bucket $bucket's share.json names another hostname" + fi + fi + # step 8: the Worker with PASS and ALIASES, workers.dev off + r2_pass=1 r2_aliases="" + if [[ $first == 1 || $dep_v != "$WORKER_VERSION" || $dep_sha != "$WORKER_SHA" || $dep_team != "$team" || $dep_pass != 1 || $dep_aliases != "$r2_aliases" ]]; then + r2_deploy "$worker" "$team" "$dataset" "$first" + echo "worker: $worker $WORKER_VERSION $WORKER_SHA deployed (passes misses to the tunnel)" + else + echo "worker: $worker $WORKER_VERSION $WORKER_SHA already deployed" + fi + r2_api POST "/accounts/$acct/workers/scripts/$worker/subdomain" '{"enabled":false,"previews_enabled":false}' + [[ $cf_code == 200 ]] || die "turning off $worker's workers.dev route answered HTTP $cf_code; no route exists yet, nothing is exposed" + r2_api GET "/accounts/$acct/workers/scripts/$worker/subdomain" + [[ $cf_code == 200 && "$(jq -r '"\(.result.enabled),\(.result.previews_enabled)"' <<<"$cf_body")" == false,false ]] || + die "$worker's workers.dev route still reads on; no route exists yet, nothing is exposed; rerun setup" + + # step 9: a pointer for every local row; one that holds this origin's pointer already is done + local n=0 + for id in $ids; do + grep -q "^$id"$'\t' <<<"${r2_machine:-}" && continue + local name src date exp opts t + IFS=$'\t' read -r _ name src date exp opts <<<"$(row "$id")" + if has_opt live "$opts"; then t=site; else t="$(share_type "$pub/$id/$name")"; fi + r2_pointer_write "$id" "$name" "$t" "$exp" "$opts" "$(opt_val access "$opts" || true)" "$date" + case $cf_code in + 200) n=$((n + 1)) ;; + 412) die "another publisher took id $id in bucket $bucket during setup; the route is not attached; rm one of them, then rerun" ;; + *) die "writing the pointer m/$id answered HTTP $cf_code; the route is not attached; rerun the same setup" ;; + esac + done + echo "pointers: $n written" + + # step 11: the config, before the route, so every later die leaves a state --no-r2 and a rerun can read + tenant_config "$bucket" "$r2_endpoint" "$sdef" "" + lock_release index + + # step 13: the route; fail open at a request limit (a drop to the free plan) keeps machine links up + local body="{\"pattern\":\"$host_name/*\",\"script\":\"$worker\",\"request_limit_fail_open\":true}" + if [[ -z $route_id ]]; then + r2_api POST "/zones/$zone_id/workers/routes" "$body" + [[ $cf_code == 200 ]] || die "attaching the route $host_name/* answered HTTP $cf_code; links still serve from the tunnel; roll back with: $me setup $host_name --no-r2" + echo "route: $host_name/* -> $worker" + elif [[ $route_open != true ]]; then + r2_api PUT "/zones/$zone_id/workers/routes/$route_id" "$body" + [[ $cf_code == 200 ]] || die "setting the route $host_name/* to fail open answered HTTP $cf_code; roll back with: $me setup $host_name --no-r2" + echo "route: $host_name/* -> $worker (now fails open)" + else + echo "route: $host_name/* -> $worker already attached" + fi + + # step 15: both legs, then a probe file through the tunnel leg + r2_wait_healthz tenant + if [[ ${SHARE_R2_DRY:-0} == 1 ]]; then echo "TUNNEL-PROBE" >>"$root/r2-calls.log" + else live_check 60 "$host_name" || die "a probe file did not answer through https://$host_name/ three times in a row; the tunnel leg is down; roll back with: $me setup $host_name --no-r2"; fi + echo "ready: R2 is on for https://$host_name/ (bucket $bucket, storage default $sdef). Store this origin's publisher token: $me api-token" +} + +cmd_setup_no_r2() { # cmd_setup_no_r2: the rollback without an alias: the route goes, then the four config keys; the bucket, records, and Worker stay + local bucket worker="share-${host_name//./-}" + bucket="$(cfg bucket || true)" + [[ -z "$(cfg aliases || true)" ]] || die "$(cfg aliases) redirects here; run the alias rollback first; nothing was changed" + tenant_admin "${bucket:-none}" + tenant_route "$worker" + if [[ -n $route_id ]]; then + r2_api DELETE "/zones/$zone_id/workers/routes/$route_id" + [[ $cf_code == 200 || $cf_code == 404 ]] || die "deleting the route $host_name/* answered HTTP $cf_code; R2 stays on; rerun" + echo "route: $host_name/* removed" + fi + tenant_config + echo "ready: R2 is off for https://$host_name/: the tunnel serves it alone. The bucket, its records, and Worker $worker stay" +} + cmd_setup_r2() { # cmd_setup_r2 : every refusal before the first write local force=$1 bucket=$2 tname=$3 login=$4 service=$5 quick=$6 local rusage="usage: share setup --backend r2 --bucket [--force]" @@ -3339,6 +3573,9 @@ cmd_setup_r2() { # cmd_setup_r2 /dev/null && + die "$host_name is a tenant with R2 on (its Worker passes misses to a tunnel); refused even with --force, nothing was changed; run '$me setup $host_name --r2 --bucket $bucket' on the origin" fi local bucket_new=0 marker="" skipped=0 @@ -3367,7 +3604,9 @@ cmd_setup_r2() { # cmd_setup_r2 /dev/null 2>&1 || - die "bucket $bucket's share.json names another hostname; refused, nothing was changed" ;; + die "bucket $bucket's share.json names another hostname; refused, nothing was changed" + [[ $role != admin ]] || ! jq -e '(.aliases // []) | length > 0' <<<"$marker" >/dev/null 2>&1 || + die "$host_name is a tenant with R2 on (its marker holds aliases); refused even with --force, nothing was changed; run '$me setup $host_name --r2 --bucket $bucket' on the origin" ;; 404) [[ $role == admin ]] || die "bucket $bucket has no share.json; whoever holds the admin token runs '$me setup $host_name --backend r2 --bucket $bucket' first" [[ $has_objects == 0 ]] || die "bucket $bucket holds objects and no share.json; it is not a share bucket; refused, nothing was changed" ;; *) die "reading $bucket/share.json answered HTTP $cf_code; nothing was changed" ;; @@ -3377,13 +3616,23 @@ cmd_setup_r2() { # cmd_setup_r2 " return 0 fi + r2_api GET "/zones/$zone_id/workers/routes" # a route on the hostname means a tenant; SPEC-007's minimal admin set may answer 403, which the PASS check covers + case $cf_code in + 200) [[ "$(jq --arg p "$host_name/*" '[.result[]? | select(.pattern == $p)] | length' <<<"$cf_body")" == 0 ]] || + die "$host_name has a Worker route on $host_name/* (a tenant with R2 on); refused even with --force, nothing was changed; run '$me setup $host_name --r2 --bucket $bucket' on the origin" ;; + 403) ;; + *) die "reading Worker routes answered HTTP $cf_code; nothing was changed" ;; + esac r2_api GET "/zones/$zone_id/dns_records?name=$host_name" [[ $cf_code == 200 ]] || die "reading DNS for $host_name answered HTTP $cf_code (the token needs DNS: Read); nothing was changed" local ndns ours theirs @@ -3444,8 +3693,9 @@ cmd_setup_r2() { # cmd_setup_r2 " } +tusage="usage: share setup --r2 --bucket [--storage-default local|cloud] | share setup --no-r2" cmd_setup() { - local tunnel_name="" force=0 service=1 login=0 quick_setup=0 had_host_arg=0 backend_arg="" bucket="" + local tunnel_name="" force=0 service=1 login=0 quick_setup=0 had_host_arg=0 backend_arg="" bucket="" r2flag="" sdef="" [[ -n ${1:-} && ${1:-} != --* ]] && { host_name="$1"; had_host_arg=1; shift; } while [[ $# -gt 0 ]]; do case $1 in @@ -3456,9 +3706,23 @@ cmd_setup() { --quick) quick_setup=1; shift ;; --backend) backend_arg="${2:-}"; shift 2 ;; --bucket) bucket="${2:-}"; shift 2 ;; - *) die "usage: share setup [hostname] [--tunnel-name N] [--force] [--no-service] [--login] [--backend tunnel|r2 --bucket ] | share setup --quick [--no-service]" ;; + --r2|--no-r2) [[ -z $r2flag ]] || die "$tusage"; r2flag="$1"; shift ;; + --storage-default) sdef="${2:-}"; shift 2 ;; + *) die "usage: share setup [hostname] [--tunnel-name N] [--force] [--no-service] [--login] [--backend tunnel|r2 --bucket ] | share setup --quick [--no-service] | $tusage" ;; esac done + if [[ -n $r2flag ]]; then # R2 on a tunnel tenant: the origin's own setup, never a new tunnel + [[ $had_host_arg == 1 && -z $backend_arg && -z $tunnel_name && $service == 1 && $login == 0 && $quick_setup == 0 ]] || die "$tusage" + if [[ $r2flag == --r2 ]]; then + [[ -n $bucket && ( -z $sdef || $sdef == local || $sdef == cloud ) ]] || die "$tusage" + cmd_setup_tenant "$bucket" "$sdef" "$force" + else + [[ -z $bucket && -z $sdef && $force == 0 ]] || die "$tusage" + cmd_setup_no_r2 + fi + return + fi + [[ -z $sdef ]] || die "--storage-default needs --r2" [[ -z $backend_arg || $backend_arg == tunnel || $backend_arg == r2 ]] || die "--backend must be 'tunnel' or 'r2'" [[ -z $bucket || $backend_arg == r2 ]] || die "--bucket needs --backend r2" if [[ $backend_arg == r2 ]]; then @@ -3700,6 +3964,8 @@ r2_purge() { # r2_purge : the admin token from the environment; every ch jq -e --arg h "$host_name" --arg b "$bucket" '.result.bindings as $bs | ($bs | any(.name == "HOST" and .text == $h)) and ($bs | any(.name == "BUCKET" and .bucket_name == $b))' <<<"$cf_body" >/dev/null || die "Worker $worker is not share's Worker for $host_name on bucket $bucket (its bindings differ); refused, nothing was changed" + ! jq -e '.result.bindings | any(.name == "PASS" and .text == "1")' <<<"$cf_body" >/dev/null || + die "$host_name is a tenant with R2 on: its origin runs '$me setup $host_name --no-r2' first; refused, nothing was changed" r2_call GET share.json if [[ $cf_code != 200 ]] || ! jq -e --arg h "$host_name" '.v == 1 and .host == $h' <<<"$cf_body" >/dev/null 2>&1; then die "bucket $bucket's share.json does not name $host_name (HTTP $cf_code); refused, nothing was changed" diff --git a/tests/share.sh b/tests/share.sh index d7de85f..e70acf1 100755 --- a/tests/share.sh +++ b/tests/share.sh @@ -3013,6 +3013,123 @@ for mode in tunnel-down 503-bare; do done rm -f "$jconf" "$s2conf" +echo "=== tenant: setup --r2 and --no-r2 on the origin (rows 11, 12, 13, 32) ===" +T5H="$WORK/tenant-admin"; DRYV="$WORK/tenant-admin-bucket" +vdir="$T5H/.config/share/profiles/ten"; vconf="$vdir/config"; vroot="$T5H/share/profiles/ten"; vlog="$vroot/r2-calls.log" +tv() { # tv : the tunnel origin of ten.example.test with the admin token; hosts names this machine only through SHARE_HOSTS (TV_HOSTS=nobody for adds, so nothing serves) + env -u SHARE_ROOT -u SHARE_CONFIG_DIR -u SHARE_PORT -u SHARE_HOSTNAME -u XDG_CONFIG_HOME -u SHARE_PROFILE -u SHARE_BACKEND \ + HOME="$T5H" SHARE_TUNNEL=0 SHARE_R2_DRY=1 SHARE_R2_DRY_DIR="$DRYV" SHARE_R2_TOKEN=drytoken SHARE_ACCESS_DRY=1 SHARE_ACCESS_POLL=0 \ + CLOUDFLARE_API_TOKEN="${TV_TOK-admintoken}" SHARE_R2_WAIT="${SHARE_R2_WAIT:-2}" SHARE_HOSTS="${TV_HOSTS-$SHARE_HOSTS}" bash "$SH" --profile ten "$@" +} +tvsetup() { tv setup ten.example.test --r2 --bucket ok-bucket; } +vwrites() { grep -cE '^(API (PUT|POST|DELETE) |PUT |DELETE )' "$vlog" 2>/dev/null || true; } +vord() { # vord ...: 1 when each first match in the setup log sits below the previous one + local p prev=0 n + for p in "$@"; do n="$(grep -n -- "$p" "$vlog" | head -1 | cut -d: -f1)"; [[ -n $n && $n -gt $prev ]] || { echo "0 at $p"; return; }; prev=$n; done + echo 1 +} +vfresh() { # the origin with three local rows (a file, a live port, a gated file) and R2 off; the bucket exists with its marker and one member's cloud link + rm -rf "$T5H" "$DRYV"; mkdir -p "$vdir" "$DRYV/.cf" "$DRYV/m" "$DRYV/o/0b0001.0000000b" + printf 'hostname=ten.example.test\ntunnel_id=tid-ten\ntunnel_name=share-ten-example-test\nauth=api\nhosts=nobody\nport=%s\n' $((base + 50)) >"$vconf" + SHARE_TEST_IDS=0a0001 TV_HOSTS=nobody tv add "$tn2" >/dev/null 2>&1 + SHARE_TEST_IDS=0a0002 TV_HOSTS=nobody tv add $((base + 55)) >/dev/null 2>&1 + mkdir -p "$vroot/pub/0a0003"; printf 'g\n' >"$vroot/pub/0a0003/g.txt" + printf '0a0003\tg.txt\t/x/g.txt\t2026-10-01\t0\taccess=00000000-0000-4000-8000-0000000a0003 access_rule=email:a@example.test\n' >>"$vroot/index.tsv" + : >"$DRYV/.cf/bucket"; printf '{"v":1,"host":"ten.example.test"}\n' >"$DRYV/share.json" + printf 'x\n' >"$DRYV/o/0b0001.0000000b/f.txt" + jq -nc '{v: 1, id: "0b0001", name: "f.txt", src: "/m/f.txt", added: "2026-10-01", expires: 0, opts: "", prefix: "o/0b0001.0000000b/", by: "member"}' >"$DRYV/m/0b0001" + : >"$vlog" +} +vfresh +cp "$vconf" "$WORK/ten.config.before"; cp "$vroot/index.tsv" "$WORK/ten.index.before"; sec0="$(grep -c 'add-generic' "$STUBSEC_LOG" || true)" +check "row 11 fixture: three local rows, R2 off, no pointer" "3 0" "$(tnrows "$vroot") $(find "$DRYV/m" -name '0a*' | grep -c .)" +out=$(tvsetup 2>&1); rc=$? +check "row 11: setup --r2 exits 0" "0" "$rc" +check "row 11: the call order" "1" "$(vord '^API GET /accounts/acct-dry/workers/scripts/share-ten-example-test/settings$' '^API GET /accounts/acct-dry/r2/buckets/ok-bucket$' \ + '^GET share.json$' '^API GET /zones/zone-dry/workers/routes$' '^LIST m/$' '^API PUT /accounts/acct-dry/workers/scripts/share-ten-example-test$' \ + '^API POST .*/subdomain$' '^API GET .*/subdomain$' '^PUT m/0a000' '^CONFIG r2 on$' '^API POST /zones/zone-dry/workers/routes$' '^HEALTHZ$' '^TUNNEL-PROBE$')" +check "row 11: every pointer PUT precedes the config write" "1" "$([[ $(grep -n '^PUT m/' "$vlog" | tail -1 | cut -d: -f1) -lt $(grep -n '^CONFIG' "$vlog" | cut -d: -f1) ]] && echo 1 || echo 0)" +check "row 11: config gains the four keys" "bucket=ok-bucket|r2_endpoint=https://acct-dry.r2.cloudflarestorage.com|storage_default=local|aliases=|" \ + "$(grep -E '^(bucket|r2_endpoint|storage_default|aliases)=' "$vconf" | tr '\n' '|')" +check "row 11: the tunnel config lines are unchanged" "$(cat "$WORK/ten.config.before")" "$(grep -vE '^(bucket|r2_endpoint|storage_default|aliases)=' "$vconf")" +check "row 11: no Keychain item written" "$sec0" "$(grep -c 'add-generic' "$STUBSEC_LOG" || true)" +check "row 11: a pointer per local row (file, live site, gated flag)" "2 machine text |2 machine site live|2 machine text gated|" \ + "$(for i in 0a0001 0a0002 0a0003; do jq -r '"\(.v) \(.storage) \(.type) \(.opts)"' "$DRYV/m/$i"; done | tr '\n' '|')" +check "row 11: the Worker binds PASS and an empty ALIASES" "PASS=1 ALIASES=" "$(jq -r '[.bindings[] | select(.name == "PASS" or .name == "ALIASES") | "\(.name)=\(.text)"] | join(" ")' "$DRYV/.cf/script.json")" +check "row 11: the route fails open and names the Worker" "ten.example.test/* share-ten-example-test true" "$(jq -r '.[0] | "\(.pattern) \(.script) \(.request_limit_fail_open)"' "$DRYV/.cf/routes.json")" +check "row 11: the member's cloud record is untouched" "1 member" "$(jq -r '"\(.v) \(.by)"' "$DRYV/m/0b0001")" +# a rerun converges: nothing deployed, written, or attached again +cp "$vconf" "$WORK/ten.config.on"; : >"$vlog" +out=$(tvsetup 2>&1); rc=$? +check "rerun: exit 0, no script PUT, no pointer PUT, no route POST" "0 0 0 0" "$rc $(grep -c '^API PUT .*/workers/scripts/' "$vlog") $(grep -c '^PUT m/' "$vlog") $(grep -c '^API POST /zones/' "$vlog")" +check "rerun: the config is the same" "$(cat "$WORK/ten.config.on")" "$(cat "$vconf")" +# a member's join sees the tenant Worker; the version hint names the tenant command; a member purge is refused +TVM="$WORK/tenant-admin-member"; mkdir -p "$TVM" +tvm() { env -u SHARE_ROOT -u SHARE_CONFIG_DIR -u SHARE_PORT -u SHARE_HOSTNAME -u XDG_CONFIG_HOME -u SHARE_PROFILE -u SHARE_BACKEND -u SHARE_HOSTS \ + HOME="$TVM" SHARE_TUNNEL=0 SHARE_R2_DRY=1 SHARE_R2_DRY_DIR="$DRYV" CLOUDFLARE_API_TOKEN="${TVM_TOK-pubtoken}" SHARE_R2_WAIT=2 ${TVM_ENV[@]+"${TVM_ENV[@]}"} bash "$SH" --profile ten "$@"; } +jq -c '(.bindings[] | select(.name == "SHA")).text = "000000000000"' "$DRYV/.cf/script.json" >"$DRYV/.cf/s.tmp" && cp "$DRYV/.cf/script.json" "$WORK/ten.script" && mv -f "$DRYV/.cf/s.tmp" "$DRYV/.cf/script.json" +TVM_ENV=(SHARE_R2_DRY_ROLE=deny SHARE_R2_DRY_BUCKETDOM=deny) +out=$(tvm setup ten.example.test --backend r2 --bucket ok-bucket 2>&1); rc=$? +check "join: a member joins a tenant; the hint names --r2 on the origin" "0 1" "$rc $(grep -c "runs '.* setup ten.example.test --r2 --bucket ok-bucket' on the tenant's origin" <<<"$out")" +cp "$WORK/ten.script" "$DRYV/.cf/script.json"; TVM_ENV=(); : >"$TVM/share/profiles/ten/r2-calls.log" +out=$(TVM_TOK=admintoken tvm teardown --yes --purge 2>&1); rc=$? +check "purge: a member purge of a tenant is refused before any write" "1 1 0" "$rc $(grep -c "its origin runs 'share --profile ten setup ten.example.test --no-r2' first" <<<"$out") $(grep -cE '^(API (PUT|POST|DELETE) |PUT |DELETE )' "$TVM/share/profiles/ten/r2-calls.log" || true)" +# row 13: --no-r2 is the rollback without an alias +: >"$vlog" +out=$(tv setup ten.example.test --no-r2 2>&1); rc=$? +check "row 13: --no-r2 exits 0 and deletes the route" "0 1 0" "$rc $(grep -c '^API DELETE /zones/zone-dry/workers/routes/route-1$' "$vlog") $(jq length "$DRYV/.cf/routes.json")" +check "row 13: the config is the one before setup --r2" "$(cat "$WORK/ten.config.before")" "$(cat "$vconf")" +: >"$vlog" +out=$(SHARE_TEST_IDS=0a0004 TV_HOSTS=nobody tv add "$tn2" 2>&1); rc=$? +check "row 13: the next add writes no pointer" "0 0" "$rc $(grep -c '^PUT m/' "$vlog" 2>/dev/null || true)" +check "row 13: index.tsv equals the one before setup --r2 plus that add" "$(cat "$WORK/ten.index.before")" "$(grep -v '^0a0004' "$vroot/index.tsv")" +check "row 13: the bucket, its records, and the Worker stay" "1 1 1" "$([[ -f $DRYV/m/0a0001 ]] && echo 1 || echo 0) $([[ -f $DRYV/m/0b0001 ]] && echo 1 || echo 0) $([[ -f $DRYV/.cf/script.json ]] && echo 1 || echo 0)" +# fresh bucket: the bucket and its marker come before any pointer +vfresh; rm -rf "$DRYV/m" "$DRYV/o" "$DRYV/share.json" "$DRYV/.cf/bucket" +out=$(tvsetup 2>&1); rc=$? +check "row 11 (fresh bucket): exit 0, bucket POST < marker PUT < script PUT < pointer PUTs < config < route" "0 1" \ + "$rc $(vord '^API POST /accounts/acct-dry/r2/buckets$' '^PUT share.json$' '^API PUT .*/workers/scripts/share-ten-example-test$' '^PUT m/0a000' '^CONFIG r2 on$' '^API POST /zones/zone-dry/workers/routes$')" +check "row 11 (fresh bucket): no m/ list on a bucket that did not exist" "0" "$(grep -c '^LIST m/' "$vlog")" +# row 12: refusals, each before any write +vrefuse() { # vrefuse