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.
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 `