From acf87d38ca31c008f4a73dd3c554d1e2a2af68ca Mon Sep 17 00:00:00 2001 From: Jacob Zweifel Date: Wed, 26 Aug 2026 01:02:21 +0000 Subject: [PATCH] Merge origin/main: fold the PD-9 domain-lock note into the updated header Both sides rewrote NEXT.md's "Last updated" line; the merged line keeps the sign-off landing note and main's CUSTOM_DOMAIN_ALLOWLIST deploy warning together. Co-Authored-By: Claude Fable 5 --- apps/web/.dev.vars.example | 4 ++++ apps/web/src/app.d.ts | 4 ++++ apps/web/src/lib/server/hosting/access.ts | 15 +++++++++++++++ apps/web/src/lib/server/hosting/hosting.test.ts | 29 +++++++++++++++++++++++++++++ apps/web/src/lib/server/hosting/index.ts | 1 + apps/web/src/routes/admin/hosting/+page.server.ts | 11 +++++++++++ apps/web/src/routes/admin/hosting/+page.svelte | 51 +++++++++++++++++++++++++++++++++++---------------- apps/web/wrangler.jsonc | 9 +++++++++ docs/NEXT.md | 10 +++++++++- docs/decisions/product-decisions.md | 1 + docs/runbooks/first-deploy.md | 7 +++++++ 11 file(s) changed, 125 insertion(s)(+), 17 deletion(s)(-) diff --git a/apps/web/.dev.vars.example b/apps/web/.dev.vars.example --- a/apps/web/.dev.vars.example +++ b/apps/web/.dev.vars.example @@ -9,3 +9,7 @@ # Set APP_HOST to serve claimed subdomains locally: with the value below, # http://.localhost:5173 renders that slug's site. Leave unset to have # every host serve the app. # APP_HOST=localhost:5173 +# Who may add a custom domain. Whitespace- or comma-separated DIDs; `*` for +# every account, unset for none. Subdomains are governed by SUBDOMAIN_ALLOWLIST, +# a var in wrangler.jsonc, which dev picks up on its own. +# CUSTOM_DOMAIN_ALLOWLIST=* diff --git a/apps/web/src/app.d.ts b/apps/web/src/app.d.ts --- a/apps/web/src/app.d.ts +++ b/apps/web/src/app.d.ts @@ -18,6 +18,10 @@ env: { DB: D1Database; /** The host the app itself lives on; unset disables host-based serving. */ APP_HOST?: string; + /** DIDs that may claim a subdomain; `*` for all, unset for none. */ + SUBDOMAIN_ALLOWLIST?: string; + /** DIDs that may add a custom domain; `*` for all, unset for none. */ + CUSTOM_DOMAIN_ALLOWLIST?: string; /** Zone for custom-hostname provisioning; unset skips provisioning. */ CLOUDFLARE_ZONE_ID?: string; CLOUDFLARE_API_TOKEN?: string; diff --git a/apps/web/src/lib/server/hosting/access.ts b/apps/web/src/lib/server/hosting/access.ts new file mode 100644 --- /dev/null +++ b/apps/web/src/lib/server/hosting/access.ts @@ -0,0 +1,15 @@ +/** + * Who may take a new host. Each allowlist holds DIDs, separated by whitespace + * or commas; `*` admits every account and an unset list admits none. + */ + +export function allows(allowlist: string | undefined, did: string): boolean { + const entries = (allowlist ?? '').split(/[\s,]+/).filter((entry) => entry !== ''); + return entries.includes('*') || entries.includes(did); +} + +export const SUBDOMAIN_LOCKED = + 'New subdomains are paused. Anything already claimed keeps working.'; + +export const CUSTOM_DOMAIN_LOCKED = + 'New custom domains are paused. Domains already added keep working.'; diff --git a/apps/web/src/lib/server/hosting/hosting.test.ts b/apps/web/src/lib/server/hosting/hosting.test.ts --- a/apps/web/src/lib/server/hosting/hosting.test.ts +++ b/apps/web/src/lib/server/hosting/hosting.test.ts @@ -3,6 +3,7 @@ import { InvalidInput } from '../mooring'; import { normalizeSlug } from './slugs'; import { normalizeDomain, verificationRecordName } from './domains'; import { classifyHost, isAppOnlyPath } from './hosts'; +import { allows } from './access'; import { lookupTxt, verifyDomainOwnership } from './dns'; import { acmeChallengeBody, @@ -310,3 +311,31 @@ }; await expect(customHostnameStatus(failing, 'example.com')).rejects.toThrow('exceeded quota'); }); }); + +describe('allows', () => { + it('admits nobody when the list is unset or empty', () => { + expect(allows(undefined, DID)).toBe(false); + expect(allows('', DID)).toBe(false); + expect(allows(' ', DID)).toBe(false); + }); + + it('admits everybody on a wildcard', () => { + expect(allows('*', DID)).toBe(true); + expect(allows('did:plc:other, *', DID)).toBe(true); + }); + + it('admits a listed DID, separated by commas or whitespace', () => { + expect(allows(DID, DID)).toBe(true); + expect(allows(`did:plc:other, ${DID}`, DID)).toBe(true); + expect(allows(`did:plc:other\n${DID}\n`, DID)).toBe(true); + }); + + it('rejects an unlisted DID', () => { + expect(allows('did:plc:other', DID)).toBe(false); + }); + + it('matches whole entries, not prefixes', () => { + expect(allows(`${DID}456`, DID)).toBe(false); + expect(allows(DID, `${DID}456`)).toBe(false); + }); +}); diff --git a/apps/web/src/lib/server/hosting/index.ts b/apps/web/src/lib/server/hosting/index.ts --- a/apps/web/src/lib/server/hosting/index.ts +++ b/apps/web/src/lib/server/hosting/index.ts @@ -1,4 +1,5 @@ export { RESERVED_SLUGS, normalizeSlug } from './slugs'; +export { CUSTOM_DOMAIN_LOCKED, SUBDOMAIN_LOCKED, allows } from './access'; export { normalizeDomain, verificationRecordName } from './domains'; export { classifyHost, isAppOnlyPath } from './hosts'; export type { HostClass } from './hosts'; diff --git a/apps/web/src/routes/admin/hosting/+page.server.ts b/apps/web/src/routes/admin/hosting/+page.server.ts --- a/apps/web/src/routes/admin/hosting/+page.server.ts +++ b/apps/web/src/routes/admin/hosting/+page.server.ts @@ -3,7 +3,10 @@ import type { Actions, PageServerLoad } from './$types'; import { requireSession } from '$lib/server/admin'; import { InvalidInput, ensureSite } from '$lib/server/mooring'; import { + CUSTOM_DOMAIN_LOCKED, + SUBDOMAIN_LOCKED, addCustomDomain, + allows, claimSubdomain, cloudflareApiFromEnv, customDomainsForDid, @@ -49,6 +52,8 @@ return { did: admin.did, appHost: event.platform!.env.APP_HOST ?? 'mooring.page', provisioning: api !== undefined, + mayClaimSubdomain: allows(event.platform!.env.SUBDOMAIN_ALLOWLIST, admin.did), + mayAddDomain: allows(event.platform!.env.CUSTOM_DOMAIN_ALLOWLIST, admin.did), slug, domains, tls @@ -58,6 +63,9 @@ export const actions: Actions = { claim: async (event) => { const admin = await requireSession(event); + if (!allows(event.platform!.env.SUBDOMAIN_ALLOWLIST, admin.did)) { + return fail(403, { message: SUBDOMAIN_LOCKED }); + } const raw = (await event.request.formData()).get('slug'); let slug: string; @@ -89,6 +97,9 @@ }, addDomain: async (event) => { const admin = await requireSession(event); + if (!allows(event.platform!.env.CUSTOM_DOMAIN_ALLOWLIST, admin.did)) { + return fail(403, { message: CUSTOM_DOMAIN_LOCKED }); + } const raw = (await event.request.formData()).get('domain'); const appHost = event.platform!.env.APP_HOST; diff --git a/apps/web/src/routes/admin/hosting/+page.svelte b/apps/web/src/routes/admin/hosting/+page.svelte --- a/apps/web/src/routes/admin/hosting/+page.svelte +++ b/apps/web/src/routes/admin/hosting/+page.svelte @@ -32,8 +32,13 @@

