···30303131This runs in sequence: issues the ACM certificate (waits for DNS validation, ~1–2 min), deploys WAF and CloudFront, creates Route 53 records, re-deploys the API with the correct `AllowedOrigin`, updates `public/.well-known/did.json`, rebuilds the frontend, and syncs the site to S3.
32323333+### User custom blog domains
3434+3535+Users bind their own domains to publications self-service (see "Custom blog domains" in `docs/architecture.md`). Deployment order for the feature:
3636+3737+```sh
3838+AWS_PROFILE=production DOMAIN_NAME=lemma.pub make -C infra create-aws-cloudfront # tenant distribution + connection group
3939+AWS_PROFILE=production DOMAIN_NAME=lemma.pub make -C infra create-aws-api # /domains routes, IAM, env vars
4040+AWS_PROFILE=production make -C infra update-aws-api # new Lambda code
4141+AWS_PROFILE=production DOMAIN_NAME=lemma.pub make -C infra deploy-site # VITE_SITE_DOMAIN + tenant invalidation
4242+AWS_PROFILE=production make -C infra update-aws-indexer
4343+AWS_PROFILE=production make -C infra update-aws-backfill
4444+```
4545+4646+`deploy-site` invalidates every distribution tenant (`invalidate-tenants`) in addition to the site distribution — tenants have no aliases, so the alias-based `invalidate-site` lookup never finds them.
4747+4848+**Orphaned tenant sweep** — deleting a publication record directly on a PDS bypasses the API, so its CloudFront distribution tenant is never deleted (the indexer only removes the DynamoDB items). Periodically compare tenants against claims and delete tenants whose domain has no `domain#<host>` item:
4949+5050+```sh
5151+# List all tenants of the multi-tenant distribution
5252+aws cloudfront list-distribution-tenants --profile production \
5353+ --association-filter DistributionId=<TenantDistributionId> \
5454+ --query 'DistributionTenantList[].[Id,Domains[0].Domain]' --output table
5555+5656+# For each domain with no matching DynamoDB item (PK=domain#<host>, SK=info):
5757+# disable, then delete the tenant (each step needs the current ETag)
5858+aws cloudfront get-distribution-tenant --profile production --identifier <tenantId>
5959+aws cloudfront update-distribution-tenant --profile production --id <tenantId> --enabled false --if-match <etag> ...
6060+aws cloudfront delete-distribution-tenant --profile production --id <tenantId> --if-match <new-etag>
6161+```
6262+6363+**Troubleshooting a stuck domain** — `GET /domains` surfaces `domainStatus`/`certStatus` from CloudFront. Cert stuck in `pending-validation` almost always means the user's CNAME is missing or wrong (`dig <domain>` should return the connection group routing endpoint). Validation can take up to an hour after the CNAME propagates; only one pending certificate request is allowed per tenant.
6464+3365### Indexer
34663567**First deploy** — creates the EC2 instance. The `UserData` cloud-init script runs once on first boot: installs Node.js 24, downloads `indexer.zip` from S3, writes the systemd unit with `DYNAMODB_TABLE`, `AWS_REGION`, and `CURSOR_FILE` baked in, then enables and starts the service. **Idempotent** — CF deploy uses `--no-fail-on-empty-changeset`; re-running does not update code on a running instance.
···11+import { useEffect, useState } from 'react'
22+import type { DomainPublication } from '~/lib/api-client'
33+import { resolveDomainPublication } from '~/lib/domain-mode'
44+import { PublicationView } from '~/routes/pub'
55+import { PostView } from '~/routes/post'
66+import CenteredMessage from '~/components/CenteredMessage'
77+import Spinner from '~/components/Spinner'
88+99+// undefined = resolving, null = no publication bound to this domain
1010+function useDomainPublication(): DomainPublication | null | undefined {
1111+ const [pub, setPub] = useState<DomainPublication | null | undefined>(undefined)
1212+ useEffect(() => {
1313+ let cancelled = false
1414+ resolveDomainPublication().then((resolved) => {
1515+ if (!cancelled) setPub(resolved)
1616+ })
1717+ return () => { cancelled = true }
1818+ }, [])
1919+ return pub
2020+}
2121+2222+function DomainLoading() {
2323+ return (
2424+ <div className="flex-1 flex items-center justify-center">
2525+ <Spinner />
2626+ </div>
2727+ )
2828+}
2929+3030+function DomainNotFound() {
3131+ return <CenteredMessage message="No publication is configured for this domain." error />
3232+}
3333+3434+// Root of a custom blog domain: the publication page.
3535+export function DomainHome() {
3636+ const pub = useDomainPublication()
3737+3838+ useEffect(() => {
3939+ if (!pub) return
4040+ document.title = pub.name
4141+ let feedLink = document.head.querySelector<HTMLLinkElement>('link[rel="alternate"][type="application/atom+xml"]')
4242+ if (!feedLink) {
4343+ feedLink = document.createElement('link')
4444+ feedLink.rel = 'alternate'
4545+ feedLink.type = 'application/atom+xml'
4646+ document.head.appendChild(feedLink)
4747+ }
4848+ feedLink.href = `${window.location.origin}/feed.xml`
4949+ return () => { feedLink?.remove() }
5050+ }, [pub])
5151+5252+ if (pub === undefined) return <DomainLoading />
5353+ if (!pub) return <DomainNotFound />
5454+ return <PublicationView id={pub.did} rkey={pub.rkey} domainMode />
5555+}
5656+5757+// /<rkey> on a custom blog domain: a post page.
5858+export function DomainPost({ rkey }: { rkey: string | undefined }) {
5959+ const pub = useDomainPublication()
6060+6161+ if (pub === undefined) return <DomainLoading />
6262+ if (!pub) return <DomainNotFound />
6363+ return <PostView userId={pub.did} pubRkey={pub.rkey} rkey={rkey} domainMode />
6464+}
+113-2
app/lib/api-client.ts
···2929 name: string
3030 description?: string
3131 authorHandle?: string
3232+ customDomain?: string
3233 preferences: { showInDiscover: boolean }
3334}
3435···373374export async function updatePublication(
374375 session: OAuthSession,
375376 rkey: string,
376376- data: { name: string; description?: string; showInDiscover: boolean },
377377+ data: { name: string; description?: string; showInDiscover: boolean; url?: string },
377378): Promise<void> {
378379 const did = session.sub
379380 const pdsUrl = await resolvePdsUrl(did)
380381381381- const url = `${window.location.origin}/${did}/pub/${rkey}`
382382+ // Preserve the record's existing url unless the caller passes one — it may
383383+ // be a custom domain, which recomputing from window.location would clobber.
384384+ let url = data.url
385385+ if (!url) {
386386+ try {
387387+ const existingRes = await session.fetchHandler(
388388+ `${pdsUrl}/xrpc/com.atproto.repo.getRecord?repo=${encodeURIComponent(did)}&collection=site.standard.publication&rkey=${encodeURIComponent(rkey)}`,
389389+ { method: 'GET' },
390390+ )
391391+ if (existingRes.ok) {
392392+ const { value } = (await existingRes.json()) as { value: { url?: string } }
393393+ url = value.url
394394+ }
395395+ } catch {
396396+ // fall through to the canonical site URL
397397+ }
398398+ }
399399+ url ??= `${window.location.origin}/${did}/pub/${rkey}`
382400 const icon = await uploadPublicationIcon(session)
383401384402 const record: Record<string, unknown> = {
···410428 body: JSON.stringify({
411429 name: data.name,
412430 description: data.description?.trim() || undefined,
431431+ url,
413432 preferences: { showInDiscover: data.showInDiscover },
414433 }),
415434 })
···417436 const body = await res.text().catch(() => '')
418437 throw new Error(`API error (${res.status})${body ? `: ${body}` : ''}`)
419438 }
439439+}
440440+441441+export type DomainPublication = {
442442+ did: string
443443+ rkey: string
444444+ atUri: string
445445+ url: string
446446+ name: string
447447+ description?: string
448448+ authorHandle?: string
449449+ preferences: { showInDiscover: boolean }
450450+}
451451+452452+// Public endpoint used by the SPA when served from a custom domain.
453453+export async function resolvePublicationByDomain(domain: string): Promise<DomainPublication | null> {
454454+ const res = await fetch(`${apiUrl()}/publications/by-domain?domain=${encodeURIComponent(domain)}`)
455455+ if (res.status === 404) return null
456456+ if (!res.ok) throw new Error(`API error (${res.status})`)
457457+ return res.json() as Promise<DomainPublication>
458458+}
459459+460460+export class DomainsUnavailableError extends Error {
461461+ constructor() {
462462+ super('Custom domains are not available on this deployment')
463463+ this.name = 'DomainsUnavailableError'
464464+ }
465465+}
466466+467467+export type CustomDomainStatus = {
468468+ domain: string
469469+ status: 'pending' | 'active'
470470+ domainStatus?: string
471471+ certStatus?: string
472472+ previousUrl?: string
473473+ cname: { name: string; value: string }
474474+}
475475+476476+export async function addCustomDomain(
477477+ session: OAuthSession,
478478+ pubRkey: string,
479479+ domain: string,
480480+): Promise<CustomDomainStatus> {
481481+ const pdsUrl = await resolvePdsUrl(session.sub)
482482+ const token = await getServiceToken(session, pdsUrl)
483483+ const res = await fetch(`${apiUrl()}/domains`, {
484484+ method: 'POST',
485485+ headers: { Authorization: `Bearer ${token}`, 'Content-Type': 'application/json' },
486486+ body: JSON.stringify({ domain, pubRkey }),
487487+ })
488488+ if (res.status === 501) throw new DomainsUnavailableError()
489489+ if (!res.ok) {
490490+ const body = (await res.json().catch(() => null)) as { error?: string } | null
491491+ throw new Error(body?.error ?? `API error (${res.status})`)
492492+ }
493493+ return res.json() as Promise<CustomDomainStatus>
494494+}
495495+496496+// Returns null when the publication has no custom domain configured.
497497+export async function getCustomDomainStatus(
498498+ session: OAuthSession,
499499+ pubRkey: string,
500500+): Promise<CustomDomainStatus | null> {
501501+ const pdsUrl = await resolvePdsUrl(session.sub)
502502+ const token = await getServiceToken(session, pdsUrl)
503503+ const res = await fetch(`${apiUrl()}/domains?rkey=${encodeURIComponent(pubRkey)}`, {
504504+ headers: { Authorization: `Bearer ${token}` },
505505+ })
506506+ if (res.status === 501) throw new DomainsUnavailableError()
507507+ if (res.status === 404) return null
508508+ if (!res.ok) {
509509+ const body = (await res.json().catch(() => null)) as { error?: string } | null
510510+ throw new Error(body?.error ?? `API error (${res.status})`)
511511+ }
512512+ return res.json() as Promise<CustomDomainStatus>
513513+}
514514+515515+export async function removeCustomDomain(
516516+ session: OAuthSession,
517517+ pubRkey: string,
518518+): Promise<{ previousUrl?: string }> {
519519+ const pdsUrl = await resolvePdsUrl(session.sub)
520520+ const token = await getServiceToken(session, pdsUrl)
521521+ const res = await fetch(`${apiUrl()}/domains?rkey=${encodeURIComponent(pubRkey)}`, {
522522+ method: 'DELETE',
523523+ headers: { Authorization: `Bearer ${token}` },
524524+ })
525525+ if (res.status === 501) throw new DomainsUnavailableError()
526526+ if (!res.ok) {
527527+ const body = (await res.json().catch(() => null)) as { error?: string } | null
528528+ throw new Error(body?.error ?? `API error (${res.status})`)
529529+ }
530530+ return res.json() as Promise<{ previousUrl?: string }>
420531}
421532422533export async function deletePublication(session: OAuthSession, rkey: string): Promise<void> {
+7
app/lib/auth-context.tsx
···99} from 'react'
1010import type { BrowserOAuthClient, OAuthSession } from '@atproto/oauth-client-browser'
1111import { recordAcceptedTerms } from '~/lib/api-client'
1212+import { isCustomDomain } from '~/lib/domain-mode'
12131314type AuthContextValue = {
1415 session: OAuthSession | null
···2728 const clientRef = useRef<BrowserOAuthClient | null>(null)
28292930 useEffect(() => {
3131+ // Custom blog domains are read-only: no OAuth client, no session. Signing
3232+ // in happens on the site's own domain.
3333+ if (isCustomDomain()) {
3434+ setLoading(false)
3535+ return
3636+ }
3037 // Dynamic import instead of a static import at the top of this file.
3138 // @atproto/oauth-client-browser instantiates BrowserRuntimeImplementation at
3239 // module evaluation time, and that constructor throws synchronously if
+11-1
app/lib/data-context.tsx
···4242}
43434444// Returns publications for the current site's origin, or null while loading.
4545+// Publications whose canonical url is their custom domain still belong to
4646+// this site, so they pass the filter too.
4547export function usePublications(): Publication[] | null {
4648 const { publications } = useData()
4749 return publications === undefined
4850 ? null
4949- : publications.filter((p) => { try { return new URL(p.url).origin === window.location.origin } catch { return false } })
5151+ : publications.filter((p) => {
5252+ try {
5353+ const url = new URL(p.url)
5454+ return url.origin === window.location.origin
5555+ || (!!p.customDomain && url.hostname === p.customDomain)
5656+ } catch {
5757+ return false
5858+ }
5959+ })
5060}
51615262export function DataProvider({ children }: { children: React.ReactNode }) {
+25
app/lib/domain-mode.ts
···11+import { resolvePublicationByDomain, type DomainPublication } from '~/lib/api-client'
22+33+// Build-time site domain (e.g. lemma.pub). Absent in local dev, so custom
44+// domain mode can never trigger there.
55+const SITE_DOMAIN = (import.meta.env.VITE_SITE_DOMAIN as string | undefined)?.toLowerCase()
66+77+export const SITE_ORIGIN = SITE_DOMAIN ? `https://${SITE_DOMAIN}` : ''
88+99+// True when the SPA is being served from a user's custom blog domain rather
1010+// than the site itself. The same build serves both; routes branch on this.
1111+export function isCustomDomain(): boolean {
1212+ if (typeof window === 'undefined' || !SITE_DOMAIN) return false
1313+ const host = window.location.hostname.toLowerCase()
1414+ if (host === SITE_DOMAIN || host.endsWith(`.${SITE_DOMAIN}`)) return false
1515+ if (host === 'localhost' || host === '127.0.0.1' || host === '[::1]' || host.endsWith('.localstack.cloud')) return false
1616+ return true
1717+}
1818+1919+let cachedResolution: Promise<DomainPublication | null> | null = null
2020+2121+// Resolves the publication bound to the current hostname, once per page load.
2222+export function resolveDomainPublication(): Promise<DomainPublication | null> {
2323+ cachedResolution ??= resolvePublicationByDomain(window.location.hostname).catch(() => null)
2424+ return cachedResolution
2525+}
···5151 .catch(() => setPublications([]))
5252 }, [session])
53535454- // Find the publication that belongs to this app's domain
5454+ // Find the publication that belongs to this app's domain. A publication
5555+ // whose canonical url is its custom domain still counts.
5556 const matchingPub = publications?.find((pub) => {
5656- try { return new URL(pub.url).origin === window.location.origin } catch { return false }
5757+ try {
5858+ const url = new URL(pub.url)
5959+ return url.origin === window.location.origin
6060+ || (!!pub.customDomain && url.hostname === pub.customDomain)
6161+ } catch { return false }
5762 }) ?? null
58635964 async function handleMigrate() {
+23-1
docs/architecture.md
···9393| Subscriber count | `u#<did>` | `count` | — | — | `subscribers` attribute incremented on each new subscription. Counted per DID, not per publication — if a reader subscribes to two publications by the same author, the count increments twice. Used by the API to identify celebrity authors (threshold: 1000) who bypass fan-out in favour of live post queries at read time. |
9494| User info | `u#<did>` | `info` | — | — | Written on first sign-in via `POST /users`. Attributes: `entityType` (`user-info`), `acceptedTermsAt` (ISO timestamp, set once), `updatedAt`. |
9595| Suggestion | `u#<did>#sugg` | `pub#<rkey>` | — | — | One item per suggested publication. `show` (boolean) controls visibility: `true` = active suggestion, `false` = dismissed tombstone. Attributes: `pubAtUri`, `authorDid`, `pubName`, `pubUrl`, `pubDescription`. Written by `POST /suggestions`; updated to `show=false` by `PATCH /suggestions`. Never deleted — tombstones prevent re-adding dismissed or subscribed-to publications on future "Find suggestions" runs. |
9696+| URL lookup | `url#<normalizedUrl>` | `info` | — | — | Reverse index from a publication URL to its `pubAtUri`. Used by `feed.xml`, the `.well-known` verification endpoint, and `GET /publications/by-domain`. Maintained by the API, indexer, and backfill on publication create/update/delete. When a publication's canonical URL switches to a custom domain, the previous URL's item is kept as an alias so old links keep resolving. |
9797+| Custom domain | `domain#<host>` | `info` | — | — | Claim binding a custom domain to a publication. Attributes: `did`, `pubRkey`, `pubAtUri`, `tenantId` (CloudFront distribution tenant), `status` (`pending` \| `active`), `previousUrl` (canonical URL before the switch), `createdAt`, `activatedAt`. Created by `POST /domains` with a conditional put (`attribute_not_exists`) so a domain can only be claimed once. |
9898+| Domain count | `u#<did>` | `domain-count` | — | — | `count` attribute incremented/decremented as the user adds/removes custom domains. Caps a user at `MAX_DOMAINS_PER_USER` (3) CloudFront tenants. |
9699| iframely cache | `iframely#cache` | SHA-256 hex of the embed URL | — | — | Expires via `ttl` attribute (DynamoDB TTL enabled) |
9710098101Global secondary index `FeedIndex` (GSI1PK=`blog-post#published`, GSI1SK=`<publishedAt>#<rkey>`) enables a time-ordered global feed across all users.
···333336|----------|-------------|
334337| `VITE_API_URL` | Base URL of the blog API (e.g. `https://api.example.com`). All API paths (`/posts`, `/publications`, `/iframely`, `/users`, etc.) are appended to this. Written to `.env.local` by `deploy-local.sh` / `deploy-aws.sh`. |
335338| `VITE_API_DID` | DID of the API service, used as the `aud` claim in ATProto service JWTs. For local dev: `did:web:localhost`. For production: `did:web:<domain>` (without fragment; the API Lambda appends `#blog-api` for the `rpc:` scope check). |
339339+| `VITE_SITE_DOMAIN` | The site's own domain (e.g. `lemma.pub`), set by `build-site` from `DOMAIN_NAME`. When the SPA is served from any other hostname it enters custom-domain mode (see below). Unset in local dev, so custom-domain mode never triggers there. |
340340+341341+Vite inlines `VITE_`-prefixed variables at build time. If any value changes, rebuild and redeploy the SPA.
342342+343343+## Custom blog domains
344344+345345+A user who controls DNS for a domain (e.g. `myblog.example.com`) can serve a publication from it instead of `https://<site>/<did>/pub/<rkey>`. Fully self-service from the publication list on the home page (the **Domain** action).
346346+347347+**Serving path** — a second, multi-tenant CloudFront distribution (`TenantDistribution` in `infra/cf/cloudfront.yaml`, `ConnectionMode: tenant-only`) shares the site's S3 and API Gateway origins. Each custom domain is a **distribution tenant** created at runtime by the API Lambda (`CreateDistributionTenant`) with a CloudFront-managed, HTTP-validated, auto-renewed ACM certificate. All tenants route through one **connection group**; its routing endpoint is the CNAME target users add at their DNS provider. A domain CNAMEd at the endpoint without a registered tenant serves nothing (no cert for that SNI, unknown Host rejected).
348348+349349+**Host awareness** — the tenant distribution's `*/feed.xml` and `*/.well-known/site.standard.publication` behaviors attach a viewer-request CloudFront Function that copies the viewer's `Host` into `x-forwarded-host` (with the `AllViewerExceptHostHeader` origin request policy so it reaches API Gateway). The Lambda builds lookup URLs from that header instead of the fixed `SITE_DOMAIN`, so `https://myblog.example.com/feed.xml` resolves via `url#https://myblog.example.com`.
336350337337-Vite inlines `VITE_`-prefixed variables at build time. If either value changes, rebuild and redeploy the SPA.
351351+**SPA** — the same static build serves both origins. `app/lib/domain-mode.ts` detects a foreign hostname; the root route renders the publication page and `/<rkey>` renders a post (bootstrapped by public `GET /publications/by-domain?domain=`). Nav/login chrome is dropped and OAuth never initializes — authoring stays on the site domain.
352352+353353+**Lifecycle** (API endpoints in `infra/api/src/index.ts`, all service-JWT authenticated):
354354+1. `POST /domains {domain, pubRkey}` — validates and normalizes the domain (rejects the site domain, IPs, `cloudfront.net`, etc.), claims it with a conditional put on `domain#<host>`, creates the distribution tenant, returns the CNAME record to add.
355355+2. `GET /domains?rkey=` — live status from `GetDistributionTenant` + `GetManagedCertificateDetails`; flips the claim to `active` once the cert is issued and the domain serves.
356356+3. "Make primary" — the client rewrites the `site.standard.publication` record's `url` to `https://<host>` and calls `PUT /publications` with the new `url`. The Lambda validates the URL belongs to the caller (canonical path or claimed domain), re-keys the `url#` index (old item kept as alias), and rewrites `resolvedUrl` on the publication's posts. The indexer converges the same way when the record update arrives via Jetstream.
357357+4. `DELETE /domains?rkey=` — disables and deletes the tenant, removes the claim/counter/`url#` items; the client reverts the record URL first if the domain was primary.
358358+359359+The `/domains` endpoints return 501 when `MULTI_TENANT_DIST_ID` is unset (LocalStack — CloudFront SaaS APIs are unsupported there), and the settings UI hides itself. Deleting a publication through the API cleans up its tenant; publications deleted directly on the PDS leave an orphaned tenant reaped by the ops sweep in `PROD.md`.
338360339361## Security
340362