diff --git a/apps/web/.dev.vars.example b/apps/web/.dev.vars.example index 8b579ab..c408cd9 100644 --- a/apps/web/.dev.vars.example +++ b/apps/web/.dev.vars.example @@ -9,3 +9,7 @@ # 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 index e9777bb..c3be3bf 100644 --- a/apps/web/src/app.d.ts +++ b/apps/web/src/app.d.ts @@ -18,6 +18,10 @@ declare global { 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 index 0000000..7ee9de4 --- /dev/null +++ b/apps/web/src/lib/server/hosting/access.ts @@ -0,0 +1,17 @@ +/** + * Who may take a new host. Each allowlist holds DIDs, separated by whitespace + * or commas; `*` admits every account and an unset list admits none. Hosts + * already taken are unaffected — the remaining hosting actions only touch rows + * the account already holds. + */ + +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 while pricing is settled. Anything already claimed keeps working.'; + +export const CUSTOM_DOMAIN_LOCKED = + 'New custom domains are paused while pricing is settled. 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 index 263754a..26d7c07 100644 --- 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 @@ describe('custom hostname provisioning', () => { 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 index 27099bf..6106512 100644 --- 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 index 3637afc..39422c7 100644 --- 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 @@ export const load: PageServerLoad = async (event) => { 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 load: PageServerLoad = async (event) => { 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 @@ export const actions: Actions = { 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 index 6ba4e48..6bebe0d 100644 --- 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 while pricing is settled.

{/if}

Custom domains

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

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

{/if} -
- - -
-

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

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

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

+ {:else} +

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

+ {/if} diff --git a/apps/web/wrangler.jsonc b/apps/web/wrangler.jsonc index 16bdc66..5673c8f 100644 --- a/apps/web/wrangler.jsonc +++ b/apps/web/wrangler.jsonc @@ -20,6 +20,11 @@ // 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 @@ // 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 index 7c421c4..2ff842f 100644 --- a/docs/NEXT.md +++ b/docs/NEXT.md @@ -2,7 +2,7 @@ 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 (**all operator steps done** — deployed, contactEmail set, Image Transformations live (avatar 966KB→17KB webp), /about written, lexicons republished. Critique 3 ran: 25 → 31 → 29, the dip being newly discovered pre-existing issues; the mechanical fixes for them are in the current PR)._ +_Last updated: 2026-08-25 (custom domains are now invite-only pending pricing — **PD-9, and it needs `CUSTOM_DOMAIN_ALLOWLIST` set on the next deploy or nobody, Jacob included, can add one**; subdomains stay open as PD-4's free tier. Earlier that day: all operator steps done — deployed, contactEmail set, Image Transformations live (avatar 966KB→17KB webp), /about written, lexicons republished. Critique 3 ran: 25 → 31 → 29, the dip being newly discovered pre-existing issues; the mechanical fixes for them are in the current PR)._ ## Where things stand @@ -10,6 +10,10 @@ Feasibility is done and the verdict was **build it** (see `FEASIBILITY.md`). All ## 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) Done so far: OAuth login (loopback dev client; hosted-client path ready pending a real key + deploy) with D1-backed state/session stores; lexicon convention tests; **read-only adapters** for Bluesky (profile + posts, replies filtered), standard.site (documents/publications, `pub.leaflet.document` fallback only when no standard.site docs exist), and sifa (profile/positions/education/skills, defensively parsed) — fetch-injected modules in `apps/web/src/lib/server/atmosphere/` with unit tests, plus `detectSources` (drives the ADR 0012 default section order) and a source-overview admin page; **site/page authoring** — record builders, PDS writes through the OAuth session, and the `/admin` + `/admin/pages` CRUD routes; **the professional-presence theme** — the render pipeline in `apps/web/src/lib/server/render/` and the public routes at `/s/[handle]`. Remaining, roughly in dependency order: @@ -30,6 +34,8 @@ Done so far: OAuth login (loopback dev client; hosted-client path ready pending ## Done +- 2026-08-25 — **Costly hosting actions gated behind DID allowlists** (PD-9). `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 with a "paused while pricing is settled" message, 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 `*`. 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. + - 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 `