From 234fde1b93db772dde41b297c7dc359b47e8c6fd Mon Sep 17 00:00:00 2001 From: Ben Davis <45952064+bmdavis419@users.noreply.github.com> Date: Wed, 9 Sep 2026 02:46:02 -0700 Subject: [PATCH 1/9] DOCS: Describe backups for the hosted deployment PlanetScale's automatic backups are the primary copy, the home-host pg_dump the independent one, and R2 gets object versioning or a second bucket. The backup host installer now checks for pg_dump beside rclone. Co-Authored-By: Claude Fable 5.1 --- docs/backup-restore.md | 15 +++++++++++++++ scripts/backup/install-backup-host.sh | 4 ++++ 2 files changed, 19 insertions(+) diff --git a/docs/backup-restore.md b/docs/backup-restore.md index 040794e..f87c4b2 100644 --- a/docs/backup-restore.md +++ b/docs/backup-restore.md @@ -7,6 +7,21 @@ below) and how to restore from it. Until the restore drill below has been performed at least once, a-drive must not be the only copy of anything important. +## Layers + +Three copies, each independent of the others: + +- **PlanetScale backups** are the primary. Automatic daily backups plus + point-in-time restore are on by default; restore from the database's + Backups page. This covers operator mistakes and bad migrations but not + loss of the PlanetScale account itself. +- **The home-host `pg_dump`** below is the independent copy: a different + machine, a different credential, outside Cloudflare and PlanetScale. +- **R2** has no automatic backup. Turn on object versioning for the + production bucket (dashboard, bucket settings) or run a nightly + `rclone sync` into a second bucket in another account; the home-host + mirror below already does the latter to local disk. + ## What is backed up, where Nightly cron on the backup host (02:17 local, `scripts/backup/backup.sh`) writes to diff --git a/scripts/backup/install-backup-host.sh b/scripts/backup/install-backup-host.sh index f7d851c..164b608 100755 --- a/scripts/backup/install-backup-host.sh +++ b/scripts/backup/install-backup-host.sh @@ -11,6 +11,10 @@ command -v rclone >/dev/null || { echo "rclone is required: https://rclone.org/install/" >&2 exit 1 } +command -v pg_dump >/dev/null || { + echo "pg_dump is required (postgresql-client): https://www.postgresql.org/download/" >&2 + exit 1 +} command -v curl >/dev/null || { echo "curl is required" >&2; exit 1; } command -v python3 >/dev/null || { echo "python3 is required" >&2; exit 1; } command -v pg_dump >/dev/null || { echo "pg_dump is required (Postgres client tools)" >&2; exit 1; } From 890e00e596ce456064d223fb94edb81784b8d4e6 Mon Sep 17 00:00:00 2001 From: Ben Davis <45952064+bmdavis419@users.noreply.github.com> Date: Wed, 9 Sep 2026 02:52:51 -0700 Subject: [PATCH 2/9] DOCS: Add the hosted launch checklist Public pages, abuse and DMCA contacts, content-zone settings, the secrets that must exist before the first deploy, and the verification skill as the final gate. release.md links to it from first-time setup and the README intro now describes the hosted direction while keeping the self-hosting instructions. Co-Authored-By: Claude Fable 5.1 --- README.md | 23 ++++++++------- docs/launch-checklist.md | 62 ++++++++++++++++++++++++++++++++++++++++ docs/release.md | 2 ++ 3 files changed, 77 insertions(+), 10 deletions(-) create mode 100644 docs/launch-checklist.md diff --git a/README.md b/README.md index 51db7f1..157c168 100644 --- a/README.md +++ b/README.md @@ -2,13 +2,17 @@ adrive is a Cloudflare-backed file spine with a dashboard, tags, hybrid search, static-site publishing, deployment-based authentication, and -scheduled storage lifecycle management. +scheduled storage lifecycle management. It is becoming a hosted product: +one deployment serving many organisations, each with its own files, keys, +and usage (`docs/plans/hosted-product.md`). Self-hosting a single-tenant +copy keeps working and is what the setup below describes. Uploads stream directly to R2, metadata and append-only version history live in -D1, and file/site bytes are served from a separate cookie-less content origin. -Search combines weighted FTS5 BM25 results, a filename trigram index, and an -optional Workers AI + pgvector semantic source with reciprocal rank fusion. -Canonical D1 hydration still applies deletion, expiry, visibility, and tag -filters. The CLI supports file transfer and safe, staged directory publishing. +Postgres (PlanetScale via Hyperdrive), and file/site bytes are served from a +separate cookie-less content origin. Search combines weighted keyword +results, a filename trigram index, and an optional Workers AI + pgvector +semantic source with reciprocal rank fusion, then applies deletion, expiry, +visibility, and tag filters. The CLI supports file transfer and safe, staged +directory publishing. ## Install the CLI @@ -51,14 +55,13 @@ Requirements: Node 26+ and Bun 1.4+. bun install bun db:pg:up # local Postgres via docker compose bun db:pg:migrate:local -bun db:migrate:local cp apps/web/.dev.vars.example apps/web/.dev.vars bun key:create:local ``` -Postgres (via a Hyperdrive binding) is being introduced beside D1; see -`docs/plans/hosted-product.md` for the port sequence. The route test suite -needs the docker compose Postgres running. +Local development runs against the docker compose Postgres through the +Hyperdrive binding; the route test suite needs it running too. Production +uses PlanetScale Postgres (`docs/release.md`). Route tests reset their Postgres database before each suite run. They use `adrive_test` by default; `ADRIVE_TEST_DATABASE_URL` may point to another diff --git a/docs/launch-checklist.md b/docs/launch-checklist.md new file mode 100644 index 0000000..9979a1b --- /dev/null +++ b/docs/launch-checklist.md @@ -0,0 +1,62 @@ +# Hosted launch checklist + +Everything here happens once, before the first paying user. Tick each +item off in order; the last one is the gate. + +## Public pages + +- Status page (an external host such as Better Stack or Instatus, so it + stays up when Cloudflare or the Worker is the outage). Link it from + the landing site footer. +- Terms of service page. +- Privacy policy page, covering what is logged (`docs/observability.md`), + where data lives (Cloudflare R2, PlanetScale), and the backup copy on + the home host (`docs/backup-restore.md`). +- DMCA policy page naming the designated agent below. + +## Abuse and legal contacts + +- Register a DMCA designated agent with the US Copyright Office and put + the same details on the DMCA page. +- Create an `abuse@` mailbox on the content domain and watch it. +- Put that address in the content domain's WHOIS abuse contact. +- Sign up for the Netcraft hosting-provider feed for the content domain + so phishing reports arrive before takedown requests do. + +## Cloudflare zone settings (content domain) + +- WAF managed rules on. +- Bot fight mode on. +- Hotlink protection off (public file links are the product). +- Universal SSL with the wildcard, so `*.` is covered + when per-tenant hostnames land. +- Notifications: the two alerts in `docs/observability.md` (DLQ depth, + 5xx rate). + +## Secrets before the first deploy + +Worker secrets, set with `wrangler secret put --env production` +from `apps/web`: + +- `PASSCODE` (`apps/web/src/env.d.ts`, `.dev.vars.example`), 12+ + characters. + +Local-only values from `.dev.vars.example` that must not be set in +production: + +- `CLOUDFLARE_HYPERDRIVE_LOCAL_CONNECTION_STRING_HYPERDRIVE`, used by + `wrangler dev` to point the Hyperdrive binding at docker Postgres. + +Shell environment for `bun release`: + +- `DATABASE_URL`, the production Postgres connection string + `scripts/release.sh` migrates against. + +Add every new secret to this list when it is introduced; the release +script does not check for them. + +## Final gate + +Run `.agents/skills/verify-deployment` against the live deployment with +the passcode. The launch is done when it reports PASS; INCONCLUSIVE or +FAIL means it is not. diff --git a/docs/release.md b/docs/release.md index 977901d..4840984 100644 --- a/docs/release.md +++ b/docs/release.md @@ -112,6 +112,8 @@ the repository root. 10. From the repo root: set up backups on your backup host (`scripts/backup/install-backup-host.sh`) and complete the restore drill in `docs/backup-restore.md`. +11. Work through `docs/launch-checklist.md` (public pages, abuse + contacts, zone settings, secrets) before the deployment is public. Semantic search notes for the first deploy: From 9c892e83d4aa4d771a3c6efdd8370a01c7c5f48a Mon Sep 17 00:00:00 2001 From: Ben Davis <45952064+bmdavis419@users.noreply.github.com> Date: Wed, 9 Sep 2026 05:24:23 -0700 Subject: [PATCH 3/9] FIX: Allow any dev hostname so Tailscale devices reach the dev server Co-Authored-By: Claude Fable 5.1 --- apps/web/vite.config.ts | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/apps/web/vite.config.ts b/apps/web/vite.config.ts index b8ad0c2..9a3f757 100644 --- a/apps/web/vite.config.ts +++ b/apps/web/vite.config.ts @@ -6,6 +6,11 @@ import { defineConfig } from 'vite'; // it because the merged config type is loose. Cast keeps tsc quiet. export default defineConfig({ plugins: [tailwindcss(), sveltekit()], + server: { + // The dev server binds 0.0.0.0 so other devices can reach it; allow + // any hostname since the app's own host gate enforces the origins. + allowedHosts: true + }, test: { exclude: [ '**/node_modules/**', From 9158242e26351c579c864f9ce1e0011230f62668 Mon Sep 17 00:00:00 2001 From: Ben Davis <45952064+bmdavis419@users.noreply.github.com> Date: Wed, 9 Sep 2026 07:58:17 -0700 Subject: [PATCH 4/9] DOCS: Record the hosted build status and next steps Co-Authored-By: Claude Fable 5.1 --- docs/plans/hosted-product-status.md | 141 ++++++++++++++++++++++++++++ 1 file changed, 141 insertions(+) create mode 100644 docs/plans/hosted-product-status.md diff --git a/docs/plans/hosted-product-status.md b/docs/plans/hosted-product-status.md new file mode 100644 index 0000000..ca6d657 --- /dev/null +++ b/docs/plans/hosted-product-status.md @@ -0,0 +1,141 @@ +# Hosted product: build status + +Written 2026-09-09 after the overnight build. Everything below is local and +unpushed. Nothing has run against real WorkOS, Autumn, PlanetScale, +Hyperdrive, Queues, URL Scanner, or rate limit namespaces; every external +service has a fake or a fail-open null used by local dev and tests. + +## What exists + +One linear chain of 37 commits on top of `main`, grouped as the stacks in +`hosted-product.md`. Each branch tip below is the last commit of its stack; +every commit was green on `bun run check`, `bun run test`, and `test:routes` +when it landed, and the final tip passes all three plus `format:check` and +`check:forbidden`. + +| Branch | Stack | Commits | +| --------------------- | --------------------------------------------------------------------------------------------------------------- | -------------- | +| `hosted/a0-plan-docs` | plan docs | 1 | +| `hosted/a1-pg-infra` | A1: Hyperdrive + `@effect/sql-pg`, migrations runner, docker Postgres, test harness | 3 | +| `hosted/a4-files` | A4: files, tags, purge on Postgres | 1 | +| `hosted/a2-auth` | A2+A3: auth and grant secrets on Postgres | 1 | +| `hosted/a7-sites` | A7: sites on Postgres | 1 | +| `hosted/a5-search` | A5+A6: tsvector/pg_trgm keyword search, pgvector semantic search | 1 | +| `hosted/a8-cutover` | A8: D1 removed, `d1-to-postgres.mjs` one-off move, backups on `pg_dump` | 2 | +| `hosted/d1-queues` | D1: Queues binding, `Job` schema, consumer facade | 2 | +| `hosted/b-tenancy` | B1-B8: orgs/users/memberships + RLS, identity hook, WorkOS AuthKit, per-org keys, scoped queries, per-org quota | 6 (+1 dev fix) | +| `hosted/c-content` | C1+C2: `.` hosts, KV slug cache, slug changes with 30-day history | 2 | +| `hosted/d-queues` | D2-D4: indexing, purges, site cleanup through Queues; DLQ into `failed_jobs` | 4 | +| `hosted/e-abuse` | E1-E6: rate limit bindings, trust levels, scan pipeline, reports + kill switch, `/admin`, `docs/abuse.md` | 6 | +| `hosted/f-billing` | F1-F4: `autumn.config.ts`, storage and AI metering, `/settings/billing`, Svix webhook | 4 | +| `hosted/g-ops` | G1+G3: backups doc, launch checklist (+ the vite allowedHosts fix) | 3 | + +Final tip: `hosted/g-ops`. Final counts on it: 217 unit tests, 73 route +tests, 0 type or svelte errors. The only failing check anywhere is +`check:wrangler-drift`, which correctly reports the Hyperdrive placeholder id. + +G2 (structured logs + Analytics Engine dataset) was built but dropped from the +chain because it conflicted with every later stack; it lives in the reflog of +`hosted/g-ops` (commit `6ee7f95` on the pre-rebase branch) and is a small +follow-up to redo on top. + +## Verified by hand on the final tip + +A dev server on the `hosted/g-ops` worktree, with the fake WorkOS client and +the local Postgres: + +- Sign-in redirect, callback, personal org bootstrap (`orgs`, `users`, + `memberships`, `org_usage` rows appear). +- Upload as that org, `org_usage.stored_bytes` increments, file serves from + `http://.siva.otter-hawksbill.ts.net:5174/f/`. +- The same file id on another org's host is 404; a second org cannot list or + fetch it through the API. +- `/api/billing` returns the free plan with usage; `/api/admin/overview` + answers for a user in `ADMIN_USER_IDS`. +- The D1 export move script was run against the local D1 state of the main + checkout and produced correct booleans, timestamps, search documents, and + API keys. + +Not verified by hand: the browser dashboard itself (only the API and content +routes were exercised), the queue consumer through the real facade (the route +tests drive it in-process), and anything behind a real credential. + +## Bringing it up tomorrow + +```sh +cd /home/davis/.t3/worktrees/a-drive/hosted-g-ops +docker compose up -d # already running on this box +bun install +bun db:pg:migrate:local # already applied locally +bun --filter @adrive/web dev # binds 0.0.0.0, allowedHosts on +``` + +Dashboard: `http://siva.otter-hawksbill.ts.net:5173/`. Content hosts: +`http://.siva.otter-hawksbill.ts.net:5174/`. Wildcard subdomains of a +Tailscale MagicDNS name do not resolve on other devices; test content hosts +from this machine with a `Host` header, or set `CONTENT_DOMAIN` to +`localhost:5174` and use the browser on this box. + +`apps/web/.dev.vars` in that worktree has placeholder values for every +secret. With `WORKOS_API_KEY` empty the fake signs you in as `user_local`; +set `ADMIN_USER_IDS="user_local"` (already set) to see `/admin`. + +## What needs real credentials + +| Secret or id | Used by | Without it | +| --------------------------------------------------------------------------------------- | --------------------------------- | --------------------------- | +| Hyperdrive id in `wrangler.jsonc` `env.production` | deploy | release preflight refuses | +| `DATABASE_URL` (PlanetScale direct port) | `bun release` migrations, backups | none locally | +| `WORKOS_API_KEY`, `WORKOS_CLIENT_ID`, `WORKOS_COOKIE_PASSWORD`, `WORKOS_WEBHOOK_SECRET` | sign-in | fake client | +| `MAINTENANCE_SECRET` | cron and queue HMAC | placeholder in `.dev.vars` | +| `AUTUMN_SECRET_KEY`, `AUTUMN_WEBHOOK_SECRET` | billing | fail-open null | +| `ADMIN_USER_IDS` | `/admin` | nobody is admin | +| `URLSCAN_API_KEY` + `CF_ACCOUNT_ID` | HTML/site scanning | verdict recorded as skipped | +| `CF_API_TOKEN` + `CF_ZONE_ID` | edge cache purge on kill switch | logged and skipped | +| `ALERT_WEBHOOK_URL` | DLQ alerts | none | +| Queues `adrive-jobs-production` and `-dlq`, rate limit namespaces 2001-2004 | production bindings | local equivalents only | + +## Known gaps and decisions to make + +- `BillingGates.canShare` is not yet consulted by `canPublish`. The rebase + agent flagged a real design question: `canShare` fails open when Autumn is + unconfigured, so "canShare OR verified" would let brand-new orgs publish in + every environment without Autumn. Suggested shape: trust allows, OR plan is + paid AND `canShare`. Read `orgs.plan` locally first. +- `org_usage.file_count` is never incremented. Harmless today; either wire it + in the upload/purge transactions or drop the column. +- The dashboard does not render `notifications` rows (quarantine notices); + they are written and visible through `/admin` only. +- `RL_PUBLISH` is bound but unused; publishes are gated by trust and the + scanner instead. +- Indexing retry cadence is the larger of the queue backoff and the row's own + `index_next_run_at`; the 15-minute reconciliation sweep covers gaps. +- A 30-day trash purge is delivered early about 60 times because of the + 12-hour queue delay cap; each delivery is a cheap re-send. +- `hosted-product.md`'s "Where the code is today" table still describes the + D1 era; it is historical. +- The `apps/site` marketing app is untouched. + +## Opening the stacked PRs + +Branch protection's "dismiss stale approvals" should be off for `gh stack` +merges. From the `hosted-g-ops` worktree: + +```sh +gh stack init hosted/a0-plan-docs hosted/a1-pg-infra hosted/a4-files \ + hosted/a2-auth hosted/a7-sites hosted/a5-search hosted/a8-cutover \ + hosted/d1-queues hosted/b-tenancy hosted/c-content hosted/d-queues \ + hosted/e-abuse hosted/f-billing hosted/g-ops +gh stack submit --auto # drafts, one PR per branch, bottom targets main +``` + +Stack B is six commits on one branch; split it with `gh stack modify` if +six separate PRs are wanted, the commits are already one-per-substep. + +## Worktrees and databases + +Worktrees live under `/home/davis/.t3/worktrees/a-drive/hosted-*`, one per +branch. They are safe to remove with `git worktree remove` once the PRs +exist; the branches stay. The docker Postgres holds `adrive` (dev, migrated +to `0008`), `adrive_test`, and one `adrive_test_` database per agent; +all but `adrive` and `adrive_test` can be dropped. From 280af96252b465d5a78adc5ccc53809f7acd7434 Mon Sep 17 00:00:00 2001 From: Ben Davis <45952064+bmdavis419@users.noreply.github.com> Date: Wed, 9 Sep 2026 14:12:30 -0700 Subject: [PATCH 5/9] BUGFIX: Drop the __Host- cookie prefix on plain-http dashboard origins Browsers refuse Secure cookies on http origins other than localhost, so signing in from a LAN or Tailscale hostname in development silently lost the session. The cookie names and Secure flag now follow the dashboard origin's scheme. Co-Authored-By: Claude Fable 5.1 --- README.md | 6 ++++++ apps/web/src/hooks.server.ts | 7 ++++--- apps/web/src/lib/server/auth-policy.ts | Bin 2967 -> 3453 bytes apps/web/src/lib/server/request-auth.ts | 6 ++++-- apps/web/src/lib/server/routes/abuse.test.ts | 3 ++- apps/web/src/lib/server/routes/routes.test.ts | 5 ++--- apps/web/src/lib/server/test/helpers.ts | 10 ++++++--- apps/web/src/routes/auth/callback/+server.ts | 19 ++++++++++-------- apps/web/src/routes/auth/sign-in/+server.ts | 9 +++++++-- apps/web/src/routes/auth/sign-out/+server.ts | 7 ++++--- 10 files changed, 47 insertions(+), 25 deletions(-) diff --git a/README.md b/README.md index 157c168..e345145 100644 --- a/README.md +++ b/README.md @@ -112,6 +112,12 @@ record points to that IP. Verify a sample tenant hostname resolves on the device running the browser; its resolver may block public DNS answers that point to private networks. +Sign-in stores the sealed WorkOS session in a thirty-day, `HttpOnly`, +`SameSite=Lax` cookie. On an HTTPS dashboard origin it is the `Secure`, +host-only `__Host-adrive-wos` cookie; on a plain-HTTP dev origin (a LAN or +Tailscale hostname) it drops the prefix and the `Secure` flag so browsers +will keep it. + Vite also needs to allow the dashboard hostname and content-domain suffix. Set this in the shell when starting development, separately from `.dev.vars`, and adjust both entries if you changed the domains: diff --git a/apps/web/src/hooks.server.ts b/apps/web/src/hooks.server.ts index 618a029..22c5136 100644 --- a/apps/web/src/hooks.server.ts +++ b/apps/web/src/hooks.server.ts @@ -1,5 +1,5 @@ import type { Handle } from '@sveltejs/kit'; -import { SESSION_COOKIE, sessionCookieOptions } from '$lib/server/auth-policy'; +import { cookieNames, sessionCookieOptions } from '$lib/server/auth-policy'; import { resolveContentHost } from '$lib/server/content-host'; import { MisdirectedRequest } from '$lib/server/errors'; import { assertHostRoute, normalizeOrigins } from '$lib/server/host-gate'; @@ -67,10 +67,11 @@ export const handle: Handle = async ({ event, resolve }) => { const resolved = await resolveEventAuth(env, event); event.locals.auth = resolved.auth; if (resolved.refreshedSession !== null) { + const names = cookieNames(origins.dashboardOrigin); event.cookies.set( - SESSION_COOKIE, + names.session, resolved.refreshedSession, - sessionCookieOptions + sessionCookieOptions(names.secure) ); } } catch (cause) { diff --git a/apps/web/src/lib/server/auth-policy.ts b/apps/web/src/lib/server/auth-policy.ts index 25c12f3c60caf311d2f279b7c7a929c1ae76c395..ee9238077569099dfd73d50251b636bdc79b0ffa 100644 GIT binary patch delta 806 zcmb`F&59F25XVWPkQvyM;G(!t2VpWYNzmhjD8b7L%Lc#Bo6by`X`FP8-97nWi8%@4 z$z={ch%eyLgYTd(;Zd-9CU_8!LLZ9$SN;1})%EH4*X@r{1+7WdZ68%XTJNu-JNH4T z6l{iItgy(DurQY@H6Xyqh3ZQM!dhGw7A*P0VfD6kvWMvkHDuNn#uaZN5n69+pV`I5 zDH+?JL80-L+(cXh>r5sYXna#jjRu}$QfeH*T+`a1Hb5%hLPm;oLqO_a&KEf)BG1^e zb~40Z`&5{0PC}=^0*PYPpcA`f~l| z-qiiFs&EZ2pPl`k6b=eamJ-TgJRU<(4>k@)zSlg_U~&Dy=n({mDF+Zk;1V_3QY*Oh zJ+DcM>c|*L>NJMg-OI*gCsHj}*k6;G)hX`V|E>4ym6-Y7ZshafnO!}JZZ#id+`Q_w z!TJd&z8iJ9!HsqC*9TSEK7QP5SYntH?`r=1(sw|1}N?ZWD3d+@(JyWUc4CJT(K@2!(-++_IqL+7ab8`HH3_W%F@ delta 304 zcmew>HC=py&SWM=8AiRyh0HnvT>AP7B^ik&3dN}<#R{1vleaNjbASZ%QY%U(|713r zXvrt8V5^`WAMcT0T%wzpQj}Sis#~64tZqF~+esFv(kU}9rC1?Rp*S-=Pd779A+e++ zHMgKdp(KB@Kcno#94Q&B`gMy-5=&CmCuUEdEXShaP|d|zkXVvorJ$~_uEWKdQBqRi zpO;f { }) ) ).rejects.toMatchObject({ status: 403 }); - ctx.cookies.delete('__Host-adrive-wos'); + ctx.cookies.delete(SESSION_COOKIE); await expect( adminCall(ctx, 'GET', '/api/admin/overview') ).rejects.toMatchObject({ status: 401 }); diff --git a/apps/web/src/lib/server/routes/routes.test.ts b/apps/web/src/lib/server/routes/routes.test.ts index 27c4f7e..513b776 100644 --- a/apps/web/src/lib/server/routes/routes.test.ts +++ b/apps/web/src/lib/server/routes/routes.test.ts @@ -24,11 +24,10 @@ import { uploadFile, listFiles, mutateFile, - indexFile + indexFile, + SESSION_COOKIE } from '../test/helpers'; -const SESSION_COOKIE = '__Host-adrive-wos'; - // Files and versions live in Postgres now; read them through the request // layer rather than the D1 binding. const queryPg = async ( diff --git a/apps/web/src/lib/server/test/helpers.ts b/apps/web/src/lib/server/test/helpers.ts index e07a722..d11f408 100644 --- a/apps/web/src/lib/server/test/helpers.ts +++ b/apps/web/src/lib/server/test/helpers.ts @@ -1,7 +1,12 @@ import { Effect, Schema } from 'effect'; import { FileListResponseSchema, type FileListResponse } from '@adrive/shared'; -import { SESSION_COOKIE } from '../auth-policy'; -import { call, type RouteTestContext } from './route-context'; +import { cookieNames } from '../auth-policy'; +import { call, DASHBOARD_ORIGIN, type RouteTestContext } from './route-context'; + +// The route suite's dashboard origin is plain http, so these are the +// unprefixed names. +export const { session: SESSION_COOKIE, state: STATE_COOKIE } = + cookieNames(DASHBOARD_ORIGIN); export interface TestIdentity { readonly userId: string; @@ -18,7 +23,6 @@ export const loginAs = async ( identity: TestIdentity ) => { const { fakeSession } = await import('../services/workos'); - const { STATE_COOKIE } = await import('../auth-policy'); const { GET } = await import('../../../routes/auth/callback/+server.js'); ctx.cookies.delete(SESSION_COOKIE); ctx.cookies.set(STATE_COOKIE, 'state=test-state'); diff --git a/apps/web/src/routes/auth/callback/+server.ts b/apps/web/src/routes/auth/callback/+server.ts index 552e50f..0b3a2b8 100644 --- a/apps/web/src/routes/auth/callback/+server.ts +++ b/apps/web/src/routes/auth/callback/+server.ts @@ -1,11 +1,8 @@ import type { RequestHandler } from './$types'; import { Effect } from 'effect'; import { deviceApprovalParams } from '$lib/device-approval'; -import { - SESSION_COOKIE, - STATE_COOKIE, - sessionCookieOptions -} from '$lib/server/auth-policy'; +import { cookieNames, sessionCookieOptions } from '$lib/server/auth-policy'; +import { AppConfig } from '$lib/server/config'; import { runEdge } from '$lib/server/edge'; import { InvalidRequest } from '$lib/server/errors'; import { Auth } from '$lib/server/services/auth'; @@ -23,10 +20,12 @@ export const GET: RequestHandler = ({ cookies, url }) => runEdge( Effect.gen(function* () { const auth = yield* Auth; + const config = yield* AppConfig; + const names = cookieNames(config.dashboardOrigin); const state = url.searchParams.get('state'); - const pending = new URLSearchParams(cookies.get(STATE_COOKIE) ?? ''); + const pending = new URLSearchParams(cookies.get(names.state) ?? ''); const expectedState = pending.get('state'); - cookies.delete(STATE_COOKIE, { path: '/' }); + cookies.delete(names.state, { path: '/' }); if (!state || !expectedState || state !== expectedState) { return yield* new InvalidRequest({ status: 400, @@ -41,7 +40,11 @@ export const GET: RequestHandler = ({ cookies, url }) => }); } const { sealedSession } = yield* auth.completeSignIn(code); - cookies.set(SESSION_COOKIE, sealedSession, sessionCookieOptions); + cookies.set( + names.session, + sealedSession, + sessionCookieOptions(names.secure) + ); return redirectHome(deviceApprovalParams(pending)); }) ); diff --git a/apps/web/src/routes/auth/sign-in/+server.ts b/apps/web/src/routes/auth/sign-in/+server.ts index 01cfd4c..acb3428 100644 --- a/apps/web/src/routes/auth/sign-in/+server.ts +++ b/apps/web/src/routes/auth/sign-in/+server.ts @@ -1,7 +1,7 @@ import type { RequestHandler } from './$types'; import { Effect } from 'effect'; import { deviceApprovalParams } from '$lib/device-approval'; -import { STATE_COOKIE, stateCookieOptions } from '$lib/server/auth-policy'; +import { cookieNames, stateCookieOptions } from '$lib/server/auth-policy'; import { AppConfig } from '$lib/server/config'; import { runEdge } from '$lib/server/edge'; import { WorkOSClient } from '$lib/server/services/workos'; @@ -24,7 +24,12 @@ export const GET: RequestHandler = ({ cookies, url }) => const state = randomState(); const pending = deviceApprovalParams(url.searchParams); pending.set('state', state); - cookies.set(STATE_COOKIE, pending.toString(), stateCookieOptions); + const names = cookieNames(config.dashboardOrigin); + cookies.set( + names.state, + pending.toString(), + stateCookieOptions(names.secure) + ); return new Response(null, { status: 302, headers: { diff --git a/apps/web/src/routes/auth/sign-out/+server.ts b/apps/web/src/routes/auth/sign-out/+server.ts index e817339..a57eb1a 100644 --- a/apps/web/src/routes/auth/sign-out/+server.ts +++ b/apps/web/src/routes/auth/sign-out/+server.ts @@ -1,6 +1,6 @@ import type { RequestHandler } from './$types'; import { Effect } from 'effect'; -import { SESSION_COOKIE } from '$lib/server/auth-policy'; +import { cookieNames } from '$lib/server/auth-policy'; import { AppConfig } from '$lib/server/config'; import { runEdge } from '$lib/server/edge'; import { InvalidRequest } from '$lib/server/errors'; @@ -20,11 +20,12 @@ export const POST: RequestHandler = ({ cookies, request, url }) => } const auth = yield* Auth; const config = yield* AppConfig; + const sessionCookie = cookieNames(config.dashboardOrigin).session; const location = yield* auth.logoutUrl( - cookies.get(SESSION_COOKIE), + cookies.get(sessionCookie), `${config.dashboardOrigin}/` ); - cookies.delete(SESSION_COOKIE, { path: '/' }); + cookies.delete(sessionCookie, { path: '/' }); return new Response(null, { status: 303, headers: { 'Cache-Control': 'private, no-store', Location: location } From 0624dde26a6af7f1516a8572a2915ed656f2a058 Mon Sep 17 00:00:00 2001 From: Ben Davis <45952064+bmdavis419@users.noreply.github.com> Date: Wed, 9 Sep 2026 14:13:01 -0700 Subject: [PATCH 6/9] DOCS: Point the dev content domain at nip.io and note the cookie change Co-Authored-By: Claude Fable 5.1 --- docs/plans/hosted-product-status.md | 14 +++++++++----- 1 file changed, 9 insertions(+), 5 deletions(-) diff --git a/docs/plans/hosted-product-status.md b/docs/plans/hosted-product-status.md index ca6d657..964cc2b 100644 --- a/docs/plans/hosted-product-status.md +++ b/docs/plans/hosted-product-status.md @@ -47,7 +47,7 @@ the local Postgres: - Sign-in redirect, callback, personal org bootstrap (`orgs`, `users`, `memberships`, `org_usage` rows appear). - Upload as that org, `org_usage.stored_bytes` increments, file serves from - `http://.siva.otter-hawksbill.ts.net:5174/f/`. + `http://.100.100.40.20.nip.io:5174/f/`. - The same file id on another org's host is 404; a second org cannot list or fetch it through the API. - `/api/billing` returns the free plan with usage; `/api/admin/overview` @@ -71,10 +71,14 @@ bun --filter @adrive/web dev # binds 0.0.0.0, allowedHosts on ``` Dashboard: `http://siva.otter-hawksbill.ts.net:5173/`. Content hosts: -`http://.siva.otter-hawksbill.ts.net:5174/`. Wildcard subdomains of a -Tailscale MagicDNS name do not resolve on other devices; test content hosts -from this machine with a `Host` header, or set `CONTENT_DOMAIN` to -`localhost:5174` and use the browser on this box. +`http://.100.100.40.20.nip.io:5174/`. Wildcard subdomains of a +Tailscale MagicDNS name do not resolve on other devices, so `CONTENT_DOMAIN` +in `.dev.vars` points at nip.io, which resolves any `*.100.100.40.20.nip.io` +name to the Tailscale address from anywhere on the tailnet. + +The session cookie drops its `__Host-` prefix and `Secure` flag when the +dashboard origin is plain http, so sign-in works from a Tailscale hostname. +On https it is the `Secure`, host-only cookie as before. `apps/web/.dev.vars` in that worktree has placeholder values for every secret. With `WORKOS_API_KEY` empty the fake signs you in as `user_local`; From 1e778f2a11381acedc0b0742a4aa52ff9af0dbfa Mon Sep 17 00:00:00 2001 From: Ben Davis <45952064+bmdavis419@users.noreply.github.com> Date: Fri, 11 Sep 2026 01:02:19 -0700 Subject: [PATCH 7/9] Fix remote auth cookies and align hosted operations runbooks --- .agents/skills/deploy-fresh-instance/SKILL.md | 218 ++++------- .agents/skills/verify-deployment/SKILL.md | 342 ++++++------------ README.md | 21 +- apps/web/.dev.vars.example | 11 +- apps/web/src/lib/server/auth-policy.ts | Bin 3453 -> 3468 bytes .../lib/server/routes/auth-cookies.test.ts | 230 ++++++++++++ .../lib/server/routes/device-sign-in.test.ts | 2 +- apps/web/src/routes/auth/callback/+server.ts | 2 +- apps/web/src/routes/auth/sign-out/+server.ts | 6 +- apps/web/vite.config.ts | 5 - docs/backup-restore.md | 134 ++++--- docs/launch-checklist.md | 64 +++- docs/observability.md | 58 +++ docs/plans/hosted-product-status.md | 221 ++++------- docs/release.md | 35 +- 15 files changed, 750 insertions(+), 599 deletions(-) create mode 100644 apps/web/src/lib/server/routes/auth-cookies.test.ts create mode 100644 docs/observability.md diff --git a/.agents/skills/deploy-fresh-instance/SKILL.md b/.agents/skills/deploy-fresh-instance/SKILL.md index a1e0743..42c71fa 100644 --- a/.agents/skills/deploy-fresh-instance/SKILL.md +++ b/.agents/skills/deploy-fresh-instance/SKILL.md @@ -1,146 +1,80 @@ --- name: deploy-fresh-instance -description: Deploy a fresh a-drive instance to Cloudflare Workers — provision Postgres (PlanetScale + Hyperdrive), R2, and KV plus Workers AI, set the PASSCODE secret, attach the two custom domains, and cut the first release. Use for the first-ever deploy to a new Cloudflare account or a new operator domain (not for routine redeploys, which are just `bun release`). +description: Bootstrap a fresh hosted a-drive deployment with Cloudflare Workers, Postgres through Hyperdrive, R2, KV, Queues, WorkOS, and optional paid billing. Use for a new account or target domain. Routine releases use bun release and the maintained release runbook. --- -# Deploy a fresh a-drive instance to Cloudflare - -This is the proven first-deploy sequence, executed successfully for -`davis7.space` (Aug 2026). It complements `docs/release.md` (the canonical -first-time-setup + release + rollback doc) — read that alongside this; the -steps here add the exact commands, the paste-the-id-yourself reality, and -the post-deploy gotchas that actually bit us. It does not repeat the -rollback or migration-rule sections — see `docs/release.md` for those. - -Reference example throughout: dashboard `drive.davis7.space`, content -`files.davis7.space`, resource name stem `adrive-production`. Substitute -the operator's own domain, hostnames, and names. - -## 1. Prerequisites - -- `wrangler whoami` succeeds and points at the intended Cloudflare account. -- The operator's domain is already added as a **Cloudflare zone** (the - custom-domain routes attach automatically on deploy only if the zone - exists). Remove conflicting CNAME records for the two application - hostnames; the deploy creates their DNS records, but not the zone. -- Node 26 + Bun 1.4 installed; run `bun install` once at the repo root. -- Decide the **two hostnames** now — a dashboard origin and a content - origin. They must be **distinct hosts** (e.g. `drive.` and `files.`); - the app rejects cross-origin serving and several checks depend on the - split. - -## 2. Provisioning (run from `apps/web`) - -Wrangler runs **non-interactively** here: each create prints an id and -exits. It does **not** edit `wrangler.jsonc` for you — you paste each id -into `env.production` by hand. - -1. Create the PlanetScale Postgres database and a Hyperdrive config for it - (`wrangler hyperdrive create adrive-production --connection-string=... --caching-disabled`) - → paste the printed id into `env.production.hyperdrive[0].id`. Keep the - connection string; `bun release` needs it as `DATABASE_URL`. -2. `wrangler r2 bucket create adrive-production` - (no id to paste — the bucket is bound by name.) -3. `wrangler kv namespace create AUTH_GUARD --env production` - → paste the printed id into `env.production.kv_namespaces[0].id`. -4. Semantic search needs no extra provisioning. Embeddings come from - the Workers AI `ai` binding already declared in `wrangler.jsonc` - `env.production`, and the vectors are stored in Postgres - (`file_chunks.embedding`, pgvector), which the `HYPERDRIVE` binding - already reaches. - - Note: `env.production` sets `SEMANTIC_SEARCH=required` (not `auto`), so a - missing `AI` binding fails the deploy **loudly** instead of silently - degrading to keyword search. That is intended. - -5. In the Cloudflare dashboard, open **Images → Transformations**, select - the zone that owns `CONTENT_DOMAIN` (`davis7.space` for - `files.davis7.space`), and enable transformations. Dashboard thumbnails - require this zone-level setting. - -## 3. Set the passcode secret (from `apps/web`) - -``` -wrangler secret put PASSCODE --env production -``` - -12+ characters, typed by the operator at the prompt — never put the -passcode in chat, a command argument, or a log. - -**Gotcha (expected):** on a first-ever setup the Worker shell doesn't -exist yet, so wrangler prompts _"There doesn't seem to be a Worker called -adrive-production. Do you want to create it?"_ — answer **yes**. This is -normal; it stages an empty Worker so the secret has somewhere to live -before the first deploy. - -## 4. Commit the provisioned ids - -The Hyperdrive `id` and KV `id` are **configuration, not secrets** — -commit the edited `wrangler.jsonc`. Two `bun release` preflight gates -depend on it: - -- **clean-tree gate** — the release refuses a dirty working tree, so the - pasted ids must be committed. -- **no-placeholder gate** — the release greps for `replace-with--*` - placeholders (e.g. `replace-with-production-...`) and refuses while any - remain. Pasting the real ids clears this. - -## 5. Deploy - -From the **repo root**: - -``` -bun release -``` - -`scripts/release.sh` runs the full gate → format check → type/lint → -tests → audit → build → deploy dry-run → Postgres migrations → deploy → append -the commit to `.release-history`. The two custom domains attach from the -`routes` in `env.production` automatically, provided the zone from step 1 -exists. - -## 6. Post-deploy verification gotchas (both hit on the davis7.space run) - -- **Transient 500s right after deploy.** The very first requests can 500 - for a few seconds while the Hyperdrive pool warms against the first live - request. Recheck once things settle — it clears on its own. -- **The second (content) custom domain lags the first.** The content - origin's DNS record and edge certificate propagate minutes behind the - dashboard origin. Worse, a **local DNS negative cache** can pin it as - broken _only on the operator's machine_: if you resolved the host before - the record existed, an NXDOMAIN is cached locally while public resolvers - already serve the new record. Fix by flushing local DNS — - `sudo dscacheutil -flushcache; sudo killall -HUP mDNSResponder` on macOS - — or wait out the negative TTL. To confirm the edge is actually up while - bypassing local DNS, resolve against Cloudflare directly: - `curl --resolve :443: https:///`. - -Expected live checks once both origins are up: - -- dashboard root → **200** -- unauth `GET /api/files` → **401** -- `GET https://./api/files` → **421** (dashboard API rejected on a content host) -- `GET /f/` → **421** (content rejected on dashboard origin) -- `GET https:///f/` → **421** (content needs an org host) -- `GET https://no-such-org./f/` → **404** -- missing file → **404** -- a known non-empty public file without a `Range` header → **200** with no - `Content-Range` -- the same file with `Range: bytes=0-99` → **206** with a valid `Content-Range` - -## 7. Backfill note (semantic search) - -Any files uploaded while the `AI` binding was absent sit in -`index_state = 'disabled'` and are backfilled by the maintenance cron at -about **5 files per 5 minutes**. The settings page's indexed-chunk count -shows progress. On a truly fresh instance there's usually nothing to -backfill; this matters when seeding a pre-existing corpus. - -## 8. Then - -- Run the **verify-deployment** skill (`.agents/skills/verify-deployment`) - as the acceptance gate — the release is not complete until it reports - PASS against the live deployment. -- Set up **independent backups** on a machine outside Cloudflare and - complete the restore drill (`docs/backup-restore.md`) before treating - the drive as the sole copy of anything. +# Deploy a fresh hosted a-drive instance + +Follow [release setup](../../../docs/release.md) for provisioning, exact +commands, migration compatibility, and rollback. This skill identifies the +bootstrap decisions and acceptance gates; historical single-tenant deploys +are not proof of the hosted architecture. + +## Establish the target + +- Confirm the intended Cloudflare account, database provider, dashboard + origin, content domain, resource names, and existing authorization. Keep + credentials out of command output, source, and reports. +- Use a separate, empty hosted Postgres target. Migration 0004 rejects + populated single-tenant data; do not bypass it with resets. Preserve the + original drive and backups. An existing-data import/cutover is separate + work described in the release runbook, not implicit in fresh bootstrap. +- Content lives under `https://.`; the dashboard must + be outside that domain. Configure the wildcard Worker route, proxied DNS, + and a certificate covering actual tenant hosts. A certificate for the + bare content host does not cover its subdomains. +- Inspect both `apps/web/wrangler.jsonc` and `apps/site/wrangler.jsonc`: + `bun release` deploys the app and landing-site Workers. Replace the example + routes and resource IDs with this target's values before releasing. + +## Provision and configure + +Use the release runbook's sequence and current CLI/provider interfaces: + +1. Provision Postgres with required extensions and run hosted migrations + using schema-admin credentials. Create a separate non-owner runtime + login with `adrive_app` privileges, no superuser/BYPASSRLS, and no schema + ownership. Hyperdrive uses that login with query caching disabled; + `DATABASE_URL` for migrations uses the schema administrator. +2. Provision the target R2 bucket and `AUTH_GUARD` KV namespace. Configure + `HYPERDRIVE`, `BUCKET`, `AUTH_GUARD`, `BROWSER`, rate limits, and scheduled + maintenance in the target environment. Environment bindings do not + inherit from local defaults. Semantic search stores vectors in Postgres; + supply the Workers AI binding when `SEMANTIC_SEARCH=required`. +3. Create the main, dead-letter, and parked queues exactly as described in + [Queues](../../../docs/release.md#queues). Bind the producer and both + consumers; the parked queue has no automatic consumer and retains + messages for 14 days. Configure monitoring and recovery before launch. +4. Configure real WorkOS authentication, the dashboard callback, and signed + webhook endpoint. Set `MAINTENANCE_SECRET`, `WORKOS_API_KEY`, + `WORKOS_CLIENT_ID`, `WORKOS_COOKIE_PASSWORD`, `WORKOS_WEBHOOK_SECRET`, and + intended `ADMIN_USER_IDS` via the provider's secret/configuration path. + Use [the launch checklist](../../../docs/launch-checklist.md) for current + requirements. Development fake auth/provider keys must remain disabled. +5. For paid launch, publish the plans and set `AUTUMN_SECRET_KEY` and + `AUTUMN_WEBHOOK_SECRET`; validate the integration in its sandbox using + [billing](../../../docs/billing.md). Configure URL scanning and cache + purge using [abuse operations](../../../docs/abuse.md), or record the + limitations of disabled optional providers. Enable zone image + transformations when required for content thumbnails. + +## Release and acceptance + +- Commit the target's nonsecret resource IDs/routes and confirm the release + checkout is clean. Preserve unrelated work; do not discard it to satisfy + the clean-tree gate. +- Run `bun release` from the repository root with `DATABASE_URL` pointing at + the intended hosted target. Read `scripts/release.sh` before execution: + it validates, builds, dry-runs, migrates Postgres, deploys both Workers, + and records the app revision. A partial deployment requires the runbook's + rollback/recovery decision; do not repeatedly deploy without diagnosis. +- Confirm live DNS, TLS, deployed resources, and revision. Investigate + errors from actual logs; do not assume 500 responses are normal warm-up. +- Run [verify-deployment](../verify-deployment/SKILL.md) with real WorkOS + access and disposable tenant data. Live provider and tenant-isolation + checks must not be replaced by fake/local test results. +- Complete [independent backups and an isolated restore drill](../../../docs/backup-restore.md), + [observability](../../../docs/observability.md), and the remaining launch + checklist. Return the reachable deployment URLs, deployed revision, + verification evidence, and any outstanding launch gates. A successful + deploy command alone does not complete the hosted launch. diff --git a/.agents/skills/verify-deployment/SKILL.md b/.agents/skills/verify-deployment/SKILL.md index 69f682f..0d343f5 100644 --- a/.agents/skills/verify-deployment/SKILL.md +++ b/.agents/skills/verify-deployment/SKILL.md @@ -1,238 +1,116 @@ --- name: verify-deployment -description: Verify a live a-drive deployment end to end through its real browser, HTTP, CLI, D1, and R2 boundaries. Use after deploying, after storage/auth/upload/routing changes, after migrations, and before trusting a-drive following Cloudflare configuration changes. Creates only uniquely-prefixed disposable files, purges them afterward, and produces a pass/fail report. +description: Verify a hosted a-drive deployment through real WorkOS browser sessions, tenant-scoped HTTP and CLI access, Postgres, R2, and background jobs. Use after deployment or changes to authentication, storage, routing, migrations, or provider configuration. Report evidence and clean up disposable verification data. --- # Verify an a-drive deployment -You are verifying a real, live deployment. Use whatever browser, -HTTP, and shell tooling you have; nothing here assumes a specific -framework. Work through every section, record each check as PASS, -FAIL, or SKIPPED (with reason), and finish with the report. - -Checks marked **[M]** are mandatory: the deployment cannot PASS if any -of them failed **or was skipped**. Unmarked checks are optional; they -may be SKIPPED with a documented reason. - -## Inputs (ask if not provided) - -- Dashboard origin (e.g. `https://drive.davis7.space`) -- Content origin (e.g. `https://files.davis7.space`) -- CLI path (repo checkout: `bun adrive`, needs Node 26+) -- Access method: the passcode (full run), or an existing read-write API - key (reduced run — see below) -- Whether destructive checks may run (default: **yes** — they touch only - files this run creates) - -**Access-mode branching:** passcode access enables the full run. With -only an API key, record as SKIPPED (reason: no passcode) every check -that needs a browser session or key management: browser sign-in, -wrong-passcode, dashboard uploads and previews, disposable key creation -(read-write, read-only, and short-expiry), the read-only-scope 403 -checks, the key-listing check, cookie-attribute checks, and -cookie-origin forgery checks. Because several of those are mandatory, a -key-only run can conclude at best INCONCLUSIVE — never PASS. - -## Safety boundaries (non-negotiable) - -- Generate a collision-resistant run prefix first: - `verify--`. Before - creating anything, search for the prefix and confirm nothing matches. - Every file, site, tag, and API key you create must carry it. -- Never modify, trash, rename, or purge anything without the prefix. -- Never print passcodes, API keys, session cookies, or signed URLs in - the report. Refer to them as ``; show only HTTP statuses, - header names/values that aren't credentials, and file ids. -- Purge only objects created during this run, and always attempt cleanup - even after failures. - -## 1. Preflight - -- **[M]** Both configured origins use `https://` and are different hosts. -- **[M]** Both origins resolve and respond (any status). -- Request `http://` on both hostnames: assert a redirect to HTTPS or a - refusal, and that no authenticated data or file content is served - over plain HTTP. -- Record the deployed commit if available (`.release-history` in the - repo, or `wrangler deployments list --env production` when wrangler - is authenticated) plus the current UTC time. -- **[M]** If wrangler is available: confirm the production env lists DB - (D1), BUCKET (R2), AUTH_GUARD (KV), HYPERDRIVE (Postgres), and AI - (Workers AI) bindings (`wrangler deploy --dry-run --env production` output). Without - wrangler this mandatory check is SKIPPED and the run cannot PASS. - -## 2. Authentication - -Run every negative check in a fresh browser context (or cookie-less -curl) so no prior session can mask a failure. - -- **[M]** Dashboard sign-in with the passcode succeeds in a real - browser. -- **[M]** In a fresh context: a wrong passcode fails with an error and - sets no session cookie. -- **[M]** In a fresh context: unauthenticated `GET /api/files` - returns 401. -- **[M]** Create a disposable **read-write** API key named with the run - prefix (dashboard settings), or complete CLI device authorization - (`bun adrive login `). -- **[M]** Create a disposable **read-only** key; verify `GET /api/files` - succeeds while each of these mutations returns 403: an upload, a - rename, a tag creation, a trash, and a version upload. -- **[M]** With the read-only key, `GET /api/auth/keys` must return 403 - (key inventory requires write scope). -- Create a key with a short expiry (e.g. two minutes), wait for it to - lapse, and confirm requests with it return 401. -- If the run may rotate the passcode (scratch deployment only — ask - first): create a session and a pending device authorization, change - the PASSCODE secret, wait for the maintenance cron, and confirm both - are revoked. On the production deployment SKIP with reason. - -## 3. Upload coverage - -Create small local test files: text (.txt), image (.png), PDF (.pdf), -and binary (a few hundred random bytes, .bin). Record each one's -SHA-256 before upload. - -- **[M]** Upload the text and image through the dashboard. -- **[M]** Upload the PDF and binary through the CLI (`bun adrive put`). -- **[M]** After each upload, verify via `GET /api/files/`: display - name, size, content type match the source. -- **[M]** Download each file and compare checksums with the source. -- Negative cases (verify the documented rejection status and that no - partial file, version, or R2 object is left behind): - - An upload whose declared size exceeds the per-file limit. - - A chunked upload with no Content-Length whose streamed bytes exceed - the per-file limit (the streaming reader must cut it off). - - A burst of uploads beyond the configured upload rate (429). - - If a scratch quota configuration is available, an upload beyond - `MAX_TOTAL_BYTES` (413); otherwise SKIP with reason. - -## 4. File lifecycle (on prefixed files only) - -- **[M]** List files via dashboard and CLI; all uploads appear. -- Create enough prefixed files (or use a page-size override) to force at - least two pages; confirm both clients follow the cursor and the - combined listing has no gaps or duplicates. -- **[M]** Search for the run prefix; results contain the uploads. -- Semantic search: settings shows semantic enabled with a nonzero - indexed-chunk count once the disposable text upload has been indexed - (wait out one or two 5-minute cron ticks); a conceptual query for the - text file's _content_ (words related to, but not literally in, its - text) surfaces it. SKIP with reason if indexing hasn't caught up - within a reasonable wait. -- **[M]** Rename one file (dashboard or CLI) and confirm the new name. -- **[M]** Create a prefixed tag, add it to a file, filter by it, remove - it. -- **[M]** Upload a replacement version to one file; version history - shows both versions; download the current and the older version and - verify each checksum against the right source bytes. -- Upload enough versions to one file (or use `versionsLimit=1`) to force - version-history pagination; follow the cursor and confirm no gaps or - duplicates across pages. -- **[M]** Trash a file, confirm it lists under trash, restore it, - confirm it's back. -- **[M]** Purge one file and confirm it is gone from list and trash. - -## 5. Public/private behavior - -- **[M]** A public file's content URL (`/f/`) loads with - **no** credentials (fresh private browser context or plain curl). -- **[M]** A private file's content URL without a grant returns 404. -- **[M]** A private file downloads through the dashboard (grant flow - works). -- **[M]** Grant integrity — three separate checks, each rejected: - - a grant past its expiry (wait out a short-lived grant, or note the - TTL makes this impractical and SKIP with reason), - - a granted URL with the expiry parameter altered, - - a granted URL with the signature parameter altered. -- **[M]** Upload a small prefixed HTML file: it must be forced public - and must render only on the content origin. -- **[M]** `GET /api/files` returns 421 (dashboard APIs rejected - on the content origin). -- **[M]** `GET /f/` returns 421 (content rejected on the - dashboard origin). - -## 6. Content and preview validation - -- **[M]** Markdown preview renders for a prefixed .md upload; a link - like `[x](javascript:alert(1))` in it must not produce a clickable - javascript: link, and raw `