-

Releasing frees the name for anyone to claim; links to it stop working.

- {:else} +

+ + Releasing frees the name for anyone to claim; links to it stop working.{#if !data.mayClaimSubdomain} + While new claims are paused, you will not be able to claim another.{/if} + +

+ {:else if data.mayClaimSubdomain}

Claim a subdomain to put your site on the web. One per account; first come, first served.

+ {:else} +

New subdomains are paused.

{/if}

Custom domains

@@ -84,22 +91,29 @@ {/if} {/each} + {:else if data.mayAddDomain} +

None yet. A custom domain serves your site at an address you own.

+ {/if} + {#if data.mayAddDomain} +
+ + +
+

+ + Ownership is proven with a TXT record tied to your atproto identity, not to Mooring — the + domain stays yours. + +

{:else} -

None yet. A custom domain serves your site at an address you own.

+

+ New custom domains are paused.{#if data.domains.length > 0} + Domains already on your account keep working.{/if} +

{/if} -
- - -
-

- - Ownership is proven with a TXT record tied to your atproto identity, not to Mooring — the - domain stays yours. - -

diff --git a/apps/web/wrangler.jsonc b/apps/web/wrangler.jsonc --- a/apps/web/wrangler.jsonc +++ b/apps/web/wrangler.jsonc @@ -20,6 +20,11 @@ // CLOUDFLARE_ZONE_ID — the mooring.page zone id (dash → Overview), // set here as a var. // Both unset = provisioning skipped; domains still verify and serve. "CLOUDFLARE_ZONE_ID": "59558dba16a1fbf920d86f627d1d5822", + // Who may claim a subdomain: whitespace/comma-separated DIDs, `*` for + // every account, unset for none. Open here because a subdomain costs + // nothing per account; an instance that wants them invite-only replaces + // this with a DID list. + "SUBDOMAIN_ALLOWLIST": "*", // OAUTH_SCOPE — unset falls back to 'atproto transition:generic'. // The granular scope needs the published page.mooring.* lexicons and // the _lexicon.mooring.page TXT record to resolve; comment this out @@ -60,4 +65,8 @@ // SESSION_SECRET — HMAC key for the session cookie. // CLOUDFLARE_API_TOKEN — scoped token (Zone → SSL and Certificates → // Edit, for the mooring.page zone) for // custom-hostname provisioning. + // CUSTOM_DOMAIN_ALLOWLIST — DIDs that may add a custom domain; + // whitespace/comma separated, `*` for every + // account, unset for none. A secret rather than a + // var because the list names individual people. } diff --git a/docs/NEXT.md b/docs/NEXT.md --- a/docs/NEXT.md +++ b/docs/NEXT.md @@ -2,13 +2,17 @@ # NEXT — the queue for future sessions The flight plan. Each item carries enough context to start cold; update this file whenever an item lands (move it to "Done") or a new one is queued. Decisions made while working an item still go through `decisions/` as usual. -_Last updated: 2026-08-25 (sign-off band + Atmosphere actions landed from the theme-UX tail; the `signOff` lexicon republication is queued for Jacob. Critique trend for the push: 25 → 31 → 29 → 32, snapshots in `.impeccable/critique/`)._ +_Last updated: 2026-08-25 (sign-off band + Atmosphere actions landed from the theme-UX tail; the `signOff` lexicon republication is queued for Jacob. PR #27 merged: custom domains are invite-only pending pricing — **PD-9, and it needs `CUSTOM_DOMAIN_ALLOWLIST` set on the next deploy or nobody, Jacob included, can add one** (queue item 0 below); subdomains stay open as PD-4's free tier. Critique trend for the push: 25 → 31 → 29 → 32, snapshots in `.impeccable/critique/`)._ ## Where things stand Feasibility is done and the verdict was **build it** (see `FEASIBILITY.md`). All founding decisions are recorded: PD-1..8 in `decisions/product-decisions.md`, ADRs 0001–0014 in `decisions/adr/` — all Accepted, including ADR 0008 (v1 scope), ADR 0010 (tech stack: TypeScript + SvelteKit, first-party `@atproto/*`, direct PDS reads for v1, BEAM benched for v2 services), ADR 0011 (hosting: Cloudflare Workers + Cloudflare for SaaS; DB: SQLite-family via D1 hosted / file self-host), ADR 0012 (`page.mooring.*` record schemas — draft lexicons in `lexicons/page/mooring/`), and ADR 0013 (the hosting registry — subdomain slugs and custom domains — lives in D1). The product is **Mooring** at **mooring.page** (domain secured); the repo keeps the codename Fahrenheit. **The v1 build is underway** (Jacob green-lit 2026-08-03): npm workspaces with `apps/web` (SvelteKit + adapter-cloudflare, atproto OAuth sign-in with the Workers compat layer, D1-backed stores) and `packages/lexicons` (convention tests), plus CI. ## Queued, roughly in order + +### 0. Operator step, before or with the next deploy + +`wrangler secret put CUSTOM_DOMAIN_ALLOWLIST` — a whitespace- or comma-separated DID list, starting with Jacob's `did:web:malpercio.dev` and `did:plc:o3zuar7kk2mrz7d4sqxdisy2` plus whoever is invited. Unset means **nobody** can add a custom domain, so a deploy without it locks the operator out too (domains already added keep serving either way). `*` opens it to every account — the value that lifts the lock when PD-4's paid tier ships. Subdomains need no secret: `SUBDOMAIN_ALLOWLIST` is a var in `wrangler.jsonc`, set to `*`, and deploys with the code. ### 1. Continue v1 (per ADR 0008 — the scope is ratified; don't re-scope) @@ -33,7 +37,11 @@ - **Optional runway**: Skyseed grants ($5–25K) are compatible with the indie model (PD-3) if wanted. ## Done +<<<<<<< HEAD - 2026-08-25 — **The airmail sign-off band landed — the letter signs off, with Atmosphere-native actions** (tail items "sign-off block" and "Atmosphere-native actions", resolved together). Three directions were mocked on the design canvas (letter closing / postmark band / "From" block); Jacob picked the **postmark band**: a sunk-paper band above the footer where the airmail stripe returns to bookend the page, a rotated circular postmark (handle + render-month, airmail-blue ink), the owner's name, and three actions — Follow (Bluesky profile link, where Follow/Message actually live; Bluesky has no follow/DM intent URL), Say hello (`bsky.app/intent/compose` prefilled with the @mention), Write to me (mailto, only when `contactEmail` is set). The valediction is **configurable, not default**: new optional `signOff` field on `page.mooring.site` (add-optional-only per ADR 0012; republication queued above) flowing lexicon → record builder → settings form → `SiteView` → `SiteLayout`; empty renders the name alone — Jacob was unsure "Warmly," suits every site, so it's a choice ("whimsy seasons the dish"). Band renders on every page like the footer; verified locally against live PDS data (jzweifel.dev): actions resolve, dark tokens flip, no horizontal overflow at 375px, chips ≥24px targets. +======= +- 2026-08-25 — **Costly hosting actions gated behind DID allowlists** (PD-9, PR #27). `hosting/access.ts` holds `allows(list, did)`; the two actions that take a *new* host — `claim` and `addDomain` in `/admin/hosting` — check it and 403, and the page hides their forms. On mooring.page only custom domains are actually locked: subdomains are PD-4's free tier and cost nothing per account, so `SUBDOMAIN_ALLOWLIST` ships as a `*` var while `CUSTOM_DOMAIN_ALLOWLIST` is a secret naming individuals. The subdomain gate stays in the code because a self-hosted instance may want it, and flipping it is a config change. Nothing else needed gating: `release`, `verifyDomain` and `removeDomain` already 404 on rows the account does not hold, so accounts that already have a subdomain or a domain keep them, keep serving, and can still retry TLS. Serving is untouched — `hooks.server.ts` never consults the lists. 8 new unit tests (194 in `web`). Caveat by design: a grandfathered account that *releases* its subdomain cannot reclaim one while the lock is on, and the release copy says so. +>>>>>>> origin/main - 2026-08-25 — **Critique rounds 3–4 and their fix PRs (#24, #25) merged and deployed.** Round 3 (29/40) discovered pre-existing issues all earlier reviews missed; PR #24 fixed the mechanical ones: per-site favicons (the stock Svelte logo was the P0 — sites use the record `icon` blob → avatar fallback via `/blob`, app pages get an airmail-striped icon), sifa positions/education sorted newest-first (record order was never meaningful), `yearOf`/`yearSpan` tolerant of formless date strings, the career "More" disclosure made reversible with focus retained, visible contact address in the footer, banner `srcset` + 768 blob width, absolute `og:image` via `/blob`, `color-scheme`/`theme-color`. Round 4 scored **32/40** ("an authored design with a real point of view") and caught PR #24's own regression; PR #25 fixed it: footer wraps at phone widths (the visible email had overflowed the nowrap row), the career summary leads with its full first paragraph so the quantified proof ("~1M → 5M+") is visible without clicking, Skills sublabel, "+21 more"→"Show fewer" swap, ≥24px disclosure targets, banner double-download eliminated. Working pattern that held all push: dual-assessment critique → decisions with Jacob (AskUserQuestion) → design canvas when the change is visual → PR with local verification against live PDS data → deploy → re-critique. - 2026-08-25 — **PR #22 merged: contact affordance, career clamp, blob proxy, polish.** The site record gained optional `contactEmail` (admin settings field; renders a "Write to me" mailto stamp in every hero and a filled "Say hello →" chip on the Open to row — answering the re-critique's P0). The sifa career summary clamps to two sentences behind a native disclosure; record-level curation fields stay queued. Profile images serve through `/blob/{did}/{cid}?w=` (ADR 0015, **Accepted**): DID-document-pinned source, width allowlist, immutable cache, `cf.image` transform with pass-through fallback. Sweep: own-host links suppressed in bios, single writing link, consistent new-tab externals, permalink `aria-label`s, ≥24px tap targets, dark-mode airmail token, " · " paragraph separator in meta descriptions, avatar alt text. Re-critique of the deployed overhaul scored **31/40** (from 25; dual-assessment snapshots in `.impeccable/critique/`). - 2026-08-24 — **Theme UX overhaul landed** (PR #20 merged: linkification everywhere, two CSS specificity/background bugs, sifa `startedAt`/`endedAt` date fix, writing URLs joined from publication url + doc path, skill cap, dates in `