Blogging platform with advanced tools for arts and sciences.
6

Configure Feed

Select the types of activity you want to include in your feed.

Add custom domains

lemma (Jul 7, 2026, 9:44 AM -0500) d1bd70d2 f512aea5

+1763 -399
+32
PROD.md
··· 30 30 31 31 This 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. 32 32 33 + ### User custom blog domains 34 + 35 + Users bind their own domains to publications self-service (see "Custom blog domains" in `docs/architecture.md`). Deployment order for the feature: 36 + 37 + ```sh 38 + AWS_PROFILE=production DOMAIN_NAME=lemma.pub make -C infra create-aws-cloudfront # tenant distribution + connection group 39 + AWS_PROFILE=production DOMAIN_NAME=lemma.pub make -C infra create-aws-api # /domains routes, IAM, env vars 40 + AWS_PROFILE=production make -C infra update-aws-api # new Lambda code 41 + AWS_PROFILE=production DOMAIN_NAME=lemma.pub make -C infra deploy-site # VITE_SITE_DOMAIN + tenant invalidation 42 + AWS_PROFILE=production make -C infra update-aws-indexer 43 + AWS_PROFILE=production make -C infra update-aws-backfill 44 + ``` 45 + 46 + `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. 47 + 48 + **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: 49 + 50 + ```sh 51 + # List all tenants of the multi-tenant distribution 52 + aws cloudfront list-distribution-tenants --profile production \ 53 + --association-filter DistributionId=<TenantDistributionId> \ 54 + --query 'DistributionTenantList[].[Id,Domains[0].Domain]' --output table 55 + 56 + # For each domain with no matching DynamoDB item (PK=domain#<host>, SK=info): 57 + # disable, then delete the tenant (each step needs the current ETag) 58 + aws cloudfront get-distribution-tenant --profile production --identifier <tenantId> 59 + aws cloudfront update-distribution-tenant --profile production --id <tenantId> --enabled false --if-match <etag> ... 60 + aws cloudfront delete-distribution-tenant --profile production --id <tenantId> --if-match <new-etag> 61 + ``` 62 + 63 + **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. 64 + 33 65 ### Indexer 34 66 35 67 **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.
+211
app/components/CustomDomainSettings.tsx
··· 1 + import { useCallback, useEffect, useRef, useState } from 'react' 2 + import type { OAuthSession } from '@atproto/oauth-client-browser' 3 + import { 4 + addCustomDomain, 5 + getCustomDomainStatus, 6 + removeCustomDomain, 7 + updatePublication, 8 + DomainsUnavailableError, 9 + type CustomDomainStatus, 10 + type Publication, 11 + } from '~/lib/api-client' 12 + import Spinner from '~/components/Spinner' 13 + 14 + const POLL_INTERVAL_MS = 30_000 15 + 16 + // Per-publication custom domain management: claim a domain, show the CNAME 17 + // record to add, poll until the certificate is issued and DNS points at us, 18 + // then let the user make the domain the publication's canonical URL. 19 + export default function CustomDomainSettings({ 20 + session, 21 + pub, 22 + onUrlChange, 23 + }: { 24 + session: OAuthSession 25 + pub: Publication 26 + onUrlChange: (url: string) => void 27 + }) { 28 + const rkey = pub.sk.slice(4) 29 + const [status, setStatus] = useState<CustomDomainStatus | null | undefined>(undefined) 30 + const [unavailable, setUnavailable] = useState(false) 31 + const [domainInput, setDomainInput] = useState('') 32 + const [pending, setPending] = useState(false) 33 + const [error, setError] = useState<string | null>(null) 34 + const pollRef = useRef<ReturnType<typeof setInterval> | null>(null) 35 + 36 + const refresh = useCallback(async () => { 37 + try { 38 + const s = await getCustomDomainStatus(session, rkey) 39 + setStatus(s) 40 + } catch (err) { 41 + if (err instanceof DomainsUnavailableError) setUnavailable(true) 42 + else setStatus(null) 43 + } 44 + }, [session, rkey]) 45 + 46 + useEffect(() => { 47 + refresh() 48 + }, [refresh]) 49 + 50 + useEffect(() => { 51 + if (status?.status !== 'pending') return 52 + pollRef.current = setInterval(refresh, POLL_INTERVAL_MS) 53 + return () => { if (pollRef.current) clearInterval(pollRef.current) } 54 + }, [status?.status, refresh]) 55 + 56 + if (unavailable) return null 57 + 58 + const isPrimary = !!status && (() => { 59 + try { return new URL(pub.url).hostname === status.domain } catch { return false } 60 + })() 61 + 62 + async function handleAdd(e: React.FormEvent) { 63 + e.preventDefault() 64 + if (!domainInput.trim()) return 65 + setPending(true) 66 + setError(null) 67 + try { 68 + const s = await addCustomDomain(session, rkey, domainInput.trim()) 69 + setStatus(s) 70 + setDomainInput('') 71 + } catch (err) { 72 + setError(err instanceof Error ? err.message : 'Failed to add domain') 73 + } finally { 74 + setPending(false) 75 + } 76 + } 77 + 78 + async function handleMakePrimary() { 79 + if (!status) return 80 + setPending(true) 81 + setError(null) 82 + try { 83 + const url = `https://${status.domain}` 84 + await updatePublication(session, rkey, { 85 + name: pub.name, 86 + description: pub.description, 87 + showInDiscover: pub.preferences.showInDiscover, 88 + url, 89 + }) 90 + onUrlChange(url) 91 + } catch (err) { 92 + setError(err instanceof Error ? err.message : 'Failed to update the publication URL') 93 + } finally { 94 + setPending(false) 95 + } 96 + } 97 + 98 + async function handleRemove() { 99 + if (!status) return 100 + setPending(true) 101 + setError(null) 102 + try { 103 + if (isPrimary) { 104 + const revertUrl = status.previousUrl 105 + ?? `${window.location.origin}/${session.sub}/pub/${rkey}` 106 + await updatePublication(session, rkey, { 107 + name: pub.name, 108 + description: pub.description, 109 + showInDiscover: pub.preferences.showInDiscover, 110 + url: revertUrl, 111 + }) 112 + onUrlChange(revertUrl) 113 + } 114 + await removeCustomDomain(session, rkey) 115 + setStatus(null) 116 + } catch (err) { 117 + setError(err instanceof Error ? err.message : 'Failed to remove the domain') 118 + } finally { 119 + setPending(false) 120 + } 121 + } 122 + 123 + const inputClass = 124 + 'w-full rounded-lg border border-stone-300 dark:border-stone-600 bg-white dark:bg-stone-800 px-3 py-2 text-sm text-stone-900 dark:text-white placeholder-stone-400 focus:outline-none focus:ring-2 focus:ring-stone-900 dark:focus:ring-stone-100 disabled:opacity-50' 125 + 126 + return ( 127 + <div className="border-t border-stone-100 dark:border-stone-800 px-4 py-3.5 space-y-3"> 128 + <p className="text-xs font-semibold uppercase tracking-widest text-stone-400 dark:text-stone-600"> 129 + Custom domain 130 + </p> 131 + 132 + {error && <p className="text-sm text-red-500">{error}</p>} 133 + 134 + {status === undefined ? ( 135 + <div className="flex items-center gap-2 text-sm text-stone-500 dark:text-stone-400"> 136 + <Spinner size="xs" muted /> 137 + Loading… 138 + </div> 139 + ) : status === null ? ( 140 + <form onSubmit={handleAdd} className="space-y-2"> 141 + <p className="text-sm text-stone-500 dark:text-stone-400"> 142 + Serve this publication from a domain you own, e.g. blog.example.com. 143 + </p> 144 + <div className="flex gap-2"> 145 + <input 146 + type="text" 147 + value={domainInput} 148 + onChange={(e) => setDomainInput(e.target.value)} 149 + placeholder="blog.example.com" 150 + autoCapitalize="none" 151 + autoCorrect="off" 152 + disabled={pending} 153 + className={inputClass} 154 + /> 155 + <button 156 + type="submit" 157 + disabled={pending || !domainInput.trim()} 158 + className="shrink-0 rounded-lg bg-stone-900 dark:bg-stone-100 px-4 py-2 text-sm font-semibold text-white dark:text-stone-900 disabled:opacity-50 hover:bg-stone-700 dark:hover:bg-stone-200 transition-colors" 159 + > 160 + {pending ? 'Adding…' : 'Add'} 161 + </button> 162 + </div> 163 + </form> 164 + ) : ( 165 + <div className="space-y-3"> 166 + <div className="flex items-center justify-between gap-3"> 167 + <p className="text-sm font-medium text-stone-900 dark:text-white truncate">{status.domain}</p> 168 + <span className={`shrink-0 text-xs font-medium rounded-full px-2 py-0.5 ${ 169 + status.status === 'active' 170 + ? 'bg-green-50 dark:bg-green-950 text-green-700 dark:text-green-400 border border-green-200 dark:border-green-900' 171 + : 'bg-amber-50 dark:bg-amber-950 text-amber-700 dark:text-amber-400 border border-amber-200 dark:border-amber-900' 172 + }`}> 173 + {status.status === 'active' ? (isPrimary ? 'Active · Primary' : 'Active') : 'Waiting for DNS'} 174 + </span> 175 + </div> 176 + 177 + {status.status !== 'active' && ( 178 + <div className="space-y-2"> 179 + <p className="text-sm text-stone-500 dark:text-stone-400"> 180 + Add this CNAME record at your DNS provider. The certificate is issued 181 + automatically once the record is live (this can take up to an hour). 182 + </p> 183 + <div className="rounded-lg bg-stone-100 dark:bg-stone-800 px-3 py-2 font-mono text-xs text-stone-700 dark:text-stone-300 overflow-x-auto whitespace-nowrap"> 184 + {status.cname.name} CNAME {status.cname.value} 185 + </div> 186 + </div> 187 + )} 188 + 189 + <div className="flex items-center gap-3"> 190 + {status.status === 'active' && !isPrimary && ( 191 + <button 192 + onClick={handleMakePrimary} 193 + disabled={pending} 194 + className="rounded-lg bg-stone-900 dark:bg-stone-100 px-3 py-1.5 text-sm font-semibold text-white dark:text-stone-900 disabled:opacity-50 hover:bg-stone-700 dark:hover:bg-stone-200 transition-colors" 195 + > 196 + {pending ? 'Updating…' : 'Make primary'} 197 + </button> 198 + )} 199 + <button 200 + onClick={handleRemove} 201 + disabled={pending} 202 + className="text-sm text-stone-400 dark:text-stone-500 hover:text-red-600 dark:hover:text-red-400 disabled:opacity-50 transition-colors" 203 + > 204 + {pending ? 'Removing…' : 'Remove domain'} 205 + </button> 206 + </div> 207 + </div> 208 + )} 209 + </div> 210 + ) 211 + }
+64
app/components/DomainViews.tsx
··· 1 + import { useEffect, useState } from 'react' 2 + import type { DomainPublication } from '~/lib/api-client' 3 + import { resolveDomainPublication } from '~/lib/domain-mode' 4 + import { PublicationView } from '~/routes/pub' 5 + import { PostView } from '~/routes/post' 6 + import CenteredMessage from '~/components/CenteredMessage' 7 + import Spinner from '~/components/Spinner' 8 + 9 + // undefined = resolving, null = no publication bound to this domain 10 + function useDomainPublication(): DomainPublication | null | undefined { 11 + const [pub, setPub] = useState<DomainPublication | null | undefined>(undefined) 12 + useEffect(() => { 13 + let cancelled = false 14 + resolveDomainPublication().then((resolved) => { 15 + if (!cancelled) setPub(resolved) 16 + }) 17 + return () => { cancelled = true } 18 + }, []) 19 + return pub 20 + } 21 + 22 + function DomainLoading() { 23 + return ( 24 + <div className="flex-1 flex items-center justify-center"> 25 + <Spinner /> 26 + </div> 27 + ) 28 + } 29 + 30 + function DomainNotFound() { 31 + return <CenteredMessage message="No publication is configured for this domain." error /> 32 + } 33 + 34 + // Root of a custom blog domain: the publication page. 35 + export function DomainHome() { 36 + const pub = useDomainPublication() 37 + 38 + useEffect(() => { 39 + if (!pub) return 40 + document.title = pub.name 41 + let feedLink = document.head.querySelector<HTMLLinkElement>('link[rel="alternate"][type="application/atom+xml"]') 42 + if (!feedLink) { 43 + feedLink = document.createElement('link') 44 + feedLink.rel = 'alternate' 45 + feedLink.type = 'application/atom+xml' 46 + document.head.appendChild(feedLink) 47 + } 48 + feedLink.href = `${window.location.origin}/feed.xml` 49 + return () => { feedLink?.remove() } 50 + }, [pub]) 51 + 52 + if (pub === undefined) return <DomainLoading /> 53 + if (!pub) return <DomainNotFound /> 54 + return <PublicationView id={pub.did} rkey={pub.rkey} domainMode /> 55 + } 56 + 57 + // /<rkey> on a custom blog domain: a post page. 58 + export function DomainPost({ rkey }: { rkey: string | undefined }) { 59 + const pub = useDomainPublication() 60 + 61 + if (pub === undefined) return <DomainLoading /> 62 + if (!pub) return <DomainNotFound /> 63 + return <PostView userId={pub.did} pubRkey={pub.rkey} rkey={rkey} domainMode /> 64 + }
+113 -2
app/lib/api-client.ts
··· 29 29 name: string 30 30 description?: string 31 31 authorHandle?: string 32 + customDomain?: string 32 33 preferences: { showInDiscover: boolean } 33 34 } 34 35 ··· 373 374 export async function updatePublication( 374 375 session: OAuthSession, 375 376 rkey: string, 376 - data: { name: string; description?: string; showInDiscover: boolean }, 377 + data: { name: string; description?: string; showInDiscover: boolean; url?: string }, 377 378 ): Promise<void> { 378 379 const did = session.sub 379 380 const pdsUrl = await resolvePdsUrl(did) 380 381 381 - const url = `${window.location.origin}/${did}/pub/${rkey}` 382 + // Preserve the record's existing url unless the caller passes one — it may 383 + // be a custom domain, which recomputing from window.location would clobber. 384 + let url = data.url 385 + if (!url) { 386 + try { 387 + const existingRes = await session.fetchHandler( 388 + `${pdsUrl}/xrpc/com.atproto.repo.getRecord?repo=${encodeURIComponent(did)}&collection=site.standard.publication&rkey=${encodeURIComponent(rkey)}`, 389 + { method: 'GET' }, 390 + ) 391 + if (existingRes.ok) { 392 + const { value } = (await existingRes.json()) as { value: { url?: string } } 393 + url = value.url 394 + } 395 + } catch { 396 + // fall through to the canonical site URL 397 + } 398 + } 399 + url ??= `${window.location.origin}/${did}/pub/${rkey}` 382 400 const icon = await uploadPublicationIcon(session) 383 401 384 402 const record: Record<string, unknown> = { ··· 410 428 body: JSON.stringify({ 411 429 name: data.name, 412 430 description: data.description?.trim() || undefined, 431 + url, 413 432 preferences: { showInDiscover: data.showInDiscover }, 414 433 }), 415 434 }) ··· 417 436 const body = await res.text().catch(() => '') 418 437 throw new Error(`API error (${res.status})${body ? `: ${body}` : ''}`) 419 438 } 439 + } 440 + 441 + export type DomainPublication = { 442 + did: string 443 + rkey: string 444 + atUri: string 445 + url: string 446 + name: string 447 + description?: string 448 + authorHandle?: string 449 + preferences: { showInDiscover: boolean } 450 + } 451 + 452 + // Public endpoint used by the SPA when served from a custom domain. 453 + export async function resolvePublicationByDomain(domain: string): Promise<DomainPublication | null> { 454 + const res = await fetch(`${apiUrl()}/publications/by-domain?domain=${encodeURIComponent(domain)}`) 455 + if (res.status === 404) return null 456 + if (!res.ok) throw new Error(`API error (${res.status})`) 457 + return res.json() as Promise<DomainPublication> 458 + } 459 + 460 + export class DomainsUnavailableError extends Error { 461 + constructor() { 462 + super('Custom domains are not available on this deployment') 463 + this.name = 'DomainsUnavailableError' 464 + } 465 + } 466 + 467 + export type CustomDomainStatus = { 468 + domain: string 469 + status: 'pending' | 'active' 470 + domainStatus?: string 471 + certStatus?: string 472 + previousUrl?: string 473 + cname: { name: string; value: string } 474 + } 475 + 476 + export async function addCustomDomain( 477 + session: OAuthSession, 478 + pubRkey: string, 479 + domain: string, 480 + ): Promise<CustomDomainStatus> { 481 + const pdsUrl = await resolvePdsUrl(session.sub) 482 + const token = await getServiceToken(session, pdsUrl) 483 + const res = await fetch(`${apiUrl()}/domains`, { 484 + method: 'POST', 485 + headers: { Authorization: `Bearer ${token}`, 'Content-Type': 'application/json' }, 486 + body: JSON.stringify({ domain, pubRkey }), 487 + }) 488 + if (res.status === 501) throw new DomainsUnavailableError() 489 + if (!res.ok) { 490 + const body = (await res.json().catch(() => null)) as { error?: string } | null 491 + throw new Error(body?.error ?? `API error (${res.status})`) 492 + } 493 + return res.json() as Promise<CustomDomainStatus> 494 + } 495 + 496 + // Returns null when the publication has no custom domain configured. 497 + export async function getCustomDomainStatus( 498 + session: OAuthSession, 499 + pubRkey: string, 500 + ): Promise<CustomDomainStatus | null> { 501 + const pdsUrl = await resolvePdsUrl(session.sub) 502 + const token = await getServiceToken(session, pdsUrl) 503 + const res = await fetch(`${apiUrl()}/domains?rkey=${encodeURIComponent(pubRkey)}`, { 504 + headers: { Authorization: `Bearer ${token}` }, 505 + }) 506 + if (res.status === 501) throw new DomainsUnavailableError() 507 + if (res.status === 404) return null 508 + if (!res.ok) { 509 + const body = (await res.json().catch(() => null)) as { error?: string } | null 510 + throw new Error(body?.error ?? `API error (${res.status})`) 511 + } 512 + return res.json() as Promise<CustomDomainStatus> 513 + } 514 + 515 + export async function removeCustomDomain( 516 + session: OAuthSession, 517 + pubRkey: string, 518 + ): Promise<{ previousUrl?: string }> { 519 + const pdsUrl = await resolvePdsUrl(session.sub) 520 + const token = await getServiceToken(session, pdsUrl) 521 + const res = await fetch(`${apiUrl()}/domains?rkey=${encodeURIComponent(pubRkey)}`, { 522 + method: 'DELETE', 523 + headers: { Authorization: `Bearer ${token}` }, 524 + }) 525 + if (res.status === 501) throw new DomainsUnavailableError() 526 + if (!res.ok) { 527 + const body = (await res.json().catch(() => null)) as { error?: string } | null 528 + throw new Error(body?.error ?? `API error (${res.status})`) 529 + } 530 + return res.json() as Promise<{ previousUrl?: string }> 420 531 } 421 532 422 533 export async function deletePublication(session: OAuthSession, rkey: string): Promise<void> {
+7
app/lib/auth-context.tsx
··· 9 9 } from 'react' 10 10 import type { BrowserOAuthClient, OAuthSession } from '@atproto/oauth-client-browser' 11 11 import { recordAcceptedTerms } from '~/lib/api-client' 12 + import { isCustomDomain } from '~/lib/domain-mode' 12 13 13 14 type AuthContextValue = { 14 15 session: OAuthSession | null ··· 27 28 const clientRef = useRef<BrowserOAuthClient | null>(null) 28 29 29 30 useEffect(() => { 31 + // Custom blog domains are read-only: no OAuth client, no session. Signing 32 + // in happens on the site's own domain. 33 + if (isCustomDomain()) { 34 + setLoading(false) 35 + return 36 + } 30 37 // Dynamic import instead of a static import at the top of this file. 31 38 // @atproto/oauth-client-browser instantiates BrowserRuntimeImplementation at 32 39 // module evaluation time, and that constructor throws synchronously if
+11 -1
app/lib/data-context.tsx
··· 42 42 } 43 43 44 44 // Returns publications for the current site's origin, or null while loading. 45 + // Publications whose canonical url is their custom domain still belong to 46 + // this site, so they pass the filter too. 45 47 export function usePublications(): Publication[] | null { 46 48 const { publications } = useData() 47 49 return publications === undefined 48 50 ? null 49 - : publications.filter((p) => { try { return new URL(p.url).origin === window.location.origin } catch { return false } }) 51 + : publications.filter((p) => { 52 + try { 53 + const url = new URL(p.url) 54 + return url.origin === window.location.origin 55 + || (!!p.customDomain && url.hostname === p.customDomain) 56 + } catch { 57 + return false 58 + } 59 + }) 50 60 } 51 61 52 62 export function DataProvider({ children }: { children: React.ReactNode }) {
+25
app/lib/domain-mode.ts
··· 1 + import { resolvePublicationByDomain, type DomainPublication } from '~/lib/api-client' 2 + 3 + // Build-time site domain (e.g. lemma.pub). Absent in local dev, so custom 4 + // domain mode can never trigger there. 5 + const SITE_DOMAIN = (import.meta.env.VITE_SITE_DOMAIN as string | undefined)?.toLowerCase() 6 + 7 + export const SITE_ORIGIN = SITE_DOMAIN ? `https://${SITE_DOMAIN}` : '' 8 + 9 + // True when the SPA is being served from a user's custom blog domain rather 10 + // than the site itself. The same build serves both; routes branch on this. 11 + export function isCustomDomain(): boolean { 12 + if (typeof window === 'undefined' || !SITE_DOMAIN) return false 13 + const host = window.location.hostname.toLowerCase() 14 + if (host === SITE_DOMAIN || host.endsWith(`.${SITE_DOMAIN}`)) return false 15 + if (host === 'localhost' || host === '127.0.0.1' || host === '[::1]' || host.endsWith('.localstack.cloud')) return false 16 + return true 17 + } 18 + 19 + let cachedResolution: Promise<DomainPublication | null> | null = null 20 + 21 + // Resolves the publication bound to the current hostname, once per page load. 22 + export function resolveDomainPublication(): Promise<DomainPublication | null> { 23 + cachedResolution ??= resolvePublicationByDomain(window.location.hostname).catch(() => null) 24 + return cachedResolution 25 + }
+26
app/root.tsx
··· 14 14 import { AuthProvider } from "~/lib/auth-context" 15 15 import { DataProvider } from "~/lib/data-context"; 16 16 import Nav from "~/components/Nav"; 17 + import { isCustomDomain, SITE_ORIGIN } from "~/lib/domain-mode"; 17 18 18 19 export const links: Route.LinksFunction = () => [ 19 20 { rel: "preconnect", href: "https://fonts.googleapis.com" }, ··· 67 68 window.location.reload() 68 69 }) 69 70 }, []) 71 + 72 + // Custom blog domains get minimal chrome: no nav, a bare attribution footer. 73 + if (isCustomDomain()) { 74 + return ( 75 + <AuthProvider> 76 + <DataProvider> 77 + <div className="min-h-screen flex flex-col"> 78 + <div className="flex-1 flex flex-col"> 79 + <Outlet /> 80 + </div> 81 + <footer className="border-t border-stone-200 dark:border-stone-800 py-4 px-4 md:px-8"> 82 + <div className="max-w-5xl mx-auto text-xs text-stone-400 dark:text-stone-500"> 83 + <a 84 + href={SITE_ORIGIN || 'https://lemma.pub'} 85 + className="hover:text-stone-600 dark:hover:text-stone-300 transition-colors" 86 + > 87 + Published with Lemma 88 + </a> 89 + </div> 90 + </footer> 91 + </div> 92 + </DataProvider> 93 + </AuthProvider> 94 + ) 95 + } 70 96 71 97 return ( 72 98 <AuthProvider>
+33
app/routes/home.tsx
··· 18 18 import Spinner from '~/components/Spinner' 19 19 import type { Route } from './+types/home' 20 20 import { SITE_NAME, TYPEAHEAD_URL } from '~/lib/site-config' 21 + import { isCustomDomain } from '~/lib/domain-mode' 22 + import { DomainHome } from '~/components/DomainViews' 23 + import CustomDomainSettings from '~/components/CustomDomainSettings' 21 24 22 25 export function meta({}: Route.MetaArgs) { 23 26 return [{ title: SITE_NAME }, { name: 'description', content: SITE_NAME }] 24 27 } 25 28 26 29 export default function Home() { 30 + // On a custom blog domain the root path is the publication page, not the app. 31 + if (isCustomDomain()) return <DomainHome /> 32 + return <HomeScreen /> 33 + } 34 + 35 + function HomeScreen() { 27 36 const { session, loading, error } = useAuth() 28 37 29 38 if (loading) return <LoadingScreen /> ··· 511 520 }) { 512 521 const [editingRkey, setEditingRkey] = useState<string | null>(null) 513 522 const [confirmDeleteRkey, setConfirmDeleteRkey] = useState<string | null>(null) 523 + const [domainRkey, setDomainRkey] = useState<string | null>(null) 514 524 const [showCreate, setShowCreate] = useState(false) 515 525 const [editName, setEditName] = useState('') 516 526 const [editDescription, setEditDescription] = useState('') ··· 589 599 const rkey = pub.sk.slice(4) 590 600 const isEditing = editingRkey === rkey 591 601 const isConfirming = confirmDeleteRkey === rkey 602 + const isDomainOpen = domainRkey === rkey 592 603 593 604 if (isEditing) { 594 605 return ( ··· 642 653 <button onClick={() => startEdit(pub)} className="text-sm text-stone-400 dark:text-stone-500 hover:text-stone-900 dark:hover:text-white transition-colors"> 643 654 Edit 644 655 </button> 656 + <button 657 + onClick={() => { setDomainRkey(isDomainOpen ? null : rkey); setEditingRkey(null); setConfirmDeleteRkey(null); setShowCreate(false) }} 658 + className={`text-sm transition-colors ${isDomainOpen ? 'text-stone-900 dark:text-white' : 'text-stone-400 dark:text-stone-500 hover:text-stone-900 dark:hover:text-white'}`} 659 + > 660 + Domain 661 + </button> 645 662 <button onClick={() => { setConfirmDeleteRkey(rkey); setEditingRkey(null); setShowCreate(false) }} className="text-sm text-stone-400 dark:text-stone-500 hover:text-red-600 dark:hover:text-red-400 transition-colors"> 646 663 Delete 647 664 </button> 648 665 </div> 649 666 </div> 667 + )} 668 + {isDomainOpen && !isConfirming && ( 669 + <CustomDomainSettings 670 + session={session} 671 + pub={pub} 672 + onUrlChange={(url) => { 673 + let customDomain = pub.customDomain 674 + try { 675 + const parsed = new URL(url) 676 + if (parsed.origin !== window.location.origin) customDomain = parsed.hostname 677 + } catch { /* keep existing */ } 678 + onPublicationsChange(publications.map((p) => 679 + p.sk === pub.sk ? { ...p, url, customDomain } : p, 680 + )) 681 + }} 682 + /> 650 683 )} 651 684 </li> 652 685 )
+21 -3
app/routes/post.tsx
··· 34 34 import Spinner from '~/components/Spinner' 35 35 import ConfirmDeleteButton from '~/components/ConfirmDeleteButton' 36 36 import { SITE_NAME } from '~/lib/site-config' 37 + import { SITE_ORIGIN } from '~/lib/domain-mode' 37 38 38 39 39 40 export default function Post() { 40 41 const { userId, pubRkey, rkey } = useParams<{ userId: string; pubRkey: string; rkey: string }>() 42 + return <PostView userId={userId} pubRkey={pubRkey} rkey={rkey} /> 43 + } 44 + 45 + // Renders a post page. domainMode drops the site chrome for use on a custom 46 + // blog domain, where posts live at /<rkey> and the back link goes to /. 47 + export function PostView({ userId, pubRkey, rkey, domainMode = false }: { 48 + userId: string | undefined 49 + pubRkey: string | undefined 50 + rkey: string | undefined 51 + domainMode?: boolean 52 + }) { 41 53 const { session } = useAuth() 42 54 const { removePost } = useData() 43 55 const navigate = useNavigate() ··· 127 139 }, [userId, rkey]) 128 140 129 141 if (error) { 130 - return <CenteredMessage message={error} error action={{ label: 'Back to profile', to: `/${userId}` }} /> 142 + return domainMode 143 + ? <CenteredMessage message={error} error action={{ label: 'Back to home', to: '/' }} /> 144 + : <CenteredMessage message={error} error action={{ label: 'Back to profile', to: `/${userId}` }} /> 131 145 } 132 146 133 147 if (title === null) { ··· 144 158 145 159 {/* ── Top bar: back + owner controls ── */} 146 160 <div className="flex items-center justify-between"> 147 - <Link to={`/${profile?.handle ?? userId}`}> 161 + <Link to={domainMode ? '/' : `/${profile?.handle ?? userId}`}> 148 162 <ArrowLongLeftIcon className="size-8 text-stone-400 hover:text-stone-700 dark:hover:text-stone-200 transition-colors" /> 149 163 </Link> 150 164 ··· 176 190 <span className="font-display font-medium text-stone-900 dark:text-white">{profile.displayName}</span> 177 191 )} 178 192 {' '} 179 - <Link to={`/${profile?.handle ?? userId}`} className="text-stone-500 dark:text-stone-400 hover:text-stone-700 dark:hover:text-stone-200 transition-colors">@{profile?.handle ?? userId}</Link> 193 + {domainMode ? ( 194 + <a href={`${SITE_ORIGIN}/${profile?.handle ?? userId}`} className="text-stone-500 dark:text-stone-400 hover:text-stone-700 dark:hover:text-stone-200 transition-colors">@{profile?.handle ?? userId}</a> 195 + ) : ( 196 + <Link to={`/${profile?.handle ?? userId}`} className="text-stone-500 dark:text-stone-400 hover:text-stone-700 dark:hover:text-stone-200 transition-colors">@{profile?.handle ?? userId}</Link> 197 + )} 180 198 </p> 181 199 {publishedAt && ( 182 200 <p className="text-sm text-stone-500 dark:text-stone-400 mt-0.5">
+10 -1
app/routes/profile.tsx
··· 15 15 import PublicationCard from '~/components/PublicationCard' 16 16 import type { Route } from './+types/profile' 17 17 import { SITE_NAME } from '~/lib/site-config' 18 + import { isCustomDomain } from '~/lib/domain-mode' 19 + import { DomainPost } from '~/components/DomainViews' 18 20 19 21 export function meta({ params }: Route.MetaArgs) { 20 22 return [{ title: `${params.userId} — ${SITE_NAME}` }] 21 23 } 22 24 23 25 export default function Profile() { 24 - const { userId: id } = useParams<{ userId: string }>() 26 + const { userId } = useParams<{ userId: string }>() 27 + // On a custom blog domain, a single path segment is a post rkey 28 + // (post URLs are <publication url>/<rkey>), not a profile handle. 29 + if (isCustomDomain()) return <DomainPost rkey={userId} /> 30 + return <ProfileView id={userId} /> 31 + } 32 + 33 + function ProfileView({ id }: { id: string | undefined }) { 25 34 const { session } = useAuth() 26 35 const [profile, setProfile] = useState<AppBskyActorDefs.ProfileViewDetailed | null>(null) 27 36 const [publications, setPublications] = useState<Publication[] | null>(null)
+32 -7
app/routes/pub.tsx
··· 21 21 } 22 22 23 23 export default function Pub() { 24 - const { userId: id, rkey } = useParams<{ userId: string; rkey: string }>() 24 + const { userId, rkey } = useParams<{ userId: string; rkey: string }>() 25 + return <PublicationView id={userId} rkey={rkey} /> 26 + } 27 + 28 + // Renders a publication page. domainMode drops the site chrome (back link, 29 + // profile links) for use at the root of a custom blog domain. 30 + export function PublicationView({ id, rkey, domainMode = false }: { 31 + id: string | undefined 32 + rkey: string | undefined 33 + domainMode?: boolean 34 + }) { 25 35 const { session } = useAuth() 26 36 const [profile, setProfile] = useState<AppBskyActorDefs.ProfileViewDetailed | null>(null) 27 37 const [publication, setPublication] = useState<Publication | null>(null) ··· 135 145 } 136 146 137 147 if (error) { 138 - return <CenteredMessage message={error} error action={{ label: 'Back to profile', to: `/${id}` }} /> 148 + return domainMode 149 + ? <CenteredMessage message={error} error /> 150 + : <CenteredMessage message={error} error action={{ label: 'Back to profile', to: `/${id}` }} /> 139 151 } 140 152 141 153 if (!profile || publication === null) { ··· 147 159 } 148 160 149 161 if (!publication) { 150 - return <CenteredMessage message="Publication not found." error action={{ label: 'Back to profile', to: `/${id}` }} /> 162 + return domainMode 163 + ? <CenteredMessage message="Publication not found." error /> 164 + : <CenteredMessage message="Publication not found." error action={{ label: 'Back to profile', to: `/${id}` }} /> 151 165 } 152 166 153 167 const showSubscribeButton = !!session && session.sub !== profile.did ··· 166 180 return ( 167 181 <PageLayout> 168 182 <div className="w-full max-w-2xl mx-auto px-4 py-8 space-y-8"> 169 - <Link to={`/${profile.handle}`}> 170 - <ArrowLongLeftIcon className="size-8 text-stone-400 hover:text-stone-700 dark:hover:text-stone-200 transition-colors" /> 171 - </Link> 183 + {!domainMode && ( 184 + <Link to={`/${profile.handle}`}> 185 + <ArrowLongLeftIcon className="size-8 text-stone-400 hover:text-stone-700 dark:hover:text-stone-200 transition-colors" /> 186 + </Link> 187 + )} 188 + 189 + {!domainMode && !isSameOrigin && ( 190 + <div className="rounded-lg border border-stone-200 dark:border-stone-700 bg-stone-50 dark:bg-stone-900/40 px-4 py-3 text-sm text-stone-600 dark:text-stone-300"> 191 + This publication has moved to{' '} 192 + <a href={publication.url} className="font-medium underline hover:text-stone-900 dark:hover:text-white transition-colors"> 193 + {new URL(publication.url).host} 194 + </a> 195 + </div> 196 + )} 172 197 173 - <ProfileHeader profile={profile} linkTo={`/${profile.handle}`} /> 198 + <ProfileHeader profile={profile} linkTo={domainMode ? undefined : `/${profile.handle}`} /> 174 199 175 200 <div className="space-y-2"> 176 201 <div className="flex items-start justify-between gap-4">
+7 -2
app/routes/settings.tsx
··· 51 51 .catch(() => setPublications([])) 52 52 }, [session]) 53 53 54 - // Find the publication that belongs to this app's domain 54 + // Find the publication that belongs to this app's domain. A publication 55 + // whose canonical url is its custom domain still counts. 55 56 const matchingPub = publications?.find((pub) => { 56 - try { return new URL(pub.url).origin === window.location.origin } catch { return false } 57 + try { 58 + const url = new URL(pub.url) 59 + return url.origin === window.location.origin 60 + || (!!pub.customDomain && url.hostname === pub.customDomain) 61 + } catch { return false } 57 62 }) ?? null 58 63 59 64 async function handleMigrate() {
+23 -1
docs/architecture.md
··· 93 93 | 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. | 94 94 | User info | `u#<did>` | `info` | — | — | Written on first sign-in via `POST /users`. Attributes: `entityType` (`user-info`), `acceptedTermsAt` (ISO timestamp, set once), `updatedAt`. | 95 95 | 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. | 96 + | 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. | 97 + | 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. | 98 + | 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. | 96 99 | iframely cache | `iframely#cache` | SHA-256 hex of the embed URL | — | — | Expires via `ttl` attribute (DynamoDB TTL enabled) | 97 100 98 101 Global secondary index `FeedIndex` (GSI1PK=`blog-post#published`, GSI1SK=`<publishedAt>#<rkey>`) enables a time-ordered global feed across all users. ··· 333 336 |----------|-------------| 334 337 | `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`. | 335 338 | `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). | 339 + | `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. | 340 + 341 + Vite inlines `VITE_`-prefixed variables at build time. If any value changes, rebuild and redeploy the SPA. 342 + 343 + ## Custom blog domains 344 + 345 + 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). 346 + 347 + **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). 348 + 349 + **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`. 336 350 337 - Vite inlines `VITE_`-prefixed variables at build time. If either value changes, rebuild and redeploy the SPA. 351 + **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. 352 + 353 + **Lifecycle** (API endpoints in `infra/api/src/index.ts`, all service-JWT authenticated): 354 + 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. 355 + 2. `GET /domains?rkey=` — live status from `GetDistributionTenant` + `GetManagedCertificateDetails`; flips the claim to `active` once the cert is issued and the domain serves. 356 + 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. 357 + 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. 358 + 359 + 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`. 338 360 339 361 ## Security 340 362
+44 -4
infra/Makefile
··· 73 73 update-local-backfill update-aws-backfill \ 74 74 run-local-backfill run-aws-backfill \ 75 75 logs-aws-backfill \ 76 - sync-site build-site deploy-site invalidate-site list-invalidations serve-local local aws \ 76 + sync-site build-site deploy-site invalidate-site invalidate-tenants list-invalidations serve-local local aws \ 77 77 update-local-site update-local \ 78 78 update-iframely-sri 79 79 ··· 101 101 @echo " build-site - Build the SPA with VITE_API_DID/VITE_API_URL derived from DOMAIN_NAME" 102 102 @echo " deploy-site - Build, sync, and invalidate CloudFront cache (requires DOMAIN_NAME, AWS_PROFILE)" 103 103 @echo " invalidate-site - Invalidate CloudFront cache for the site distribution" 104 + @echo " invalidate-tenants - Invalidate custom-domain distribution tenant caches" 104 105 @echo " list-invalidations - Show recent CloudFront invalidation statuses" 105 106 @echo " update-iframely-sri - Fetch cdn.iframe.ly/embed.js and update the pinned SRI hash" 106 107 ··· 261 262 --query 'Stacks[0].Outputs[?OutputKey==`IframelySecretArn`].OutputValue' \ 262 263 --output text 2>/dev/null || echo ''); \ 263 264 fi; \ 265 + TENANT_DIST_ID=$$(aws cloudformation describe-stacks \ 266 + --profile $${AWS_PROFILE} --region $${AWS_REGION} \ 267 + --stack-name $(CLOUDFRONT_STACK_NAME) \ 268 + --query 'Stacks[0].Outputs[?OutputKey==`TenantDistributionId`].OutputValue' \ 269 + --output text 2>/dev/null || echo ''); \ 270 + TENANT_CG_ID=$$(aws cloudformation describe-stacks \ 271 + --profile $${AWS_PROFILE} --region $${AWS_REGION} \ 272 + --stack-name $(CLOUDFRONT_STACK_NAME) \ 273 + --query 'Stacks[0].Outputs[?OutputKey==`TenantConnectionGroupId`].OutputValue' \ 274 + --output text 2>/dev/null || echo ''); \ 275 + TENANT_ENDPOINT=$$(aws cloudformation describe-stacks \ 276 + --profile $${AWS_PROFILE} --region $${AWS_REGION} \ 277 + --stack-name $(CLOUDFRONT_STACK_NAME) \ 278 + --query 'Stacks[0].Outputs[?OutputKey==`TenantRoutingEndpoint`].OutputValue' \ 279 + --output text 2>/dev/null || echo ''); \ 264 280 aws cloudformation deploy --profile $${AWS_PROFILE} --region $${AWS_REGION} \ 265 281 --template-file ./cf/blog-api.yaml \ 266 282 --stack-name $(PROJECT_NAME)-api-stack \ 267 283 --capabilities CAPABILITY_NAMED_IAM \ 268 284 --no-fail-on-empty-changeset \ 269 - --parameter-overrides ApiDid=$(API_DID) DynamoDBTable=$(DYNAMODB_TABLE) AllowedOrigin=$(ALLOWED_ORIGIN) IframelySecretArn=$$IFRAMELY_ARN SiteDomain=$(DOMAIN_NAME) 285 + --parameter-overrides ApiDid=$(API_DID) DynamoDBTable=$(DYNAMODB_TABLE) AllowedOrigin=$(ALLOWED_ORIGIN) IframelySecretArn=$$IFRAMELY_ARN SiteDomain=$(DOMAIN_NAME) MultiTenantDistributionId=$$TENANT_DIST_ID TenantConnectionGroupId=$$TENANT_CG_ID TenantRoutingEndpoint=$$TENANT_ENDPOINT 270 286 @echo "🔄 Forcing API Gateway redeployment to prod stage..." 271 287 @REST_API_ID=$$(aws cloudformation describe-stacks --profile $${AWS_PROFILE} --region $${AWS_REGION} \ 272 288 --stack-name $(PROJECT_NAME)-api-stack \ ··· 639 655 640 656 build-site: 641 657 @[ -n "$(DOMAIN_NAME)" ] || (echo "❌ DOMAIN_NAME is required (e.g. make build-site DOMAIN_NAME=example.com)."; exit 1) 642 - VITE_API_URL=$(VITE_API_URL) VITE_API_DID=$(API_DID) pnpm --dir .. build 658 + VITE_API_URL=$(VITE_API_URL) VITE_API_DID=$(API_DID) VITE_SITE_DOMAIN=$(DOMAIN_NAME) pnpm --dir .. build 643 659 644 - deploy-site: build-site sync-site invalidate-site 660 + deploy-site: build-site sync-site invalidate-site invalidate-tenants 645 661 646 662 invalidate-site: 647 663 @[ -n "$(DOMAIN_NAME)" ] || (echo "❌ DOMAIN_NAME is required."; exit 1) ··· 657 673 --paths "/*" \ 658 674 --query 'Invalidation.Id' --output text && \ 659 675 echo "✅ Invalidation queued" 676 + 677 + # Custom-domain tenants have no aliases, so invalidate-site never finds them. 678 + # Invalidate each distribution tenant so custom domains pick up new SPA builds. 679 + invalidate-tenants: 680 + @echo "🔄 Invalidating custom-domain tenant caches..." 681 + @TENANT_DIST_ID=$$(aws cloudformation describe-stacks \ 682 + --profile $${AWS_PROFILE} --region $${AWS_REGION} \ 683 + --stack-name $(CLOUDFRONT_STACK_NAME) \ 684 + --query 'Stacks[0].Outputs[?OutputKey==`TenantDistributionId`].OutputValue' \ 685 + --output text 2>/dev/null || echo ''); \ 686 + [ -n "$$TENANT_DIST_ID" ] && [ "$$TENANT_DIST_ID" != "None" ] || { echo "⚠️ No tenant distribution — skipping"; exit 0; }; \ 687 + TENANT_IDS=$$(aws cloudfront list-distribution-tenants \ 688 + --profile $${AWS_PROFILE} \ 689 + --association-filter DistributionId=$$TENANT_DIST_ID \ 690 + --query 'DistributionTenantList[].Id' --output text 2>/dev/null || echo ''); \ 691 + [ -n "$$TENANT_IDS" ] && [ "$$TENANT_IDS" != "None" ] || { echo "ℹ️ No distribution tenants yet — skipping"; exit 0; }; \ 692 + for ID in $$TENANT_IDS; do \ 693 + aws cloudfront create-invalidation-for-distribution-tenant \ 694 + --profile $${AWS_PROFILE} \ 695 + --id "$$ID" \ 696 + --invalidation-batch "{\"Paths\": {\"Quantity\": 1, \"Items\": [\"/*\"]}, \"CallerReference\": \"deploy-$$(date +%s)-$$ID\"}" \ 697 + --query 'Invalidation.Id' --output text; \ 698 + done; \ 699 + echo "✅ Tenant invalidations queued" 660 700 661 701 list-invalidations: 662 702 @[ -n "$(DOMAIN_NAME)" ] || (echo "❌ DOMAIN_NAME is required."; exit 1)
+172 -359
infra/api/package-lock.json
··· 10 10 "dependencies": { 11 11 "@atproto/crypto": "^0.4.5", 12 12 "@atproto/identity": "^0.4.12", 13 + "@aws-sdk/client-cloudfront": "^3.1080.0", 13 14 "@aws-sdk/client-dynamodb": "^3", 14 15 "@aws-sdk/client-secrets-manager": "^3", 15 16 "@aws-sdk/lib-dynamodb": "^3" ··· 201 202 "node": ">=14.0.0" 202 203 } 203 204 }, 205 + "node_modules/@aws-sdk/client-cloudfront": { 206 + "version": "3.1080.0", 207 + "resolved": "https://registry.npmjs.org/@aws-sdk/client-cloudfront/-/client-cloudfront-3.1080.0.tgz", 208 + "integrity": "sha512-E8KrSO4gaxuxSrQY3XBaiF/Vm6VqJMlWZACOLy1kAb+2y/fAZbifQzBUEMVv5q4aTm5lv+9xQpxlpJE54CZg5Q==", 209 + "dependencies": { 210 + "@aws-sdk/core": "^3.974.28", 211 + "@aws-sdk/credential-provider-node": "^3.972.63", 212 + "@aws-sdk/types": "^3.973.15", 213 + "@smithy/core": "^3.29.0", 214 + "@smithy/fetch-http-handler": "^5.6.2", 215 + "@smithy/node-http-handler": "^4.9.2", 216 + "@smithy/types": "^4.15.1", 217 + "tslib": "^2.6.2" 218 + }, 219 + "engines": { 220 + "node": ">=20.0.0" 221 + } 222 + }, 204 223 "node_modules/@aws-sdk/client-dynamodb": { 205 224 "version": "3.1045.0", 206 225 "resolved": "https://registry.npmjs.org/@aws-sdk/client-dynamodb/-/client-dynamodb-3.1045.0.tgz", ··· 303 322 } 304 323 }, 305 324 "node_modules/@aws-sdk/core": { 306 - "version": "3.974.8", 307 - "resolved": "https://registry.npmjs.org/@aws-sdk/core/-/core-3.974.8.tgz", 308 - "integrity": "sha512-njR2qoG6ZuB0kvAS2FyICsFZJ6gmCcf2X/7JcD14sUvGDm26wiZ5BrA6LOiUxKFEF+IVe7kdroxyE00YlkiYsw==", 325 + "version": "3.974.28", 326 + "resolved": "https://registry.npmjs.org/@aws-sdk/core/-/core-3.974.28.tgz", 327 + "integrity": "sha512-4/1DtLwgLqzIg2uFzkFaFjMQHhhhwHIZN4PfziIVqXYX7koO78omuchQlLHyzQBw80l255dtmTA/J4W5yhb7zw==", 309 328 "dependencies": { 310 - "@aws-sdk/types": "^3.973.8", 311 - "@aws-sdk/xml-builder": "^3.972.22", 312 - "@smithy/core": "^3.23.17", 313 - "@smithy/node-config-provider": "^4.3.14", 314 - "@smithy/property-provider": "^4.2.14", 315 - "@smithy/protocol-http": "^5.3.14", 316 - "@smithy/signature-v4": "^5.3.14", 317 - "@smithy/smithy-client": "^4.12.13", 318 - "@smithy/types": "^4.14.1", 319 - "@smithy/util-base64": "^4.3.2", 320 - "@smithy/util-middleware": "^4.2.14", 321 - "@smithy/util-retry": "^4.3.6", 322 - "@smithy/util-utf8": "^4.2.2", 329 + "@aws-sdk/types": "^3.973.15", 330 + "@aws-sdk/xml-builder": "^3.972.33", 331 + "@aws/lambda-invoke-store": "^0.3.0", 332 + "@smithy/core": "^3.29.0", 333 + "@smithy/signature-v4": "^5.6.1", 334 + "@smithy/types": "^4.15.1", 335 + "bowser": "^2.11.0", 323 336 "tslib": "^2.6.2" 324 337 }, 325 338 "engines": { 326 339 "node": ">=20.0.0" 327 340 } 328 341 }, 342 + "node_modules/@aws-sdk/core/node_modules/@aws/lambda-invoke-store": { 343 + "version": "0.3.0", 344 + "resolved": "https://registry.npmjs.org/@aws/lambda-invoke-store/-/lambda-invoke-store-0.3.0.tgz", 345 + "integrity": "sha512-sl4Bm6yiMNYrZKkqqDFWN0UfnWhlS8ivKxrYl+6t0gCLrqr8y3B2IqZZbFRkfaVVp7C/baApyh71P+LeE1A2sQ==", 346 + "engines": { 347 + "node": ">=18.0.0" 348 + } 349 + }, 329 350 "node_modules/@aws-sdk/credential-provider-env": { 330 - "version": "3.972.34", 331 - "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-env/-/credential-provider-env-3.972.34.tgz", 332 - "integrity": "sha512-XT0jtf8Fw9JE6ppsQeoNnZRiG+jqRixMT1v1ZR17G60UvVdsQmTG8nbEyHuEPfMxDXEhfdARaM/XiEhca4lGHQ==", 351 + "version": "3.972.54", 352 + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-env/-/credential-provider-env-3.972.54.tgz", 353 + "integrity": "sha512-F4WQCG8GULIt+XrMHsqUM9dZc0eTwZM3HUWByOjIKOwBqJSzUxX8CFAtbgMvWfCua52FS1oi5FXarH3+khFq8A==", 333 354 "dependencies": { 334 - "@aws-sdk/core": "^3.974.8", 335 - "@aws-sdk/types": "^3.973.8", 336 - "@smithy/property-provider": "^4.2.14", 337 - "@smithy/types": "^4.14.1", 355 + "@aws-sdk/core": "^3.974.28", 356 + "@aws-sdk/types": "^3.973.15", 357 + "@smithy/core": "^3.29.0", 358 + "@smithy/types": "^4.15.1", 338 359 "tslib": "^2.6.2" 339 360 }, 340 361 "engines": { ··· 342 363 } 343 364 }, 344 365 "node_modules/@aws-sdk/credential-provider-http": { 345 - "version": "3.972.36", 346 - "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-http/-/credential-provider-http-3.972.36.tgz", 347 - "integrity": "sha512-DPoGWfy7J7RKxvbf5kOKIGQkD2ek3dbKgzKIGrnLuvZBz5myU+Im/H6pmc14QcnFbqHMqxvtWSgRDSJW3qXLQg==", 366 + "version": "3.972.56", 367 + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-http/-/credential-provider-http-3.972.56.tgz", 368 + "integrity": "sha512-PiwHgEK2srdfi/lFveyQ+3w/qikyh6MKvuZAdlMC+dwQi4K5iVBr32oh7cugnYw+jA8iGtnQMhCGvLm/mqplmw==", 348 369 "dependencies": { 349 - "@aws-sdk/core": "^3.974.8", 350 - "@aws-sdk/types": "^3.973.8", 351 - "@smithy/fetch-http-handler": "^5.3.17", 352 - "@smithy/node-http-handler": "^4.6.1", 353 - "@smithy/property-provider": "^4.2.14", 354 - "@smithy/protocol-http": "^5.3.14", 355 - "@smithy/smithy-client": "^4.12.13", 356 - "@smithy/types": "^4.14.1", 357 - "@smithy/util-stream": "^4.5.25", 370 + "@aws-sdk/core": "^3.974.28", 371 + "@aws-sdk/types": "^3.973.15", 372 + "@smithy/core": "^3.29.0", 373 + "@smithy/fetch-http-handler": "^5.6.2", 374 + "@smithy/node-http-handler": "^4.9.2", 375 + "@smithy/types": "^4.15.1", 358 376 "tslib": "^2.6.2" 359 377 }, 360 378 "engines": { ··· 362 380 } 363 381 }, 364 382 "node_modules/@aws-sdk/credential-provider-ini": { 365 - "version": "3.972.38", 366 - "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-ini/-/credential-provider-ini-3.972.38.tgz", 367 - "integrity": "sha512-oDzUBu2MGJFgoar05sPMCwSrhw44ASyccrHzj66vO69OZqi7I6hZZxXfuPLC8OCzW7C+sU+bI73XHij41yekgQ==", 383 + "version": "3.972.61", 384 + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-ini/-/credential-provider-ini-3.972.61.tgz", 385 + "integrity": "sha512-9CZWMjhBlgfKlqJk40R7kvMOLEIoOr3HJ1J/ELjAH9H5LzR4bCxLujPVM/1PKAEjzSZKZCxWOalm7JUGZbhQqw==", 368 386 "dependencies": { 369 - "@aws-sdk/core": "^3.974.8", 370 - "@aws-sdk/credential-provider-env": "^3.972.34", 371 - "@aws-sdk/credential-provider-http": "^3.972.36", 372 - "@aws-sdk/credential-provider-login": "^3.972.38", 373 - "@aws-sdk/credential-provider-process": "^3.972.34", 374 - "@aws-sdk/credential-provider-sso": "^3.972.38", 375 - "@aws-sdk/credential-provider-web-identity": "^3.972.38", 376 - "@aws-sdk/nested-clients": "^3.997.6", 377 - "@aws-sdk/types": "^3.973.8", 378 - "@smithy/credential-provider-imds": "^4.2.14", 379 - "@smithy/property-provider": "^4.2.14", 380 - "@smithy/shared-ini-file-loader": "^4.4.9", 381 - "@smithy/types": "^4.14.1", 387 + "@aws-sdk/core": "^3.974.28", 388 + "@aws-sdk/credential-provider-env": "^3.972.54", 389 + "@aws-sdk/credential-provider-http": "^3.972.56", 390 + "@aws-sdk/credential-provider-login": "^3.972.60", 391 + "@aws-sdk/credential-provider-process": "^3.972.54", 392 + "@aws-sdk/credential-provider-sso": "^3.972.60", 393 + "@aws-sdk/credential-provider-web-identity": "^3.972.60", 394 + "@aws-sdk/nested-clients": "^3.997.28", 395 + "@aws-sdk/types": "^3.973.15", 396 + "@smithy/core": "^3.29.0", 397 + "@smithy/credential-provider-imds": "^4.4.5", 398 + "@smithy/types": "^4.15.1", 382 399 "tslib": "^2.6.2" 383 400 }, 384 401 "engines": { ··· 386 403 } 387 404 }, 388 405 "node_modules/@aws-sdk/credential-provider-login": { 389 - "version": "3.972.38", 390 - "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-login/-/credential-provider-login-3.972.38.tgz", 391 - "integrity": "sha512-g1NosS8qe4OF++G2UFCM5ovSkgipC7YYor5KCWatG0UoMSO5YFj9C8muePlyVmOBV/WTI16Jo3/s1NUo/o1Bww==", 406 + "version": "3.972.60", 407 + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-login/-/credential-provider-login-3.972.60.tgz", 408 + "integrity": "sha512-Ak6OOrCbXvACyxLFIP1mcS+JTLS9ZpW1ZqyBtqu6axvdpsbG1gVNhUlAfQq8TK/gar/h2w35LrzlQU0PcUzJpw==", 392 409 "dependencies": { 393 - "@aws-sdk/core": "^3.974.8", 394 - "@aws-sdk/nested-clients": "^3.997.6", 395 - "@aws-sdk/types": "^3.973.8", 396 - "@smithy/property-provider": "^4.2.14", 397 - "@smithy/protocol-http": "^5.3.14", 398 - "@smithy/shared-ini-file-loader": "^4.4.9", 399 - "@smithy/types": "^4.14.1", 410 + "@aws-sdk/core": "^3.974.28", 411 + "@aws-sdk/nested-clients": "^3.997.28", 412 + "@aws-sdk/types": "^3.973.15", 413 + "@smithy/core": "^3.29.0", 414 + "@smithy/types": "^4.15.1", 400 415 "tslib": "^2.6.2" 401 416 }, 402 417 "engines": { ··· 404 419 } 405 420 }, 406 421 "node_modules/@aws-sdk/credential-provider-node": { 407 - "version": "3.972.39", 408 - "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-node/-/credential-provider-node-3.972.39.tgz", 409 - "integrity": "sha512-HEswDQyxUtadoZ/bJsPPENHg7R0Lzym5LuMksJeHvqhCOpP+rtkDLKI4/ZChH4w3cf5kG8n6bZuI8PzajoiqMg==", 422 + "version": "3.972.63", 423 + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-node/-/credential-provider-node-3.972.63.tgz", 424 + "integrity": "sha512-YmgWtTPZDStyT74ApSHpApD3r7W9znsc+WEZjW0vceom+NAxRx9/F3TyukOKix8kJkPaa49aIREQJdpGeLDiEw==", 410 425 "dependencies": { 411 - "@aws-sdk/credential-provider-env": "^3.972.34", 412 - "@aws-sdk/credential-provider-http": "^3.972.36", 413 - "@aws-sdk/credential-provider-ini": "^3.972.38", 414 - "@aws-sdk/credential-provider-process": "^3.972.34", 415 - "@aws-sdk/credential-provider-sso": "^3.972.38", 416 - "@aws-sdk/credential-provider-web-identity": "^3.972.38", 417 - "@aws-sdk/types": "^3.973.8", 418 - "@smithy/credential-provider-imds": "^4.2.14", 419 - "@smithy/property-provider": "^4.2.14", 420 - "@smithy/shared-ini-file-loader": "^4.4.9", 421 - "@smithy/types": "^4.14.1", 426 + "@aws-sdk/credential-provider-env": "^3.972.54", 427 + "@aws-sdk/credential-provider-http": "^3.972.56", 428 + "@aws-sdk/credential-provider-ini": "^3.972.61", 429 + "@aws-sdk/credential-provider-process": "^3.972.54", 430 + "@aws-sdk/credential-provider-sso": "^3.972.60", 431 + "@aws-sdk/credential-provider-web-identity": "^3.972.60", 432 + "@aws-sdk/types": "^3.973.15", 433 + "@smithy/core": "^3.29.0", 434 + "@smithy/credential-provider-imds": "^4.4.5", 435 + "@smithy/types": "^4.15.1", 422 436 "tslib": "^2.6.2" 423 437 }, 424 438 "engines": { ··· 426 440 } 427 441 }, 428 442 "node_modules/@aws-sdk/credential-provider-process": { 429 - "version": "3.972.34", 430 - "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-process/-/credential-provider-process-3.972.34.tgz", 431 - "integrity": "sha512-T3IFs4EVmVi1dVN5RciFnklCANSzvrQd/VuHY9ThHSQmYkTogjcGkoJEr+oNUPQZnso52183088NqysMPji1/Q==", 443 + "version": "3.972.54", 444 + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-process/-/credential-provider-process-3.972.54.tgz", 445 + "integrity": "sha512-UNmUjtTnp3wH3YTZcctN7yK17S2AdaJpiNIdIf0l70hHOdGuDfdMxk62P5rt64GwGm6qkFOYG0js/cU6cdZDdg==", 432 446 "dependencies": { 433 - "@aws-sdk/core": "^3.974.8", 434 - "@aws-sdk/types": "^3.973.8", 435 - "@smithy/property-provider": "^4.2.14", 436 - "@smithy/shared-ini-file-loader": "^4.4.9", 437 - "@smithy/types": "^4.14.1", 447 + "@aws-sdk/core": "^3.974.28", 448 + "@aws-sdk/types": "^3.973.15", 449 + "@smithy/core": "^3.29.0", 450 + "@smithy/types": "^4.15.1", 438 451 "tslib": "^2.6.2" 439 452 }, 440 453 "engines": { ··· 442 455 } 443 456 }, 444 457 "node_modules/@aws-sdk/credential-provider-sso": { 445 - "version": "3.972.38", 446 - "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-sso/-/credential-provider-sso-3.972.38.tgz", 447 - "integrity": "sha512-5ZxG+t0+3Q3QPh8KEjX6syskhgNf7I0MN7oGioTf6Lm1NTjfP7sIcYGNsthXC2qR8vcD3edNZwCr2ovfSSWuRA==", 458 + "version": "3.972.60", 459 + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-sso/-/credential-provider-sso-3.972.60.tgz", 460 + "integrity": "sha512-zH8SvJkTRw1Kb7GARjGPjzkQPfL3jDi/OxK32I5SQq7bgwgFHJZaoDEwkEvFlfNfpByM6n4Rs20Y1mLyOdb6ag==", 448 461 "dependencies": { 449 - "@aws-sdk/core": "^3.974.8", 450 - "@aws-sdk/nested-clients": "^3.997.6", 451 - "@aws-sdk/token-providers": "3.1041.0", 452 - "@aws-sdk/types": "^3.973.8", 453 - "@smithy/property-provider": "^4.2.14", 454 - "@smithy/shared-ini-file-loader": "^4.4.9", 455 - "@smithy/types": "^4.14.1", 462 + "@aws-sdk/core": "^3.974.28", 463 + "@aws-sdk/nested-clients": "^3.997.28", 464 + "@aws-sdk/token-providers": "3.1080.0", 465 + "@aws-sdk/types": "^3.973.15", 466 + "@smithy/core": "^3.29.0", 467 + "@smithy/types": "^4.15.1", 456 468 "tslib": "^2.6.2" 457 469 }, 458 470 "engines": { ··· 460 472 } 461 473 }, 462 474 "node_modules/@aws-sdk/credential-provider-web-identity": { 463 - "version": "3.972.38", 464 - "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-web-identity/-/credential-provider-web-identity-3.972.38.tgz", 465 - "integrity": "sha512-lYHFF30DGI20jZcYX8cm6Ns0V7f1dDN6g/MBDLTyD/5iw+bXs3yBr2iAiHDkx4RFU5JgsnZvCHYKiRVPRdmOgw==", 475 + "version": "3.972.60", 476 + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-web-identity/-/credential-provider-web-identity-3.972.60.tgz", 477 + "integrity": "sha512-q2rJSQ/AMjemUS18OtQqczqSo2R6VjOCLmLVmLVcdrP5/AKoj632lGCrMUWQ6EgnaLMEHbq0UO4mWh/u6gjPhA==", 466 478 "dependencies": { 467 - "@aws-sdk/core": "^3.974.8", 468 - "@aws-sdk/nested-clients": "^3.997.6", 469 - "@aws-sdk/types": "^3.973.8", 470 - "@smithy/property-provider": "^4.2.14", 471 - "@smithy/shared-ini-file-loader": "^4.4.9", 472 - "@smithy/types": "^4.14.1", 479 + "@aws-sdk/core": "^3.974.28", 480 + "@aws-sdk/nested-clients": "^3.997.28", 481 + "@aws-sdk/types": "^3.973.15", 482 + "@smithy/core": "^3.29.0", 483 + "@smithy/types": "^4.15.1", 473 484 "tslib": "^2.6.2" 474 485 }, 475 486 "engines": { ··· 580 591 "node": ">=20.0.0" 581 592 } 582 593 }, 583 - "node_modules/@aws-sdk/middleware-sdk-s3": { 584 - "version": "3.972.37", 585 - "resolved": "https://registry.npmjs.org/@aws-sdk/middleware-sdk-s3/-/middleware-sdk-s3-3.972.37.tgz", 586 - "integrity": "sha512-Km7M+i8DrLArVzrid1gfxeGhYHBd3uxvE77g0s5a52zPSVosxzQBnJ0gwWb6NIp/DOk8gsBMhi7V+cpJG0ndTA==", 587 - "dependencies": { 588 - "@aws-sdk/core": "^3.974.8", 589 - "@aws-sdk/types": "^3.973.8", 590 - "@aws-sdk/util-arn-parser": "^3.972.3", 591 - "@smithy/core": "^3.23.17", 592 - "@smithy/node-config-provider": "^4.3.14", 593 - "@smithy/protocol-http": "^5.3.14", 594 - "@smithy/signature-v4": "^5.3.14", 595 - "@smithy/smithy-client": "^4.12.13", 596 - "@smithy/types": "^4.14.1", 597 - "@smithy/util-config-provider": "^4.2.2", 598 - "@smithy/util-middleware": "^4.2.14", 599 - "@smithy/util-stream": "^4.5.25", 600 - "@smithy/util-utf8": "^4.2.2", 601 - "tslib": "^2.6.2" 602 - }, 603 - "engines": { 604 - "node": ">=20.0.0" 605 - } 606 - }, 607 594 "node_modules/@aws-sdk/middleware-user-agent": { 608 595 "version": "3.972.38", 609 596 "resolved": "https://registry.npmjs.org/@aws-sdk/middleware-user-agent/-/middleware-user-agent-3.972.38.tgz", ··· 623 610 } 624 611 }, 625 612 "node_modules/@aws-sdk/nested-clients": { 626 - "version": "3.997.6", 627 - "resolved": "https://registry.npmjs.org/@aws-sdk/nested-clients/-/nested-clients-3.997.6.tgz", 628 - "integrity": "sha512-WBDnqatJl+kGObpfmfSxqnXeYTu3Me8wx8WCtvoxX3pfWrrTv8I4WTMSSs7PZqcRcVh8WeUKMgGFjMG+52SR1w==", 613 + "version": "3.997.28", 614 + "resolved": "https://registry.npmjs.org/@aws-sdk/nested-clients/-/nested-clients-3.997.28.tgz", 615 + "integrity": "sha512-1oG/mM3jmE2M3kad2zWJS6IKIY8hjRV4l5kAgg+xTQdLMTtehhcSucL/y4WqQpcHmQwi6+gRK8E46GYl1NBW9Q==", 629 616 "dependencies": { 630 - "@aws-crypto/sha256-browser": "5.2.0", 631 - "@aws-crypto/sha256-js": "5.2.0", 632 - "@aws-sdk/core": "^3.974.8", 633 - "@aws-sdk/middleware-host-header": "^3.972.10", 634 - "@aws-sdk/middleware-logger": "^3.972.10", 635 - "@aws-sdk/middleware-recursion-detection": "^3.972.11", 636 - "@aws-sdk/middleware-user-agent": "^3.972.38", 637 - "@aws-sdk/region-config-resolver": "^3.972.13", 638 - "@aws-sdk/signature-v4-multi-region": "^3.996.25", 639 - "@aws-sdk/types": "^3.973.8", 640 - "@aws-sdk/util-endpoints": "^3.996.8", 641 - "@aws-sdk/util-user-agent-browser": "^3.972.10", 642 - "@aws-sdk/util-user-agent-node": "^3.973.24", 643 - "@smithy/config-resolver": "^4.4.17", 644 - "@smithy/core": "^3.23.17", 645 - "@smithy/fetch-http-handler": "^5.3.17", 646 - "@smithy/hash-node": "^4.2.14", 647 - "@smithy/invalid-dependency": "^4.2.14", 648 - "@smithy/middleware-content-length": "^4.2.14", 649 - "@smithy/middleware-endpoint": "^4.4.32", 650 - "@smithy/middleware-retry": "^4.5.7", 651 - "@smithy/middleware-serde": "^4.2.20", 652 - "@smithy/middleware-stack": "^4.2.14", 653 - "@smithy/node-config-provider": "^4.3.14", 654 - "@smithy/node-http-handler": "^4.6.1", 655 - "@smithy/protocol-http": "^5.3.14", 656 - "@smithy/smithy-client": "^4.12.13", 657 - "@smithy/types": "^4.14.1", 658 - "@smithy/url-parser": "^4.2.14", 659 - "@smithy/util-base64": "^4.3.2", 660 - "@smithy/util-body-length-browser": "^4.2.2", 661 - "@smithy/util-body-length-node": "^4.2.3", 662 - "@smithy/util-defaults-mode-browser": "^4.3.49", 663 - "@smithy/util-defaults-mode-node": "^4.2.54", 664 - "@smithy/util-endpoints": "^3.4.2", 665 - "@smithy/util-middleware": "^4.2.14", 666 - "@smithy/util-retry": "^4.3.6", 667 - "@smithy/util-utf8": "^4.2.2", 617 + "@aws-sdk/core": "^3.974.28", 618 + "@aws-sdk/signature-v4-multi-region": "^3.996.38", 619 + "@aws-sdk/types": "^3.973.15", 620 + "@smithy/core": "^3.29.0", 621 + "@smithy/fetch-http-handler": "^5.6.2", 622 + "@smithy/node-http-handler": "^4.9.2", 623 + "@smithy/types": "^4.15.1", 668 624 "tslib": "^2.6.2" 669 625 }, 670 626 "engines": { ··· 687 643 } 688 644 }, 689 645 "node_modules/@aws-sdk/signature-v4-multi-region": { 690 - "version": "3.996.25", 691 - "resolved": "https://registry.npmjs.org/@aws-sdk/signature-v4-multi-region/-/signature-v4-multi-region-3.996.25.tgz", 692 - "integrity": "sha512-+CMIt3e1VzlklAECmG+DtP1sV8iKq25FuA0OKpnJ4KA0kxUtd7CgClY7/RU6VzJBQwbN4EJ9Ue6plvqx1qGadw==", 646 + "version": "3.996.38", 647 + "resolved": "https://registry.npmjs.org/@aws-sdk/signature-v4-multi-region/-/signature-v4-multi-region-3.996.38.tgz", 648 + "integrity": "sha512-C379Sk+MiFZCfWZphKlMyLHKxV22OjoGM5KJjj5IJNJcOCWL4IGIpnEGzv1FQiRwhYXfq55SJMfxlqPE08JJ9g==", 693 649 "dependencies": { 694 - "@aws-sdk/middleware-sdk-s3": "^3.972.37", 695 - "@aws-sdk/types": "^3.973.8", 696 - "@smithy/protocol-http": "^5.3.14", 697 - "@smithy/signature-v4": "^5.3.14", 698 - "@smithy/types": "^4.14.1", 650 + "@aws-sdk/types": "^3.973.15", 651 + "@smithy/signature-v4": "^5.6.1", 652 + "@smithy/types": "^4.15.1", 699 653 "tslib": "^2.6.2" 700 654 }, 701 655 "engines": { ··· 703 657 } 704 658 }, 705 659 "node_modules/@aws-sdk/token-providers": { 706 - "version": "3.1041.0", 707 - "resolved": "https://registry.npmjs.org/@aws-sdk/token-providers/-/token-providers-3.1041.0.tgz", 708 - "integrity": "sha512-Th7kPI6YPtvJUcdznooXJMy+9rQWjmEF81LxaJssngBzuysK4a/x+l8kjm1zb7nYsUPbndnBdUnwng/3PLvtGw==", 660 + "version": "3.1080.0", 661 + "resolved": "https://registry.npmjs.org/@aws-sdk/token-providers/-/token-providers-3.1080.0.tgz", 662 + "integrity": "sha512-8PufAQvncWXvdZUvODbuyXa8l3aszefEzwSMBUcgheNbZOmJMcNn388Ebt/piVrUHyxKN1MlxnR2OonzTyZaGw==", 709 663 "dependencies": { 710 - "@aws-sdk/core": "^3.974.8", 711 - "@aws-sdk/nested-clients": "^3.997.6", 712 - "@aws-sdk/types": "^3.973.8", 713 - "@smithy/property-provider": "^4.2.14", 714 - "@smithy/shared-ini-file-loader": "^4.4.9", 715 - "@smithy/types": "^4.14.1", 664 + "@aws-sdk/core": "^3.974.28", 665 + "@aws-sdk/nested-clients": "^3.997.28", 666 + "@aws-sdk/types": "^3.973.15", 667 + "@smithy/core": "^3.29.0", 668 + "@smithy/types": "^4.15.1", 716 669 "tslib": "^2.6.2" 717 670 }, 718 671 "engines": { ··· 720 673 } 721 674 }, 722 675 "node_modules/@aws-sdk/types": { 723 - "version": "3.973.8", 724 - "resolved": "https://registry.npmjs.org/@aws-sdk/types/-/types-3.973.8.tgz", 725 - "integrity": "sha512-gjlAdtHMbtR9X5iIhVUvbVcy55KnznpC6bkDUWW9z915bi0ckdUr5cjf16Kp6xq0bP5HBD2xzgbL9F9Quv5vUw==", 726 - "dependencies": { 727 - "@smithy/types": "^4.14.1", 728 - "tslib": "^2.6.2" 729 - }, 730 - "engines": { 731 - "node": ">=20.0.0" 732 - } 733 - }, 734 - "node_modules/@aws-sdk/util-arn-parser": { 735 - "version": "3.972.3", 736 - "resolved": "https://registry.npmjs.org/@aws-sdk/util-arn-parser/-/util-arn-parser-3.972.3.tgz", 737 - "integrity": "sha512-HzSD8PMFrvgi2Kserxuff5VitNq2sgf3w9qxmskKDiDTThWfVteJxuCS9JXiPIPtmCrp+7N9asfIaVhBFORllA==", 676 + "version": "3.973.15", 677 + "resolved": "https://registry.npmjs.org/@aws-sdk/types/-/types-3.973.15.tgz", 678 + "integrity": "sha512-IULn8uBV/SMtmOIANsm4WHXIOtVPBWfOWs3WGL0j/sI+KhaYehvOw0ET+9urnn8MBpiijuU/0JOpuwKOE451PQ==", 738 679 "dependencies": { 680 + "@smithy/types": "^4.15.1", 739 681 "tslib": "^2.6.2" 740 682 }, 741 683 "engines": { ··· 818 760 } 819 761 }, 820 762 "node_modules/@aws-sdk/xml-builder": { 821 - "version": "3.972.22", 822 - "resolved": "https://registry.npmjs.org/@aws-sdk/xml-builder/-/xml-builder-3.972.22.tgz", 823 - "integrity": "sha512-PMYKKtJd70IsSG0yHrdAbxBr+ZWBKLvzFZfD3/urxgf6hXVMzuU5M+3MJ5G67RpOmLBu1fAUN65SbWuKUCOlAA==", 763 + "version": "3.972.33", 764 + "resolved": "https://registry.npmjs.org/@aws-sdk/xml-builder/-/xml-builder-3.972.33.tgz", 765 + "integrity": "sha512-ezbwz9WpuLctm6o7P2t2naDhVVPI5jFGrVefVybhcKGjU57VIyT46pQVO0RI2RYkUdhdj2Z9uSIlAzGZE9NW9A==", 824 766 "dependencies": { 825 - "@nodable/entities": "2.1.0", 826 - "@smithy/types": "^4.14.1", 827 - "fast-xml-parser": "5.7.2", 767 + "@smithy/types": "^4.15.1", 828 768 "tslib": "^2.6.2" 829 769 }, 830 770 "engines": { ··· 1335 1275 "url": "https://paulmillr.com/funding/" 1336 1276 } 1337 1277 }, 1338 - "node_modules/@nodable/entities": { 1339 - "version": "2.1.0", 1340 - "resolved": "https://registry.npmjs.org/@nodable/entities/-/entities-2.1.0.tgz", 1341 - "integrity": "sha512-nyT7T3nbMyBI/lvr6L5TyWbFJAI9FTgVRakNoBqCD+PmID8DzFrrNdLLtHMwMszOtqZa8PAOV24ZqDnQrhQINA==", 1342 - "funding": [ 1343 - { 1344 - "type": "github", 1345 - "url": "https://github.com/sponsors/nodable" 1346 - } 1347 - ] 1348 - }, 1349 1278 "node_modules/@oxc-project/types": { 1350 1279 "version": "0.133.0", 1351 1280 "resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.133.0.tgz", ··· 1620 1549 } 1621 1550 }, 1622 1551 "node_modules/@smithy/core": { 1623 - "version": "3.23.17", 1624 - "resolved": "https://registry.npmjs.org/@smithy/core/-/core-3.23.17.tgz", 1625 - "integrity": "sha512-x7BlLbUFL8NWCGjMF9C+1N5cVCxcPa7g6Tv9B4A2luWx3be3oU8hQ96wIwxe/s7OhIzvoJH73HAUSg5JXVlEtQ==", 1552 + "version": "3.29.1", 1553 + "resolved": "https://registry.npmjs.org/@smithy/core/-/core-3.29.1.tgz", 1554 + "integrity": "sha512-qoiY4nrk5OCu1+eIR1VB8l5DmON/oKiqrd5zZFAhXJXjJlLWQusKEW/SkBDAtGDcPaz86m9kfcE1lngU0GlM6A==", 1626 1555 "dependencies": { 1627 - "@smithy/protocol-http": "^5.3.14", 1628 - "@smithy/types": "^4.14.1", 1629 - "@smithy/url-parser": "^4.2.14", 1630 - "@smithy/util-base64": "^4.3.2", 1631 - "@smithy/util-body-length-browser": "^4.2.2", 1632 - "@smithy/util-middleware": "^4.2.14", 1633 - "@smithy/util-stream": "^4.5.25", 1634 - "@smithy/util-utf8": "^4.2.2", 1635 - "@smithy/uuid": "^1.1.2", 1556 + "@smithy/types": "^4.15.1", 1636 1557 "tslib": "^2.6.2" 1637 1558 }, 1638 1559 "engines": { ··· 1640 1561 } 1641 1562 }, 1642 1563 "node_modules/@smithy/credential-provider-imds": { 1643 - "version": "4.2.14", 1644 - "resolved": "https://registry.npmjs.org/@smithy/credential-provider-imds/-/credential-provider-imds-4.2.14.tgz", 1645 - "integrity": "sha512-Au28zBN48ZAoXdooGUHemuVBrkE+Ie6RPmGNIAJsFqj33Vhb6xAgRifUydZ2aY+M+KaMAETAlKk5NC5h1G7wpg==", 1564 + "version": "4.4.6", 1565 + "resolved": "https://registry.npmjs.org/@smithy/credential-provider-imds/-/credential-provider-imds-4.4.6.tgz", 1566 + "integrity": "sha512-B2WQ/PV/H6Jeg3lrIq6bKUfa6Hy01mtK7CGs6lhjzHA6k4aagldH6T6eEjnzKl4HI0cJnAsxfJ19pgb5PV+CVQ==", 1646 1567 "dependencies": { 1647 - "@smithy/node-config-provider": "^4.3.14", 1648 - "@smithy/property-provider": "^4.2.14", 1649 - "@smithy/types": "^4.14.1", 1650 - "@smithy/url-parser": "^4.2.14", 1568 + "@smithy/core": "^3.29.1", 1569 + "@smithy/types": "^4.15.1", 1651 1570 "tslib": "^2.6.2" 1652 1571 }, 1653 1572 "engines": { ··· 1655 1574 } 1656 1575 }, 1657 1576 "node_modules/@smithy/fetch-http-handler": { 1658 - "version": "5.3.17", 1659 - "resolved": "https://registry.npmjs.org/@smithy/fetch-http-handler/-/fetch-http-handler-5.3.17.tgz", 1660 - "integrity": "sha512-bXOvQzaSm6MnmLaWA1elgfQcAtN4UP3vXqV97bHuoOrHQOJiLT3ds6o9eo5bqd0TJfRFpzdGnDQdW3FACiAVdw==", 1577 + "version": "5.6.3", 1578 + "resolved": "https://registry.npmjs.org/@smithy/fetch-http-handler/-/fetch-http-handler-5.6.3.tgz", 1579 + "integrity": "sha512-CwCc/7SMTj45y97MUnDTbTaxvtAsiNNRm81z3abROIuMbMsC2Iy5EKfkkVdsKrz8WExQAAMx1EJapq+9j4fFTQ==", 1661 1580 "dependencies": { 1662 - "@smithy/protocol-http": "^5.3.14", 1663 - "@smithy/querystring-builder": "^4.2.14", 1664 - "@smithy/types": "^4.14.1", 1665 - "@smithy/util-base64": "^4.3.2", 1581 + "@smithy/core": "^3.29.1", 1582 + "@smithy/types": "^4.15.1", 1666 1583 "tslib": "^2.6.2" 1667 1584 }, 1668 1585 "engines": { ··· 1798 1715 } 1799 1716 }, 1800 1717 "node_modules/@smithy/node-http-handler": { 1801 - "version": "4.6.1", 1802 - "resolved": "https://registry.npmjs.org/@smithy/node-http-handler/-/node-http-handler-4.6.1.tgz", 1803 - "integrity": "sha512-iB+orM4x3xrr57X3YaXazfKnntl0LHlZB1kcXSGzMV1Tt0+YwEjGlbjk/44qEGtBzXAz6yFDzkYTKSV6Pj2HUg==", 1718 + "version": "4.9.3", 1719 + "resolved": "https://registry.npmjs.org/@smithy/node-http-handler/-/node-http-handler-4.9.3.tgz", 1720 + "integrity": "sha512-qZTa4gQFUo8RM02rk6q5UVTDLNrQ1oS20LsepBzqq1QBVc/EHJ03OOUADcqMZiXHArW+Y7+OGY0BpdTwZRq/Yg==", 1804 1721 "dependencies": { 1805 - "@smithy/protocol-http": "^5.3.14", 1806 - "@smithy/querystring-builder": "^4.2.14", 1807 - "@smithy/types": "^4.14.1", 1722 + "@smithy/core": "^3.29.1", 1723 + "@smithy/types": "^4.15.1", 1808 1724 "tslib": "^2.6.2" 1809 1725 }, 1810 1726 "engines": { ··· 1835 1751 "node": ">=18.0.0" 1836 1752 } 1837 1753 }, 1838 - "node_modules/@smithy/querystring-builder": { 1839 - "version": "4.2.14", 1840 - "resolved": "https://registry.npmjs.org/@smithy/querystring-builder/-/querystring-builder-4.2.14.tgz", 1841 - "integrity": "sha512-XYA5Z0IqTeF+5XDdh4BBmSA0HvbgVZIyv4cmOoUheDNR57K1HgBp9ukUMx3Cr3XpDHHpLBnexPE3LAtDsZkj2A==", 1842 - "dependencies": { 1843 - "@smithy/types": "^4.14.1", 1844 - "@smithy/util-uri-escape": "^4.2.2", 1845 - "tslib": "^2.6.2" 1846 - }, 1847 - "engines": { 1848 - "node": ">=18.0.0" 1849 - } 1850 - }, 1851 1754 "node_modules/@smithy/querystring-parser": { 1852 1755 "version": "4.2.14", 1853 1756 "resolved": "https://registry.npmjs.org/@smithy/querystring-parser/-/querystring-parser-4.2.14.tgz", ··· 1884 1787 } 1885 1788 }, 1886 1789 "node_modules/@smithy/signature-v4": { 1887 - "version": "5.3.14", 1888 - "resolved": "https://registry.npmjs.org/@smithy/signature-v4/-/signature-v4-5.3.14.tgz", 1889 - "integrity": "sha512-1D9Y/nmlVjCeSivCbhZ7hgEpmHyY1h0GvpSZt3l0xcD9JjmjVC1CHOozS6+Gh+/ldMH8JuJ6cujObQqfayAVFA==", 1790 + "version": "5.6.2", 1791 + "resolved": "https://registry.npmjs.org/@smithy/signature-v4/-/signature-v4-5.6.2.tgz", 1792 + "integrity": "sha512-QgHflghMoPxCJ9axiCVh8KZfbC9fuP6vkXXyK//E3cq7nLaSSyyLj0GAoqVWezYeDQmXIZhmlRvLE16jsqDK6g==", 1890 1793 "dependencies": { 1891 - "@smithy/is-array-buffer": "^4.2.2", 1892 - "@smithy/protocol-http": "^5.3.14", 1893 - "@smithy/types": "^4.14.1", 1894 - "@smithy/util-hex-encoding": "^4.2.2", 1895 - "@smithy/util-middleware": "^4.2.14", 1896 - "@smithy/util-uri-escape": "^4.2.2", 1897 - "@smithy/util-utf8": "^4.2.2", 1794 + "@smithy/core": "^3.29.1", 1795 + "@smithy/types": "^4.15.1", 1898 1796 "tslib": "^2.6.2" 1899 1797 }, 1900 1798 "engines": { ··· 1919 1817 } 1920 1818 }, 1921 1819 "node_modules/@smithy/types": { 1922 - "version": "4.14.1", 1923 - "resolved": "https://registry.npmjs.org/@smithy/types/-/types-4.14.1.tgz", 1924 - "integrity": "sha512-59b5HtSVrVR/eYNei3BUj3DCPKD/G7EtDDe7OEJE7i7FtQFugYo6MxbotS8mVJkLNVf8gYaAlEBwwtJ9HzhWSg==", 1820 + "version": "4.15.1", 1821 + "resolved": "https://registry.npmjs.org/@smithy/types/-/types-4.15.1.tgz", 1822 + "integrity": "sha512-x3L0XSACF6UYzKpa9biqiRMgvH5+wnFFew9Tm/grFYqgaupPwx/+ojDPpPJM8dZON3S9tjz5U+PQYsCBd1Mw5Q==", 1925 1823 "dependencies": { 1926 1824 "tslib": "^2.6.2" 1927 1825 }, ··· 2092 1990 "@smithy/util-buffer-from": "^4.2.2", 2093 1991 "@smithy/util-hex-encoding": "^4.2.2", 2094 1992 "@smithy/util-utf8": "^4.2.2", 2095 - "tslib": "^2.6.2" 2096 - }, 2097 - "engines": { 2098 - "node": ">=18.0.0" 2099 - } 2100 - }, 2101 - "node_modules/@smithy/util-uri-escape": { 2102 - "version": "4.2.2", 2103 - "resolved": "https://registry.npmjs.org/@smithy/util-uri-escape/-/util-uri-escape-4.2.2.tgz", 2104 - "integrity": "sha512-2kAStBlvq+lTXHyAZYfJRb/DfS3rsinLiwb+69SstC9Vb0s9vNWkRwpnj918Pfi85mzi42sOqdV72OLxWAISnw==", 2105 - "dependencies": { 2106 1993 "tslib": "^2.6.2" 2107 1994 }, 2108 1995 "engines": { ··· 2380 2267 "node": ">=12.0.0" 2381 2268 } 2382 2269 }, 2383 - "node_modules/fast-xml-builder": { 2384 - "version": "1.2.0", 2385 - "resolved": "https://registry.npmjs.org/fast-xml-builder/-/fast-xml-builder-1.2.0.tgz", 2386 - "integrity": "sha512-00aAWieqff+ZJhsXA4g1g7M8k+7AYoMUUHF+/zFb5U6Uv/P0Vl4QZo84/IcufzYalLuEj9928bXN9PbbFzMF0Q==", 2387 - "funding": [ 2388 - { 2389 - "type": "github", 2390 - "url": "https://github.com/sponsors/NaturalIntelligence" 2391 - } 2392 - ], 2393 - "dependencies": { 2394 - "path-expression-matcher": "^1.5.0", 2395 - "xml-naming": "^0.1.0" 2396 - } 2397 - }, 2398 - "node_modules/fast-xml-parser": { 2399 - "version": "5.7.2", 2400 - "resolved": "https://registry.npmjs.org/fast-xml-parser/-/fast-xml-parser-5.7.2.tgz", 2401 - "integrity": "sha512-P7oW7tLbYnhOLQk/Gv7cZgzgMPP/XN03K02/Jy6Y/NHzyIAIpxuZIM/YqAkfiXFPxA2CTm7NtCijK9EDu09u2w==", 2402 - "funding": [ 2403 - { 2404 - "type": "github", 2405 - "url": "https://github.com/sponsors/NaturalIntelligence" 2406 - } 2407 - ], 2408 - "dependencies": { 2409 - "@nodable/entities": "^2.1.0", 2410 - "fast-xml-builder": "^1.1.5", 2411 - "path-expression-matcher": "^1.5.0", 2412 - "strnum": "^2.2.3" 2413 - }, 2414 - "bin": { 2415 - "fxparser": "src/cli/cli.js" 2416 - } 2417 - }, 2418 2270 "node_modules/fdir": { 2419 2271 "version": "6.5.0", 2420 2272 "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", ··· 2753 2605 "node": ">=12.20.0" 2754 2606 } 2755 2607 }, 2756 - "node_modules/path-expression-matcher": { 2757 - "version": "1.5.0", 2758 - "resolved": "https://registry.npmjs.org/path-expression-matcher/-/path-expression-matcher-1.5.0.tgz", 2759 - "integrity": "sha512-cbrerZV+6rvdQrrD+iGMcZFEiiSrbv9Tfdkvnusy6y0x0GKBXREFg/Y65GhIfm0tnLntThhzCnfKwp1WRjeCyQ==", 2760 - "funding": [ 2761 - { 2762 - "type": "github", 2763 - "url": "https://github.com/sponsors/NaturalIntelligence" 2764 - } 2765 - ], 2766 - "engines": { 2767 - "node": ">=14.0.0" 2768 - } 2769 - }, 2770 2608 "node_modules/pathe": { 2771 2609 "version": "2.0.3", 2772 2610 "resolved": "https://registry.npmjs.org/pathe/-/pathe-2.0.3.tgz", ··· 2878 2716 "resolved": "https://registry.npmjs.org/std-env/-/std-env-4.1.0.tgz", 2879 2717 "integrity": "sha512-Rq7ybcX2RuC55r9oaPVEW7/xu3tj8u4GeBYHBWCychFtzMIr86A7e3PPEBPT37sHStKX3+TiX/Fr/ACmJLVlLQ==", 2880 2718 "dev": true 2881 - }, 2882 - "node_modules/strnum": { 2883 - "version": "2.3.0", 2884 - "resolved": "https://registry.npmjs.org/strnum/-/strnum-2.3.0.tgz", 2885 - "integrity": "sha512-ums3KNd42PGyx5xaoVTO1mjU1bH3NpY4vsrVlnv9PNGqQj8wd7rJ6nEypLrJ7z5vxK5RP0yMLo6J/Gsm62DI5Q==", 2886 - "funding": [ 2887 - { 2888 - "type": "github", 2889 - "url": "https://github.com/sponsors/NaturalIntelligence" 2890 - } 2891 - ] 2892 2719 }, 2893 2720 "node_modules/tinybench": { 2894 2721 "version": "2.9.0", ··· 3658 3485 }, 3659 3486 "engines": { 3660 3487 "node": ">=8" 3661 - } 3662 - }, 3663 - "node_modules/xml-naming": { 3664 - "version": "0.1.0", 3665 - "resolved": "https://registry.npmjs.org/xml-naming/-/xml-naming-0.1.0.tgz", 3666 - "integrity": "sha512-k8KO9hrMyNk6tUWqUfkTEZbezRRpONVOzUTnc97VnCvyj6Tf9lyUR9EDAIeiVLv56jsMcoXEwjW8Kv5yPY52lw==", 3667 - "funding": [ 3668 - { 3669 - "type": "github", 3670 - "url": "https://github.com/sponsors/NaturalIntelligence" 3671 - } 3672 - ], 3673 - "engines": { 3674 - "node": ">=16.0.0" 3675 3488 } 3676 3489 }, 3677 3490 "node_modules/zod": {
+1
infra/api/package.json
··· 11 11 "dependencies": { 12 12 "@atproto/crypto": "^0.4.5", 13 13 "@atproto/identity": "^0.4.12", 14 + "@aws-sdk/client-cloudfront": "^3.1080.0", 14 15 "@aws-sdk/client-dynamodb": "^3", 15 16 "@aws-sdk/client-secrets-manager": "^3", 16 17 "@aws-sdk/lib-dynamodb": "^3"
+85
infra/api/src/domain-utils.test.ts
··· 1 + import { describe, it, expect } from 'vitest'; 2 + import { normalizeCustomDomain } from './domain-utils'; 3 + 4 + const SITE = 'lemma.pub'; 5 + const ENDPOINT = 'd123abc.cloudfront.net'; 6 + 7 + describe('normalizeCustomDomain', () => { 8 + it('accepts a plain subdomain', () => { 9 + expect(normalizeCustomDomain('myblog.example.com', SITE)).toBe('myblog.example.com'); 10 + }); 11 + 12 + it('accepts an apex domain', () => { 13 + expect(normalizeCustomDomain('example.com', SITE)).toBe('example.com'); 14 + }); 15 + 16 + it('lowercases input', () => { 17 + expect(normalizeCustomDomain('MyBlog.Example.COM', SITE)).toBe('myblog.example.com'); 18 + }); 19 + 20 + it('trims whitespace and a trailing dot', () => { 21 + expect(normalizeCustomDomain(' myblog.example.com. ', SITE)).toBe('myblog.example.com'); 22 + }); 23 + 24 + it('converts internationalized domain names to punycode', () => { 25 + expect(normalizeCustomDomain('bücher.example.com', SITE)).toBe('xn--bcher-kva.example.com'); 26 + }); 27 + 28 + it('rejects the empty string', () => { 29 + expect(normalizeCustomDomain('', SITE)).toBeNull(); 30 + }); 31 + 32 + it('rejects single-label hostnames', () => { 33 + expect(normalizeCustomDomain('myblog', SITE)).toBeNull(); 34 + }); 35 + 36 + it('rejects localhost and its subdomains', () => { 37 + expect(normalizeCustomDomain('localhost', SITE)).toBeNull(); 38 + expect(normalizeCustomDomain('foo.localhost', SITE)).toBeNull(); 39 + }); 40 + 41 + it('rejects the site domain and its subdomains', () => { 42 + expect(normalizeCustomDomain('lemma.pub', SITE)).toBeNull(); 43 + expect(normalizeCustomDomain('blog.lemma.pub', SITE)).toBeNull(); 44 + }); 45 + 46 + it('rejects CloudFront and AWS hostnames', () => { 47 + expect(normalizeCustomDomain('d111111abcdef8.cloudfront.net', SITE)).toBeNull(); 48 + expect(normalizeCustomDomain('foo.s3.amazonaws.com', SITE)).toBeNull(); 49 + }); 50 + 51 + it('rejects the routing endpoint itself', () => { 52 + expect(normalizeCustomDomain(ENDPOINT, SITE, ENDPOINT)).toBeNull(); 53 + }); 54 + 55 + it('rejects IPv4 addresses', () => { 56 + expect(normalizeCustomDomain('192.168.1.1', SITE)).toBeNull(); 57 + }); 58 + 59 + it('rejects IPv6 addresses', () => { 60 + expect(normalizeCustomDomain('[::1]', SITE)).toBeNull(); 61 + expect(normalizeCustomDomain('::1', SITE)).toBeNull(); 62 + }); 63 + 64 + it('rejects URLs with a scheme, path, port, query, or credentials', () => { 65 + expect(normalizeCustomDomain('https://myblog.example.com', SITE)).toBeNull(); 66 + expect(normalizeCustomDomain('myblog.example.com/blog', SITE)).toBeNull(); 67 + expect(normalizeCustomDomain('myblog.example.com:8080', SITE)).toBeNull(); 68 + expect(normalizeCustomDomain('myblog.example.com?x=1', SITE)).toBeNull(); 69 + expect(normalizeCustomDomain('user@myblog.example.com', SITE)).toBeNull(); 70 + }); 71 + 72 + it('rejects hostnames longer than 253 characters', () => { 73 + const long = `${'a'.repeat(250)}.example.com`; 74 + expect(normalizeCustomDomain(long, SITE)).toBeNull(); 75 + }); 76 + 77 + it('rejects labels with leading or trailing hyphens', () => { 78 + expect(normalizeCustomDomain('-bad.example.com', SITE)).toBeNull(); 79 + expect(normalizeCustomDomain('bad-.example.com', SITE)).toBeNull(); 80 + }); 81 + 82 + it('rejects underscores and other invalid characters', () => { 83 + expect(normalizeCustomDomain('my_blog.example.com', SITE)).toBeNull(); 84 + }); 85 + });
+48
infra/api/src/domain-utils.ts
··· 1 + // Validation and normalization for user-supplied custom blog domains. 2 + 3 + const BLOCKED_SUFFIXES = ['localhost', 'cloudfront.net', 'amazonaws.com']; 4 + 5 + // RFC 1035-ish hostname with at least two labels; letters/digits/hyphens only. 6 + const HOSTNAME_RE = /^[a-z0-9]([a-z0-9-]*[a-z0-9])?(\.[a-z0-9]([a-z0-9-]*[a-z0-9])?)+$/; 7 + 8 + const IPV4_RE = /^\d{1,3}(\.\d{1,3}){3}$/; 9 + 10 + /** 11 + * Normalizes a user-supplied domain to a bare lowercase (punycode) hostname. 12 + * Returns null when the input is not a plain hostname, is an IP address, or 13 + * targets infrastructure we must not serve tenants for (the site's own domain 14 + * and its subdomains, CloudFront/AWS endpoints, localhost). 15 + */ 16 + export function normalizeCustomDomain( 17 + input: string, 18 + siteDomain: string, 19 + routingEndpoint?: string, 20 + ): string | null { 21 + const raw = input.trim().toLowerCase().replace(/\.$/, ''); 22 + if (!raw || raw.length > 253) return null; 23 + 24 + let hostname: string; 25 + try { 26 + // URL handles punycode conversion for internationalized domain names. 27 + const url = new URL(`https://${raw}`); 28 + if (url.username || url.password || url.port || url.pathname !== '/' || url.search || url.hash) { 29 + return null; 30 + } 31 + hostname = url.hostname; 32 + } catch { 33 + return null; 34 + } 35 + 36 + // IPv6 literals come back bracketed and fail the hostname regex; reject IPv4 explicitly. 37 + if (IPV4_RE.test(hostname)) return null; 38 + if (!HOSTNAME_RE.test(hostname)) return null; 39 + 40 + const blocked = [...BLOCKED_SUFFIXES]; 41 + if (siteDomain) blocked.push(siteDomain.toLowerCase()); 42 + for (const suffix of blocked) { 43 + if (hostname === suffix || hostname.endsWith(`.${suffix}`)) return null; 44 + } 45 + if (routingEndpoint && hostname === routingEndpoint.toLowerCase()) return null; 46 + 47 + return hostname; 48 + }
+395 -17
infra/api/src/index.ts
··· 1 1 import { createHash } from 'crypto'; 2 2 import { escXml, buildAtomSummary, markdownToText } from './atom-utils'; 3 + import { normalizeCustomDomain } from './domain-utils'; 3 4 import { verifySignature } from '@atproto/crypto'; 4 5 import { IdResolver } from '@atproto/identity'; 6 + import { 7 + CloudFrontClient, 8 + CreateDistributionTenantCommand, 9 + DeleteDistributionTenantCommand, 10 + GetDistributionTenantCommand, 11 + GetManagedCertificateDetailsCommand, 12 + UpdateDistributionTenantCommand, 13 + } from '@aws-sdk/client-cloudfront'; 5 14 import { DynamoDBClient } from '@aws-sdk/client-dynamodb'; 6 15 import { DynamoDBDocumentClient, BatchGetCommand, DeleteCommand, GetCommand, PutCommand, QueryCommand, UpdateCommand } from '@aws-sdk/lib-dynamodb'; 7 16 import { SecretsManagerClient, GetSecretValueCommand } from '@aws-sdk/client-secrets-manager'; ··· 12 21 const TABLE = process.env.DYNAMODB_TABLE ?? 'blogs'; 13 22 const CELEBRITY_THRESHOLD = parseInt(process.env.CELEBRITY_THRESHOLD ?? '1000', 10); 14 23 const CELEBRITY_POST_LIMIT = 10; 24 + // Custom-domain support — empty when the multi-tenant CloudFront distribution 25 + // is not deployed (e.g. LocalStack); the /domains endpoints then return 501. 26 + const MULTI_TENANT_DIST_ID = process.env.MULTI_TENANT_DIST_ID ?? ''; 27 + const TENANT_CONNECTION_GROUP_ID = process.env.TENANT_CONNECTION_GROUP_ID ?? ''; 28 + const TENANT_ROUTING_ENDPOINT = process.env.TENANT_ROUTING_ENDPOINT ?? ''; 29 + const MAX_DOMAINS_PER_USER = 3; 30 + 31 + const cloudfront = new CloudFrontClient({}); 15 32 16 33 const idResolver = new IdResolver({}); 17 34 const ddb = DynamoDBDocumentClient.from( ··· 95 112 name: item.name as string, 96 113 description: item.description as string | undefined, 97 114 authorHandle: item.resolvedHandle as string | undefined, 115 + customDomain: item.customDomain as string | undefined, 98 116 preferences: { showInDiscover: (item.preferences as { showInDiscover?: boolean } | undefined)?.showInDiscover ?? false }, 99 117 })); 100 118 return json(200, publications); ··· 144 162 return json(201, { sk, atUri, url, name, description, preferences: { showInDiscover } }); 145 163 } 146 164 165 + // Only two url shapes may be indexed for a publication the caller owns: its 166 + // canonical path on this site, or a custom domain the caller has claimed via 167 + // POST /domains. Anything else could hijack another publication's url# item. 168 + async function validatePublicationUrl(url: string, did: string, rkey: string): Promise<boolean> { 169 + let parsed: URL; 170 + try { 171 + parsed = new URL(url); 172 + } catch { 173 + return false; 174 + } 175 + if (parsed.protocol !== 'https:' || parsed.search || parsed.hash) return false; 176 + if (SITE_DOMAIN && parsed.hostname === SITE_DOMAIN) { 177 + return parsed.pathname === `/${did}/pub/${rkey}`; 178 + } 179 + if (parsed.pathname !== '/') return false; 180 + const claim = await ddb.send(new GetCommand({ 181 + TableName: TABLE, 182 + Key: { PK: `domain#${parsed.hostname}`, SK: 'info' }, 183 + })); 184 + return claim.Item?.did === did && claim.Item?.pubRkey === rkey; 185 + } 186 + 187 + // Rewrites the denormalized resolvedUrl on every post of a publication after 188 + // its url changes, so feeds and cards link to the new canonical location. 189 + async function refreshPostUrls(did: string, pubAtUri: string, newUrl: string): Promise<void> { 190 + const base = newUrl.replace(/\/$/, ''); 191 + let lastKey: Record<string, unknown> | undefined; 192 + do { 193 + const page = await ddb.send(new QueryCommand({ 194 + TableName: TABLE, 195 + KeyConditionExpression: 'PK = :pk', 196 + FilterExpression: 'site = :site', 197 + ExpressionAttributeValues: { ':pk': `u#${did}#post`, ':site': pubAtUri }, 198 + ExclusiveStartKey: lastKey, 199 + })); 200 + await Promise.all((page.Items ?? []).map((item) => 201 + ddb.send(new UpdateCommand({ 202 + TableName: TABLE, 203 + Key: { PK: item.PK, SK: item.SK }, 204 + UpdateExpression: 'SET resolvedUrl = :url', 205 + ExpressionAttributeValues: { ':url': base }, 206 + })), 207 + )); 208 + lastKey = page.LastEvaluatedKey as Record<string, unknown> | undefined; 209 + } while (lastKey); 210 + } 211 + 147 212 async function updatePublication(event: APIGatewayProxyEventV2, did: string): Promise<APIGatewayProxyResultV2> { 148 213 const rkey = event.queryStringParameters?.rkey; 149 214 if (!rkey) return json(400, { error: 'rkey query parameter is required' }); 150 215 151 216 let name: string, description: string | undefined, showInDiscover: boolean; 217 + let url: string | undefined; 152 218 try { 153 219 const body = JSON.parse(event.body ?? '{}') as { 154 - name?: string; description?: string; preferences?: { showInDiscover?: boolean }; 220 + name?: string; description?: string; url?: string; preferences?: { showInDiscover?: boolean }; 155 221 }; 156 222 if (!body.name?.trim()) return json(400, { error: 'name is required' }); 157 223 name = body.name.trim(); 158 224 description = body.description?.trim() || undefined; 159 225 showInDiscover = body.preferences?.showInDiscover ?? false; 226 + url = body.url?.trim() || undefined; 160 227 } catch { 161 228 return json(400, { error: 'Invalid JSON body' }); 162 229 } 163 230 164 - const setExpr = description !== undefined 231 + let urlChange: { newUrl: string; pubAtUri: string } | undefined; 232 + if (url) { 233 + const normalizedUrl = decodeURIComponent(url.replace(/\/$/, '')); 234 + const existing = await ddb.send(new GetCommand({ 235 + TableName: TABLE, 236 + Key: { PK: `u#${did}#pub`, SK: `pub#${rkey}` }, 237 + })); 238 + if (!existing.Item) return json(404, { error: 'Publication not found' }); 239 + if (normalizedUrl !== (existing.Item.url as string)) { 240 + if (!(await validatePublicationUrl(normalizedUrl, did, rkey))) { 241 + return json(400, { error: 'url is not valid for this publication' }); 242 + } 243 + urlChange = { newUrl: normalizedUrl, pubAtUri: existing.Item.atUri as string }; 244 + } 245 + } 246 + 247 + let setExpr = description !== undefined 165 248 ? 'SET #name = :name, preferences = :preferences, description = :description' 166 249 : 'SET #name = :name, preferences = :preferences REMOVE description'; 167 250 const exprValues: Record<string, unknown> = { ··· 169 252 ':preferences': { showInDiscover }, 170 253 }; 171 254 if (description !== undefined) exprValues[':description'] = description; 255 + if (urlChange) { 256 + setExpr = setExpr.replace('SET ', 'SET #url = :url, '); 257 + exprValues[':url'] = urlChange.newUrl; 258 + } 172 259 173 260 await ddb.send(new UpdateCommand({ 174 261 TableName: TABLE, 175 262 Key: { PK: `u#${did}#pub`, SK: `pub#${rkey}` }, 176 263 UpdateExpression: setExpr, 177 - ExpressionAttributeNames: { '#name': 'name' }, 264 + ExpressionAttributeNames: urlChange ? { '#name': 'name', '#url': 'url' } : { '#name': 'name' }, 178 265 ExpressionAttributeValues: exprValues, 179 266 })); 180 - return json(200, { name, description, preferences: { showInDiscover } }); 267 + 268 + if (urlChange) { 269 + // The old url# item is intentionally kept as an alias so previously shared 270 + // links (and their feed/.well-known endpoints) keep resolving. 271 + await ddb.send(new PutCommand({ 272 + TableName: TABLE, 273 + Item: { PK: `url#${urlChange.newUrl}`, SK: 'info', entityType: 'url-lookup', pubAtUri: urlChange.pubAtUri }, 274 + })); 275 + await refreshPostUrls(did, urlChange.pubAtUri, urlChange.newUrl); 276 + } 277 + 278 + return json(200, { name, description, url: urlChange?.newUrl, preferences: { showInDiscover } }); 279 + } 280 + 281 + // Deletes the CloudFront distribution tenant for a custom domain and removes 282 + // the domain claim, counter increment, and url# alias from DynamoDB. Throws 283 + // when the tenant exists but CloudFront refuses to delete it. 284 + async function cleanupCustomDomain(did: string, host: string): Promise<void> { 285 + const domainKey = { PK: `domain#${host}`, SK: 'info' }; 286 + const claim = await ddb.send(new GetCommand({ TableName: TABLE, Key: domainKey })); 287 + const tenantId = claim.Item?.tenantId as string | undefined; 288 + 289 + if (tenantId) { 290 + try { 291 + const tenant = await cloudfront.send(new GetDistributionTenantCommand({ Identifier: tenantId })); 292 + let etag = tenant.ETag; 293 + if (tenant.DistributionTenant?.Enabled) { 294 + const updated = await cloudfront.send(new UpdateDistributionTenantCommand({ 295 + Id: tenantId, 296 + IfMatch: etag, 297 + Domains: tenant.DistributionTenant.Domains?.map((d) => ({ Domain: d.Domain })), 298 + Enabled: false, 299 + })); 300 + etag = updated.ETag ?? etag; 301 + } 302 + await cloudfront.send(new DeleteDistributionTenantCommand({ Id: tenantId, IfMatch: etag })); 303 + } catch (err) { 304 + if ((err as Error).name !== 'EntityNotFound') throw err; 305 + } 306 + } 307 + 308 + await Promise.all([ 309 + ddb.send(new DeleteCommand({ TableName: TABLE, Key: domainKey })), 310 + ddb.send(new DeleteCommand({ TableName: TABLE, Key: { PK: `url#https://${host}`, SK: 'info' } })), 311 + ddb.send(new UpdateCommand({ 312 + TableName: TABLE, 313 + Key: { PK: `u#${did}`, SK: 'domain-count' }, 314 + UpdateExpression: 'ADD #count :dec', 315 + ExpressionAttributeNames: { '#count': 'count' }, 316 + ExpressionAttributeValues: { ':dec': -1 }, 317 + })), 318 + ]); 181 319 } 182 320 183 321 async function deletePublication(event: APIGatewayProxyEventV2, did: string): Promise<APIGatewayProxyResultV2> { ··· 186 324 const key = { PK: `u#${did}#pub`, SK: `pub#${rkey}` }; 187 325 const existing = await ddb.send(new GetCommand({ TableName: TABLE, Key: key })); 188 326 const pubUrl = existing.Item?.url as string | undefined; 327 + const customDomain = existing.Item?.customDomain as string | undefined; 328 + if (customDomain) { 329 + try { 330 + await cleanupCustomDomain(did, customDomain); 331 + } catch (err) { 332 + console.error('Custom domain cleanup failed:', err); 333 + return json(502, { error: 'Failed to release the custom domain — try again' }); 334 + } 335 + } 189 336 await ddb.send(new DeleteCommand({ TableName: TABLE, Key: key })); 190 337 if (pubUrl) { 191 338 const normalizedUrl = decodeURIComponent(pubUrl.replace(/\/$/, '')); ··· 194 341 return json(200, {}); 195 342 } 196 343 197 - async function verifyPublication(rawPath: string): Promise<APIGatewayProxyResultV2> { 198 - if (!SITE_DOMAIN) { 344 + async function verifyPublication(rawPath: string, host: string): Promise<APIGatewayProxyResultV2> { 345 + if (!host) { 199 346 return { statusCode: 404, headers: { 'Content-Type': 'text/plain' }, body: 'Not found' }; 200 347 } 201 348 const wellKnownSuffix = '/.well-known/site.standard.publication'; ··· 204 351 const rawPubPath = rawPath.slice(0, rawPath.length - wellKnownSuffix.length); 205 352 // Decode percent-encoding so the lookup URL matches what was stored (e.g. 'did:plc:xxx' not 'did%3Aplc%3Axxx') 206 353 const pubPath = rawPubPath ? decodeURIComponent(rawPubPath) : ''; 207 - const url = `https://${SITE_DOMAIN}${pubPath}`; 354 + const url = `https://${host}${pubPath}`; 208 355 const result = await ddb.send(new GetCommand({ TableName: TABLE, Key: { PK: `url#${url}`, SK: 'info' } })); 209 356 if (!result.Item?.pubAtUri) { 210 357 return { statusCode: 404, headers: { 'Content-Type': 'text/plain' }, body: 'Not found' }; ··· 213 360 } 214 361 215 362 216 - async function getPublicationFeed(rawPath: string): Promise<APIGatewayProxyResultV2> { 217 - if (!SITE_DOMAIN) { 363 + async function getPublicationFeed(rawPath: string, host: string): Promise<APIGatewayProxyResultV2> { 364 + if (!host) { 218 365 return { statusCode: 404, headers: { 'Content-Type': 'text/plain' }, body: 'Not found' }; 219 366 } 220 367 221 368 const feedSuffix = '/feed.xml'; 222 369 const rawPubPath = rawPath.slice(0, rawPath.length - feedSuffix.length); 223 370 const pubPath = decodeURIComponent(rawPubPath); 224 - const pubUrl = `https://${SITE_DOMAIN}${pubPath}`; 371 + const pubUrl = `https://${host}${pubPath}`; 225 372 226 373 const urlLookup = await ddb.send(new GetCommand({ 227 374 TableName: TABLE, ··· 258 405 const pubSiteUrl = (pub.url as string).replace(/\/$/, ''); 259 406 const pubDescription = pub.description as string | undefined; 260 407 const posts = (postsResult.Items ?? []).slice(0, 20); 261 - const selfUrl = `https://${SITE_DOMAIN}${rawPath}`; 408 + const selfUrl = `https://${host}${rawPath}`; 262 409 const updatedAt = posts.length > 0 263 410 ? ((posts[0].updatedAt ?? posts[0].publishedAt) as string) 264 411 : new Date().toISOString(); ··· 304 451 headers: { 'Content-Type': 'application/atom+xml; charset=utf-8', 'Cache-Control': 'public, max-age=300' }, 305 452 body: feedLines.join('\n'), 306 453 }; 454 + } 455 + 456 + // Public SPA bootstrap for custom-domain mode: resolves the publication that 457 + // a custom domain is bound to. 458 + async function getPublicationByDomain(event: APIGatewayProxyEventV2): Promise<APIGatewayProxyResultV2> { 459 + const domain = event.queryStringParameters?.domain; 460 + if (!domain) return json(400, { error: 'domain query parameter is required' }); 461 + const host = normalizeCustomDomain(domain, SITE_DOMAIN, TENANT_ROUTING_ENDPOINT); 462 + if (!host) return json(400, { error: 'Invalid domain' }); 463 + 464 + const claim = await ddb.send(new GetCommand({ TableName: TABLE, Key: { PK: `domain#${host}`, SK: 'info' } })); 465 + let pubAtUri = claim.Item?.pubAtUri as string | undefined; 466 + if (!pubAtUri) { 467 + // Publications whose url already points at the domain but predate the 468 + // domain claim item (e.g. written by the indexer from a PDS record). 469 + const urlLookup = await ddb.send(new GetCommand({ TableName: TABLE, Key: { PK: `url#https://${host}`, SK: 'info' } })); 470 + pubAtUri = urlLookup.Item?.pubAtUri as string | undefined; 471 + } 472 + if (!pubAtUri) return json(404, { error: 'No publication for this domain' }); 473 + 474 + const atParts = pubAtUri.slice('at://'.length).split('/'); 475 + const pubDid = atParts[0]; 476 + const pubRkey = atParts[2]; 477 + const pubResult = await ddb.send(new GetCommand({ TableName: TABLE, Key: { PK: `u#${pubDid}#pub`, SK: `pub#${pubRkey}` } })); 478 + if (!pubResult.Item) return json(404, { error: 'No publication for this domain' }); 479 + 480 + const item = pubResult.Item; 481 + return json(200, { 482 + did: pubDid, 483 + rkey: pubRkey, 484 + atUri: pubAtUri, 485 + url: item.url as string, 486 + name: item.name as string, 487 + description: item.description as string | undefined, 488 + authorHandle: item.resolvedHandle as string | undefined, 489 + preferences: { showInDiscover: (item.preferences as { showInDiscover?: boolean } | undefined)?.showInDiscover ?? false }, 490 + }); 491 + } 492 + 493 + async function addCustomDomain(event: APIGatewayProxyEventV2, did: string): Promise<APIGatewayProxyResultV2> { 494 + if (!MULTI_TENANT_DIST_ID) return json(501, { error: 'Custom domains are not configured' }); 495 + 496 + let domainInput: string, pubRkey: string; 497 + try { 498 + const body = JSON.parse(event.body ?? '{}') as { domain?: string; pubRkey?: string }; 499 + if (!body.domain?.trim()) return json(400, { error: 'domain is required' }); 500 + if (!body.pubRkey?.trim()) return json(400, { error: 'pubRkey is required' }); 501 + domainInput = body.domain; 502 + pubRkey = body.pubRkey.trim(); 503 + } catch { 504 + return json(400, { error: 'Invalid JSON body' }); 505 + } 506 + 507 + const host = normalizeCustomDomain(domainInput, SITE_DOMAIN, TENANT_ROUTING_ENDPOINT); 508 + if (!host) return json(400, { error: 'Invalid domain' }); 509 + 510 + const pubResult = await ddb.send(new GetCommand({ TableName: TABLE, Key: { PK: `u#${did}#pub`, SK: `pub#${pubRkey}` } })); 511 + if (!pubResult.Item) return json(404, { error: 'Publication not found' }); 512 + if (pubResult.Item.customDomain) return json(409, { error: 'Publication already has a custom domain' }); 513 + const pubAtUri = pubResult.Item.atUri as string; 514 + 515 + const counter = await ddb.send(new GetCommand({ TableName: TABLE, Key: { PK: `u#${did}`, SK: 'domain-count' } })); 516 + if (((counter.Item?.count as number | undefined) ?? 0) >= MAX_DOMAINS_PER_USER) { 517 + return json(400, { error: `Limit of ${MAX_DOMAINS_PER_USER} custom domains reached` }); 518 + } 519 + 520 + const domainKey = { PK: `domain#${host}`, SK: 'info' }; 521 + try { 522 + await ddb.send(new PutCommand({ 523 + TableName: TABLE, 524 + Item: { 525 + ...domainKey, 526 + entityType: 'custom-domain', 527 + did, 528 + pubRkey, 529 + pubAtUri, 530 + status: 'pending', 531 + previousUrl: pubResult.Item.url as string, 532 + createdAt: new Date().toISOString(), 533 + }, 534 + ConditionExpression: 'attribute_not_exists(PK)', 535 + })); 536 + } catch (err) { 537 + if ((err as Error).name === 'ConditionalCheckFailedException') { 538 + return json(409, { error: 'Domain is already in use' }); 539 + } 540 + throw err; 541 + } 542 + 543 + let tenantId: string; 544 + try { 545 + const created = await cloudfront.send(new CreateDistributionTenantCommand({ 546 + DistributionId: MULTI_TENANT_DIST_ID, 547 + ConnectionGroupId: TENANT_CONNECTION_GROUP_ID || undefined, 548 + // Tenant names must be unique per account; the normalized hostname fits 549 + // the allowed pattern and is unique by the conditional put above. 550 + Name: host, 551 + Domains: [{ Domain: host }], 552 + Enabled: true, 553 + ManagedCertificateRequest: { ValidationTokenHost: 'cloudfront' }, 554 + Tags: { Items: [{ Key: 'project', Value: 'blog' }, { Key: 'did', Value: did }] }, 555 + })); 556 + tenantId = created.DistributionTenant?.Id ?? ''; 557 + if (!tenantId) throw new Error('CreateDistributionTenant returned no tenant ID'); 558 + } catch (err) { 559 + await ddb.send(new DeleteCommand({ TableName: TABLE, Key: domainKey })); 560 + if ((err as Error).name === 'CNAMEAlreadyExists') { 561 + return json(409, { error: 'Domain is already in use' }); 562 + } 563 + console.error('CreateDistributionTenant failed:', err); 564 + return json(502, { error: 'Failed to provision the domain' }); 565 + } 566 + 567 + await Promise.all([ 568 + ddb.send(new UpdateCommand({ 569 + TableName: TABLE, 570 + Key: domainKey, 571 + UpdateExpression: 'SET tenantId = :tenantId', 572 + ExpressionAttributeValues: { ':tenantId': tenantId }, 573 + })), 574 + ddb.send(new UpdateCommand({ 575 + TableName: TABLE, 576 + Key: { PK: `u#${did}#pub`, SK: `pub#${pubRkey}` }, 577 + UpdateExpression: 'SET customDomain = :domain', 578 + ExpressionAttributeValues: { ':domain': host }, 579 + })), 580 + ddb.send(new UpdateCommand({ 581 + TableName: TABLE, 582 + Key: { PK: `u#${did}`, SK: 'domain-count' }, 583 + UpdateExpression: 'ADD #count :inc', 584 + ExpressionAttributeNames: { '#count': 'count' }, 585 + ExpressionAttributeValues: { ':inc': 1 }, 586 + })), 587 + ]); 588 + 589 + return json(201, { 590 + domain: host, 591 + status: 'pending', 592 + cname: { name: host, value: TENANT_ROUTING_ENDPOINT }, 593 + }); 594 + } 595 + 596 + async function getCustomDomain(event: APIGatewayProxyEventV2, did: string): Promise<APIGatewayProxyResultV2> { 597 + if (!MULTI_TENANT_DIST_ID) return json(501, { error: 'Custom domains are not configured' }); 598 + const rkey = event.queryStringParameters?.rkey; 599 + if (!rkey) return json(400, { error: 'rkey query parameter is required' }); 600 + 601 + const pubResult = await ddb.send(new GetCommand({ TableName: TABLE, Key: { PK: `u#${did}#pub`, SK: `pub#${rkey}` } })); 602 + if (!pubResult.Item) return json(404, { error: 'Publication not found' }); 603 + const host = pubResult.Item.customDomain as string | undefined; 604 + if (!host) return json(404, { error: 'No custom domain configured' }); 605 + 606 + const claim = await ddb.send(new GetCommand({ TableName: TABLE, Key: { PK: `domain#${host}`, SK: 'info' } })); 607 + if (!claim.Item || claim.Item.did !== did) return json(404, { error: 'No custom domain configured' }); 608 + 609 + const tenantId = claim.Item.tenantId as string | undefined; 610 + let domainStatus: string | undefined; 611 + let certStatus: string | undefined; 612 + if (tenantId) { 613 + try { 614 + const [tenant, cert] = await Promise.all([ 615 + cloudfront.send(new GetDistributionTenantCommand({ Identifier: tenantId })), 616 + cloudfront.send(new GetManagedCertificateDetailsCommand({ Identifier: tenantId })).catch(() => null), 617 + ]); 618 + domainStatus = tenant.DistributionTenant?.Domains?.find((d) => d.Domain === host)?.Status; 619 + certStatus = cert?.ManagedCertificateDetails?.CertificateStatus; 620 + } catch (err) { 621 + console.warn('Tenant status lookup failed:', (err as Error).message); 622 + } 623 + } 624 + 625 + let status = claim.Item.status as string; 626 + if (status !== 'active' && domainStatus === 'active' && certStatus === 'issued') { 627 + status = 'active'; 628 + await ddb.send(new UpdateCommand({ 629 + TableName: TABLE, 630 + Key: { PK: `domain#${host}`, SK: 'info' }, 631 + UpdateExpression: 'SET #status = :status, activatedAt = :now', 632 + ExpressionAttributeNames: { '#status': 'status' }, 633 + ExpressionAttributeValues: { ':status': 'active', ':now': new Date().toISOString() }, 634 + })); 635 + } 636 + 637 + return json(200, { 638 + domain: host, 639 + status, 640 + domainStatus, 641 + certStatus, 642 + previousUrl: claim.Item.previousUrl as string | undefined, 643 + cname: { name: host, value: TENANT_ROUTING_ENDPOINT }, 644 + }); 645 + } 646 + 647 + async function removeCustomDomain(event: APIGatewayProxyEventV2, did: string): Promise<APIGatewayProxyResultV2> { 648 + if (!MULTI_TENANT_DIST_ID) return json(501, { error: 'Custom domains are not configured' }); 649 + const rkey = event.queryStringParameters?.rkey; 650 + if (!rkey) return json(400, { error: 'rkey query parameter is required' }); 651 + 652 + const pubKey = { PK: `u#${did}#pub`, SK: `pub#${rkey}` }; 653 + const pubResult = await ddb.send(new GetCommand({ TableName: TABLE, Key: pubKey })); 654 + if (!pubResult.Item) return json(404, { error: 'Publication not found' }); 655 + const host = pubResult.Item.customDomain as string | undefined; 656 + if (!host) return json(404, { error: 'No custom domain configured' }); 657 + 658 + const claim = await ddb.send(new GetCommand({ TableName: TABLE, Key: { PK: `domain#${host}`, SK: 'info' } })); 659 + if (claim.Item && claim.Item.did !== did) return json(403, { error: 'Domain belongs to another user' }); 660 + 661 + try { 662 + await cleanupCustomDomain(did, host); 663 + } catch (err) { 664 + console.error('Custom domain cleanup failed:', err); 665 + return json(502, { error: 'Failed to remove the domain — try again' }); 666 + } 667 + 668 + await ddb.send(new UpdateCommand({ 669 + TableName: TABLE, 670 + Key: pubKey, 671 + UpdateExpression: 'REMOVE customDomain', 672 + })); 673 + 674 + return json(200, { previousUrl: claim.Item?.previousUrl as string | undefined }); 307 675 } 308 676 309 677 async function resolvePublicationMeta(atUri: string): Promise<{ url: string; name: string }> { ··· 1060 1428 // Respond to CORS preflight before any auth checks. 1061 1429 if (method === 'OPTIONS') return json(204, {}); 1062 1430 1431 + // Normalize header casing: V1 preserves original casing, V2 lowercases. 1432 + const headers = Object.fromEntries( 1433 + Object.entries(event.headers ?? {}).map(([k, v]) => [k.toLowerCase(), v]), 1434 + ); 1435 + // Custom-domain requests arrive through the multi-tenant distribution, 1436 + // whose viewer-request function stores the original Host header in 1437 + // x-forwarded-host (CloudFront replaces Host with the API Gateway host). 1438 + const forwardedHost = (headers['x-forwarded-host'] ?? '').trim().toLowerCase(); 1439 + const host = forwardedHost || SITE_DOMAIN; 1440 + 1063 1441 // Public endpoints — no auth required. 1064 - if (method === 'GET' && path.endsWith('/.well-known/site.standard.publication')) return verifyPublication(path); 1065 - if (method === 'GET' && path.endsWith('/feed.xml')) return getPublicationFeed(path); 1442 + if (method === 'GET' && path.endsWith('/.well-known/site.standard.publication')) return verifyPublication(path, host); 1443 + if (method === 'GET' && path.endsWith('/feed.xml')) return getPublicationFeed(path, host); 1066 1444 if (method === 'GET' && path === '/feed') return getFeed(event); 1067 1445 if (method === 'GET' && path === '/iframely') return getIframelyEmbed(event); 1068 1446 if (method === 'GET' && path === '/posts/content') return getPublicPostContent(event); 1447 + if (method === 'GET' && path === '/publications/by-domain') return getPublicationByDomain(event); 1069 1448 if (method === 'GET' && path === '/publications' && event.queryStringParameters?.did) { 1070 1449 const publicDid = event.queryStringParameters.did; 1071 1450 if (!publicDid.startsWith('did:')) return json(400, { error: 'did must be a valid DID' }); ··· 1079 1458 } 1080 1459 1081 1460 // All other endpoints require a valid ATProto service JWT. 1082 - // Normalize to lowercase: V1 preserves original casing, V2 lowercases. 1083 - const headers = Object.fromEntries( 1084 - Object.entries(event.headers ?? {}).map(([k, v]) => [k.toLowerCase(), v]), 1085 - ); 1086 1461 const authHeader = headers['authorization'] ?? ''; 1087 1462 if (!authHeader.toLowerCase().startsWith('bearer ')) { 1088 1463 return json(401, { error: 'Missing or invalid Authorization header' }); ··· 1102 1477 if (method === 'POST' && path === '/publications') return createPublication(event, did); 1103 1478 if (method === 'PUT' && path === '/publications') return updatePublication(event, did); 1104 1479 if (method === 'DELETE' && path === '/publications') return deletePublication(event, did); 1480 + if (method === 'GET' && path === '/domains') return getCustomDomain(event, did); 1481 + if (method === 'POST' && path === '/domains') return addCustomDomain(event, did); 1482 + if (method === 'DELETE' && path === '/domains') return removeCustomDomain(event, did); 1105 1483 if (method === 'GET' && path === '/posts') return getUserBlogPosts(event, did); 1106 1484 if (method === 'POST' && path === '/posts') return createPost(event, did); 1107 1485 if (method === 'DELETE' && path === '/posts') return deletePost(event, did);
+9
infra/backfill/src/index.ts
··· 129 129 ): Promise<void> { 130 130 const atUri = `at://${did}/site.standard.publication/${rkey}` 131 131 const normalizedUrl = decodeURIComponent(record.url.replace(/\/$/, '')) 132 + 133 + const existing = await ddb.send(new GetCommand({ 134 + TableName: TABLE, 135 + Key: { PK: `u#${did}#pub`, SK: `pub#${rkey}` }, 136 + })) 137 + const customDomain = existing.Item?.customDomain as string | undefined 138 + 132 139 const item: Record<string, unknown> = { 133 140 PK: `u#${did}#pub`, 134 141 SK: `pub#${rkey}`, ··· 140 147 preferences: { showInDiscover: record.preferences?.showInDiscover ?? false }, 141 148 } 142 149 if (record.description?.trim()) item.description = record.description.trim() 150 + // The API manages this attribute; a full-item Put would silently drop it. 151 + if (customDomain) item.customDomain = customDomain 143 152 await Promise.all([ 144 153 ddb.send(new PutCommand({ TableName: TABLE, Item: item })), 145 154 ddb.send(new PutCommand({
+180
infra/cf/blog-api.yaml
··· 20 20 Type: String 21 21 Default: '' 22 22 Description: Apex domain of the site (e.g. lemma.pub) used for publication URL verification 23 + MultiTenantDistributionId: 24 + Type: String 25 + Default: '' 26 + Description: >- 27 + ID of the multi-tenant CloudFront distribution from the cloudfront stack 28 + (TenantDistributionId output). Leave empty to disable custom domains 29 + (the /domains endpoints return 501) — e.g. on LocalStack. 30 + TenantConnectionGroupId: 31 + Type: String 32 + Default: '' 33 + Description: Connection group ID from the cloudfront stack (TenantConnectionGroupId output) 34 + TenantRoutingEndpoint: 35 + Type: String 36 + Default: '' 37 + Description: >- 38 + Connection group routing endpoint from the cloudfront stack 39 + (TenantRoutingEndpoint output) — the CNAME target for custom domains 23 40 24 41 Conditions: 25 42 HasIframelySecret: !Not [!Equals [!Ref IframelySecretArn, '']] 43 + HasTenantDistribution: !Not [!Equals [!Ref MultiTenantDistributionId, '']] 26 44 27 45 Resources: 28 46 ··· 67 85 Action: secretsmanager:GetSecretValue 68 86 Resource: !Ref IframelySecretArn 69 87 - !Ref AWS::NoValue 88 + - !If 89 + - HasTenantDistribution 90 + - PolicyName: CustomDomainTenants 91 + PolicyDocument: 92 + Version: '2012-10-17' 93 + Statement: 94 + - Effect: Allow 95 + Action: 96 + - cloudfront:CreateDistributionTenant 97 + - cloudfront:GetDistributionTenant 98 + - cloudfront:GetDistributionTenantByDomain 99 + - cloudfront:UpdateDistributionTenant 100 + - cloudfront:DeleteDistributionTenant 101 + - cloudfront:GetManagedCertificateDetails 102 + - cloudfront:GetConnectionGroup 103 + - cloudfront:ListDistributionTenants 104 + Resource: 105 + - !Sub 'arn:aws:cloudfront::${AWS::AccountId}:distribution/${MultiTenantDistributionId}' 106 + - !Sub 'arn:aws:cloudfront::${AWS::AccountId}:distribution-tenant/*' 107 + - !Sub 'arn:aws:cloudfront::${AWS::AccountId}:connection-group/${TenantConnectionGroupId}' 108 + # CreateDistributionTenant with a ManagedCertificateRequest 109 + # requests the ACM certificate on the caller's behalf; ACM 110 + # actions do not support resource-level scoping at request time 111 + - Effect: Allow 112 + Action: 113 + - acm:RequestCertificate 114 + - acm:AddTagsToCertificate 115 + - acm:DescribeCertificate 116 + Resource: '*' 117 + - !Ref AWS::NoValue 70 118 Tags: 71 119 - Key: project 72 120 Value: blog ··· 92 140 DYNAMODB_TABLE: !Ref DynamoDBTable 93 141 SITE_DOMAIN: !Ref SiteDomain 94 142 IFRAMELY_SECRET_ARN: !If [HasIframelySecret, !Ref IframelySecretArn, !Ref AWS::NoValue] 143 + MULTI_TENANT_DIST_ID: !If [HasTenantDistribution, !Ref MultiTenantDistributionId, !Ref AWS::NoValue] 144 + TENANT_CONNECTION_GROUP_ID: !If [HasTenantDistribution, !Ref TenantConnectionGroupId, !Ref AWS::NoValue] 145 + TENANT_ROUTING_ENDPOINT: !If [HasTenantDistribution, !Ref TenantRoutingEndpoint, !Ref AWS::NoValue] 95 146 # Placeholder — deploy real code via deploy-local-api-lambda / deploy-aws-api-lambda 96 147 Code: 97 148 ZipFile: | ··· 141 192 RestApiId: !Ref BlogApiRestApi 142 193 ParentId: !GetAtt BlogApiRestApi.RootResourceId 143 194 PathPart: publications 195 + 196 + BlogApiPublicationsByDomainResource: 197 + Type: AWS::ApiGateway::Resource 198 + Properties: 199 + RestApiId: !Ref BlogApiRestApi 200 + ParentId: !Ref BlogApiPublicationsResource 201 + PathPart: by-domain 202 + 203 + BlogApiDomainsResource: 204 + Type: AWS::ApiGateway::Resource 205 + Properties: 206 + RestApiId: !Ref BlogApiRestApi 207 + ParentId: !GetAtt BlogApiRestApi.RootResourceId 208 + PathPart: domains 144 209 145 210 BlogApiSubscriptionsResource: 146 211 Type: AWS::ApiGateway::Resource ··· 461 526 method.response.header.Access-Control-Allow-Methods: false 462 527 method.response.header.Access-Control-Allow-Origin: false 463 528 529 + # /publications/by-domain 530 + 531 + BlogApiPublicationsByDomainGetMethod: 532 + Type: AWS::ApiGateway::Method 533 + Properties: 534 + RestApiId: !Ref BlogApiRestApi 535 + ResourceId: !Ref BlogApiPublicationsByDomainResource 536 + HttpMethod: GET 537 + AuthorizationType: NONE 538 + Integration: 539 + Type: AWS_PROXY 540 + IntegrationHttpMethod: POST 541 + Uri: !Sub 'arn:aws:apigateway:${AWS::Region}:lambda:path/2015-03-31/functions/${BlogApiFunction.Arn}/invocations' 542 + 543 + BlogApiPublicationsByDomainOptionsMethod: 544 + Type: AWS::ApiGateway::Method 545 + Properties: 546 + RestApiId: !Ref BlogApiRestApi 547 + ResourceId: !Ref BlogApiPublicationsByDomainResource 548 + HttpMethod: OPTIONS 549 + AuthorizationType: NONE 550 + Integration: 551 + Type: MOCK 552 + RequestTemplates: 553 + application/json: '{"statusCode": 200}' 554 + IntegrationResponses: 555 + - StatusCode: '200' 556 + ResponseParameters: 557 + method.response.header.Access-Control-Allow-Headers: "'Content-Type'" 558 + method.response.header.Access-Control-Allow-Methods: "'GET,OPTIONS'" 559 + # Public endpoint queried from arbitrary custom-domain origins 560 + method.response.header.Access-Control-Allow-Origin: "'*'" 561 + ResponseTemplates: 562 + application/json: '' 563 + MethodResponses: 564 + - StatusCode: '200' 565 + ResponseModels: 566 + application/json: Empty 567 + ResponseParameters: 568 + method.response.header.Access-Control-Allow-Headers: false 569 + method.response.header.Access-Control-Allow-Methods: false 570 + method.response.header.Access-Control-Allow-Origin: false 571 + 572 + # /domains 573 + 574 + BlogApiDomainsGetMethod: 575 + Type: AWS::ApiGateway::Method 576 + Properties: 577 + RestApiId: !Ref BlogApiRestApi 578 + ResourceId: !Ref BlogApiDomainsResource 579 + HttpMethod: GET 580 + AuthorizationType: NONE 581 + Integration: 582 + Type: AWS_PROXY 583 + IntegrationHttpMethod: POST 584 + Uri: !Sub 'arn:aws:apigateway:${AWS::Region}:lambda:path/2015-03-31/functions/${BlogApiFunction.Arn}/invocations' 585 + 586 + BlogApiDomainsPostMethod: 587 + Type: AWS::ApiGateway::Method 588 + Properties: 589 + RestApiId: !Ref BlogApiRestApi 590 + ResourceId: !Ref BlogApiDomainsResource 591 + HttpMethod: POST 592 + AuthorizationType: NONE 593 + Integration: 594 + Type: AWS_PROXY 595 + IntegrationHttpMethod: POST 596 + Uri: !Sub 'arn:aws:apigateway:${AWS::Region}:lambda:path/2015-03-31/functions/${BlogApiFunction.Arn}/invocations' 597 + 598 + BlogApiDomainsDeleteMethod: 599 + Type: AWS::ApiGateway::Method 600 + Properties: 601 + RestApiId: !Ref BlogApiRestApi 602 + ResourceId: !Ref BlogApiDomainsResource 603 + HttpMethod: DELETE 604 + AuthorizationType: NONE 605 + Integration: 606 + Type: AWS_PROXY 607 + IntegrationHttpMethod: POST 608 + Uri: !Sub 'arn:aws:apigateway:${AWS::Region}:lambda:path/2015-03-31/functions/${BlogApiFunction.Arn}/invocations' 609 + 610 + BlogApiDomainsOptionsMethod: 611 + Type: AWS::ApiGateway::Method 612 + Properties: 613 + RestApiId: !Ref BlogApiRestApi 614 + ResourceId: !Ref BlogApiDomainsResource 615 + HttpMethod: OPTIONS 616 + AuthorizationType: NONE 617 + Integration: 618 + Type: MOCK 619 + RequestTemplates: 620 + application/json: '{"statusCode": 200}' 621 + IntegrationResponses: 622 + - StatusCode: '200' 623 + ResponseParameters: 624 + method.response.header.Access-Control-Allow-Headers: "'Authorization,Content-Type'" 625 + method.response.header.Access-Control-Allow-Methods: "'GET,POST,DELETE,OPTIONS'" 626 + method.response.header.Access-Control-Allow-Origin: !Sub "'${AllowedOrigin}'" 627 + ResponseTemplates: 628 + application/json: '' 629 + MethodResponses: 630 + - StatusCode: '200' 631 + ResponseModels: 632 + application/json: Empty 633 + ResponseParameters: 634 + method.response.header.Access-Control-Allow-Headers: false 635 + method.response.header.Access-Control-Allow-Methods: false 636 + method.response.header.Access-Control-Allow-Origin: false 637 + 464 638 # /subscriptions 465 639 466 640 BlogApiSubscriptionsGetMethod: ··· 723 897 - BlogApiPublicationsPutMethod 724 898 - BlogApiPublicationsDeleteMethod 725 899 - BlogApiPublicationsOptionsMethod 900 + - BlogApiPublicationsByDomainGetMethod 901 + - BlogApiPublicationsByDomainOptionsMethod 902 + - BlogApiDomainsGetMethod 903 + - BlogApiDomainsPostMethod 904 + - BlogApiDomainsDeleteMethod 905 + - BlogApiDomainsOptionsMethod 726 906 - BlogApiSubscriptionsGetMethod 727 907 - BlogApiSubscriptionsPostMethod 728 908 - BlogApiSubscriptionsDeleteMethod
+128 -2
infra/cf/cloudfront.yaml
··· 70 70 SigningBehavior: always 71 71 SigningProtocol: sigv4 72 72 73 - # Grants CloudFront (scoped to this distribution) read access to the bucket. 73 + # Grants CloudFront (scoped to our distributions) read access to the bucket. 74 74 # Replaces the public Principal:* policy that was in s3.yaml. 75 75 SiteBucketPolicy: 76 76 Type: AWS::S3::BucketPolicy ··· 86 86 Resource: !Sub 'arn:aws:s3:::${SiteBucketName}/*' 87 87 Condition: 88 88 StringEquals: 89 - AWS:SourceArn: !Sub 'arn:aws:cloudfront::${AWS::AccountId}:distribution/${SiteDistribution}' 89 + AWS:SourceArn: 90 + - !Sub 'arn:aws:cloudfront::${AWS::AccountId}:distribution/${SiteDistribution}' 91 + - !Sub 'arn:aws:cloudfront::${AWS::AccountId}:distribution/${TenantDistribution}' 90 92 91 93 # ── SPA router ──────────────────────────────────────────────────────────── 92 94 # ··· 108 110 if (!uri.match(/\.[a-zA-Z0-9]+$/)) { 109 111 event.request.uri = '/index.html'; 110 112 } 113 + return event.request; 114 + } 115 + 116 + # Copies the viewer's Host header into x-forwarded-host so the API Lambda 117 + # can tell which custom domain a feed.xml / .well-known request arrived on. 118 + # (CloudFront must replace Host with the API Gateway host for routing, so 119 + # the original value has to travel in a different header.) Always overwrites 120 + # any client-supplied x-forwarded-host. 121 + TenantHostForwardFunction: 122 + Type: AWS::CloudFront::Function 123 + Properties: 124 + Name: !Sub '${AWS::StackName}-tenant-host-forward' 125 + AutoPublish: true 126 + FunctionConfig: 127 + Comment: Preserve the viewer Host header in x-forwarded-host for the API origin 128 + Runtime: cloudfront-js-2.0 129 + FunctionCode: | 130 + function handler(event) { 131 + event.request.headers['x-forwarded-host'] = { 132 + value: event.request.headers.host.value 133 + }; 111 134 return event.request; 112 135 } 113 136 ··· 198 221 - Key: project 199 222 Value: blog 200 223 224 + # ── Custom-domain (multi-tenant) distribution ───────────────────────────── 225 + # 226 + # Template distribution for users' custom blog domains. It has no routing 227 + # endpoint of its own: the API Lambda creates one distribution tenant per 228 + # custom domain at runtime (CreateDistributionTenant), each with a 229 + # CloudFront-managed, auto-renewed ACM certificate. Viewers reach tenants 230 + # through the connection group's routing endpoint, which is the CNAME 231 + # target users configure at their DNS provider. 232 + 233 + TenantConnectionGroup: 234 + Type: AWS::CloudFront::ConnectionGroup 235 + Properties: 236 + Name: !Sub '${AWS::StackName}-tenants' 237 + Enabled: true 238 + 239 + TenantDistribution: 240 + Type: AWS::CloudFront::Distribution 241 + Properties: 242 + DistributionConfig: 243 + ConnectionMode: tenant-only 244 + Enabled: true 245 + Comment: !Sub '${AWS::StackName} — custom blog domains' 246 + DefaultRootObject: index.html 247 + # No Aliases/ViewerCertificate: domains and certificates live on the 248 + # distribution tenants. PriceClass and TTL overrides are unsupported 249 + # in tenant-only mode. 250 + Origins: 251 + - Id: S3Origin 252 + DomainName: !Sub '${SiteBucketName}.s3.${AWS::Region}.amazonaws.com' 253 + S3OriginConfig: 254 + OriginAccessIdentity: '' # empty = not using legacy OAI 255 + OriginAccessControlId: !Ref SiteOAC 256 + - Id: ApiGatewayOrigin 257 + DomainName: !Sub '${ApiGatewayId}.execute-api.${AWS::Region}.amazonaws.com' 258 + OriginPath: !Sub '/${ApiStageName}' 259 + CustomOriginConfig: 260 + OriginProtocolPolicy: https-only 261 + OriginSSLProtocols: 262 + - TLSv1.2 263 + CacheBehaviors: 264 + - PathPattern: '*/feed.xml' 265 + TargetOriginId: ApiGatewayOrigin 266 + ViewerProtocolPolicy: redirect-to-https 267 + AllowedMethods: 268 + - GET 269 + - HEAD 270 + CachedMethods: 271 + - GET 272 + - HEAD 273 + # CachingDisabled — every request reaches the Lambda 274 + CachePolicyId: 4135ea2d-6df8-44a3-9df3-4b5a84be39ad 275 + # AllViewerExceptHostHeader — required so x-forwarded-host (set by 276 + # TenantHostForwardFunction) actually reaches API Gateway 277 + OriginRequestPolicyId: b689b0a8-53d0-40ab-baf2-68738e2966ac 278 + Compress: true 279 + FunctionAssociations: 280 + - EventType: viewer-request 281 + FunctionARN: !GetAtt TenantHostForwardFunction.FunctionARN 282 + - PathPattern: '*/.well-known/site.standard.publication' 283 + TargetOriginId: ApiGatewayOrigin 284 + ViewerProtocolPolicy: redirect-to-https 285 + AllowedMethods: 286 + - GET 287 + - HEAD 288 + CachedMethods: 289 + - GET 290 + - HEAD 291 + # CachingDisabled — every request reaches the Lambda 292 + CachePolicyId: 4135ea2d-6df8-44a3-9df3-4b5a84be39ad 293 + OriginRequestPolicyId: b689b0a8-53d0-40ab-baf2-68738e2966ac 294 + Compress: true 295 + FunctionAssociations: 296 + - EventType: viewer-request 297 + FunctionARN: !GetAtt TenantHostForwardFunction.FunctionARN 298 + DefaultCacheBehavior: 299 + TargetOriginId: S3Origin 300 + ViewerProtocolPolicy: redirect-to-https 301 + CachePolicyId: 658327ea-f89d-4fab-a63d-7e88639e58f6 # CachingOptimized 302 + Compress: true 303 + FunctionAssociations: 304 + - EventType: viewer-request 305 + FunctionARN: !GetAtt SpaRouterFunction.FunctionARN 306 + WebACLId: !If [HasWebACL, !Ref WebACLArn, !Ref AWS::NoValue] 307 + # S3 returns 403 (not 404) for missing objects when ListBucket is not 308 + # granted. Treat it as a SPA route and serve index.html with 200. 309 + CustomErrorResponses: 310 + - ErrorCode: 403 311 + ResponseCode: 200 312 + ResponsePagePath: /index.html 313 + ErrorCachingMinTTL: 0 314 + Tags: 315 + - Key: project 316 + Value: blog 317 + 201 318 # ── API distribution ────────────────────────────────────────────────────── 202 319 203 320 ApiDistribution: ··· 269 386 ApiDistributionDomain: 270 387 Description: Raw CloudFront domain for the API — used as ApiDistributionDomain input to the route53 stack 271 388 Value: !GetAtt ApiDistribution.DomainName 389 + TenantDistributionId: 390 + Description: Multi-tenant distribution ID — passed to the blog-api stack as MultiTenantDistributionId 391 + Value: !Ref TenantDistribution 392 + TenantConnectionGroupId: 393 + Description: Connection group ID for custom-domain tenants — passed to the blog-api stack 394 + Value: !GetAtt TenantConnectionGroup.Id 395 + TenantRoutingEndpoint: 396 + Description: CNAME target users point their custom domains at — passed to the blog-api stack 397 + Value: !GetAtt TenantConnectionGroup.RoutingEndpoint
+86
infra/indexer/src/index.ts
··· 148 148 const atUri = `at://${did}/site.standard.publication/${rkey}` 149 149 const normalizedUrl = decodeURIComponent(record.url.replace(/\/$/, '')) 150 150 const resolvedHandle = await resolveAuthorHandle(did) 151 + 152 + const existing = await ddb.send(new GetCommand({ 153 + TableName: TABLE, 154 + Key: { PK: `u#${did}#pub`, SK: `pub#${rkey}` }, 155 + })) 156 + const oldUrl = existing.Item?.url as string | undefined 157 + const customDomain = existing.Item?.customDomain as string | undefined 158 + 151 159 const item: Record<string, unknown> = { 152 160 PK: `u#${did}#pub`, 153 161 SK: `pub#${rkey}`, ··· 159 167 preferences: { showInDiscover: record.preferences?.showInDiscover ?? false }, 160 168 } 161 169 if (record.description?.trim()) item.description = record.description.trim() 170 + // The API manages this attribute; a full-item Put would silently drop it. 171 + if (customDomain) item.customDomain = customDomain 162 172 await Promise.all([ 163 173 ddb.send(new PutCommand({ TableName: TABLE, Item: item })), 164 174 ddb.send(new PutCommand({ ··· 166 176 Item: { PK: `url#${normalizedUrl}`, SK: 'info', entityType: 'url-lookup', pubAtUri: atUri }, 167 177 })), 168 178 ]) 179 + 180 + if (oldUrl && oldUrl !== normalizedUrl) { 181 + await handlePublicationUrlChange(did, atUri, oldUrl, normalizedUrl, customDomain) 182 + } 183 + } 184 + 185 + // When a publication's url changes (e.g. a custom domain became canonical), 186 + // drop the stale url# item — unless it's the pre-switch url recorded on the 187 + // domain claim, which is kept as an alias so old links keep resolving — and 188 + // refresh the denormalized resolvedUrl on the publication's posts. 189 + async function handlePublicationUrlChange( 190 + did: string, 191 + atUri: string, 192 + oldUrl: string, 193 + newUrl: string, 194 + customDomain: string | undefined, 195 + ) { 196 + let keepAlias = false 197 + if (customDomain) { 198 + const claim = await ddb.send(new GetCommand({ 199 + TableName: TABLE, 200 + Key: { PK: `domain#${customDomain}`, SK: 'info' }, 201 + })) 202 + keepAlias = claim.Item?.previousUrl === oldUrl 203 + } 204 + if (!keepAlias) { 205 + const stale = await ddb.send(new GetCommand({ 206 + TableName: TABLE, 207 + Key: { PK: `url#${oldUrl}`, SK: 'info' }, 208 + })) 209 + if (stale.Item?.pubAtUri === atUri) { 210 + await ddb.send(new DeleteCommand({ TableName: TABLE, Key: { PK: `url#${oldUrl}`, SK: 'info' } })) 211 + } 212 + } 213 + 214 + const base = newUrl.replace(/\/$/, '') 215 + let lastKey: Record<string, unknown> | undefined 216 + do { 217 + const page = await ddb.send(new QueryCommand({ 218 + TableName: TABLE, 219 + KeyConditionExpression: 'PK = :pk', 220 + FilterExpression: 'site = :site', 221 + ExpressionAttributeValues: { ':pk': `u#${did}#post`, ':site': atUri }, 222 + ExclusiveStartKey: lastKey, 223 + })) 224 + await Promise.all((page.Items ?? []).map((post) => 225 + ddb.send(new UpdateCommand({ 226 + TableName: TABLE, 227 + Key: { PK: post.PK, SK: post.SK }, 228 + UpdateExpression: 'SET resolvedUrl = :url', 229 + ExpressionAttributeValues: { ':url': base }, 230 + })), 231 + )) 232 + lastKey = page.LastEvaluatedKey as Record<string, unknown> | undefined 233 + } while (lastKey) 169 234 } 170 235 171 236 async function deletePublication(did: string, rkey: string) { 172 237 const key = { PK: `u#${did}#pub`, SK: `pub#${rkey}` } 173 238 const existing = await ddb.send(new GetCommand({ TableName: TABLE, Key: key })) 174 239 const pubUrl = existing.Item?.url as string | undefined 240 + const customDomain = existing.Item?.customDomain as string | undefined 175 241 const deletes: Promise<unknown>[] = [ 176 242 ddb.send(new DeleteCommand({ TableName: TABLE, Key: key })), 177 243 ] ··· 179 245 deletes.push(ddb.send(new DeleteCommand({ 180 246 TableName: TABLE, 181 247 Key: { PK: `url#${pubUrl.replace(/\/$/, '')}`, SK: 'info' }, 248 + }))) 249 + } 250 + if (customDomain) { 251 + // The CloudFront distribution tenant is NOT deleted here (the indexer has 252 + // no CloudFront access) — orphaned tenants are reaped by the ops sweep 253 + // documented in PROD.md. 254 + deletes.push(ddb.send(new DeleteCommand({ 255 + TableName: TABLE, 256 + Key: { PK: `domain#${customDomain}`, SK: 'info' }, 257 + }))) 258 + deletes.push(ddb.send(new DeleteCommand({ 259 + TableName: TABLE, 260 + Key: { PK: `url#https://${customDomain}`, SK: 'info' }, 261 + }))) 262 + deletes.push(ddb.send(new UpdateCommand({ 263 + TableName: TABLE, 264 + Key: { PK: `u#${did}`, SK: 'domain-count' }, 265 + UpdateExpression: 'ADD #count :dec', 266 + ExpressionAttributeNames: { '#count': 'count' }, 267 + ExpressionAttributeValues: { ':dec': -1 }, 182 268 }))) 183 269 } 184 270 await Promise.all(deletes)