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.
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 `
{:else}
- New custom domains are paused while pricing is settled.{#if data.domains.length > 0}
+ New custom domains are paused.{#if data.domains.length > 0}
Domains already on your account keep working.{/if}