Something went wrong. Try again.
Cooperative email for PDS operators
Something went wrong. Try again.
25 kB · 640 lines
TypeScript
at main
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641// Typed fetch wrapper for the Go `/api/v1/*` JSON endpoints.//// The wrapper is the single funnel every Astro client island uses to// reach the backend. Conventions Phase 2/3 must follow://// - Always `credentials: 'include'` — auth is the session cookie set// by the OAuth callback (see decisions doc, Decision 5).// - 401 redirects to `/` — the marketing landing now doubles as the// sign-in surface (the consolidation PR merged the old /signup// form into the landing). Previous targets were /account → loop,// then /signup → consolidated.// - The redirect is suppressed when the user is ALREADY on `/`, so// the landing's own funnel-resume probe doesn't trigger an// unauthenticated self-redirect.// - Caller passes a path like "/api/v1/account"; the wrapper does not// prefix a base URL — the SPA is same-origin with the Go binary.
export type ApiResult<T> = | { ok: true; data: T } | { ok: false; status: number; error: string };
export interface ApiFetchOptions extends RequestInit { /** If true (default), a 401 response redirects the browser to * the landing at `/` instead of returning an error. Set false for * endpoints where the caller wants to handle 401 itself (e.g. a * probe). The redirect is also skipped automatically when the * caller is already on `/` so the landing's own resume-probe * doesn't trigger a self-redirect. */ redirectOnUnauthorized?: boolean;}
export async function apiFetch<T>( path: string, opts: ApiFetchOptions = {},): Promise<ApiResult<T>> { const { redirectOnUnauthorized = true, headers, ...rest } = opts;
let response: Response; try { response = await fetch(path, { credentials: "include", headers: { Accept: "application/json", ...headers, }, ...rest, }); } catch (err) { return { ok: false, status: 0, error: err instanceof Error ? err.message : "network error", }; }
if (response.status === 401) { if ( redirectOnUnauthorized && typeof window !== "undefined" && // Same loop-guard as AuthGuard: never redirect / → /. window.location.pathname !== "/" ) { window.location.href = "/"; } return { ok: false, status: 401, error: "unauthorized" }; }
if (!response.ok) { let bodyText = ""; try { bodyText = await response.text(); } catch { // intentional empty } return { ok: false, status: response.status, error: bodyText || response.statusText || `HTTP ${response.status}`, }; }
try { const data = (await response.json()) as T; return { ok: true, data }; } catch (err) { return { ok: false, status: response.status, error: err instanceof Error ? err.message : "invalid JSON", }; }}
// Response types — mirror internal/admin/ui/apitypes.go. Phase 2/3 add// new shapes here as new endpoints are wired.
export interface MeResponse { did?: string; handle?: string; authenticated: boolean;}
export interface DomainSummary { domain: string; attestationPublished: boolean; createdAt: string; dkimSelector?: string;}
// In-flight enrollment row (internal ref). Surfaced on /account so a// user who abandoned the wizard before DNS verification can see the// domain and either Resume or Cancel — without this list, the domain// is hidden but still consumes a slot against MaxDomainsPerMember.export interface PendingDomainSummary { domain: string; token: string; createdAt: string; expiresAt: string;}
export interface AccountResponse { did: string; handle?: string; email?: string; emailVerified: boolean; status: string; hasOperatorLabel: boolean; // Mirrors the backend's account.HasRelayLabel (internal/admin/ui // apitypes.AccountResponse) — true once the per-account relay // label has been published. Required because the backend always // emits it; the /account label-status card reads it directly. hasRelayLabel: boolean; // Mirrors account.HasCleanSenderLabel. True only when the labeler // currently issues `clean-sender` (effective state). The account UI // shows this label ONLY when true — it's an earned reputation signal, // so there is no pending/missing render (internal ref). hasCleanSenderLabel: boolean; labelsKnown: boolean; domains: DomainSummary[]; // Billing/feature tier (internal ref). One of the relaystore // ValidMemberPlans constants ("self-host" | "personal" | "business" // | "agency"); empty / missing for sessions whose member row hasn't // landed yet. The /account "Current plan" card (internal ref) // renders the display name + support tier off this field and falls // back to the self-host copy when unknown. NOT yet wired here: // a `coopMember` boolean — internal ref will add it; until then // the plan card intentionally omits the co-op badge instead of // inventing the field client-side. plan?: string; // Per-tier monthly send ceiling (internal ref), resolved by the // backend so the SPA doesn't duplicate the tier → cap table. // Always populated; the backend collapses unknown plans to the // self-host floor (100). The monthly usage gauge (internal ref) // pairs this with `monthlyUsed` (optional — pre-#536 backends omit // it, and the SPA hides the gauge when missing or zero). monthlyCap: number; monthlyUsed?: number; // In-flight wizard rows the user hasn't verified yet (internal ref). // Optional + may be omitted when empty so existing consumers keep // working unchanged. pendingDomains?: PendingDomainSummary[]; // Account-deletion cooldown state (internal ref / Gitea #655). // Optional because the backend agent's endpoint may land after the // UI does — when absent, the banner and the scheduled landing simply // render "no deletion pending" / suppress themselves. Both timestamps // are RFC 3339 strings; the SPA formats them to YYYY-MM-DD locally // so we don't depend on backend-side date formatting. pendingDeletion?: boolean; deletionScheduledAt?: string; cooldownEndsAt?: string; // FeaturePaidTiers kill switch mirror (internal ref). When false // (default), commercial-tier surfaces stay hidden client-side and the // /pricing CTA is suppressed. paidTiersEnabled?: boolean; // State-machine gate from internal/admin/ui canAddDomain. Mirrors // CanAddDomainStatus in internal/admin/ui/apitypes.go: when // `allowed` is false, the dashboard disables the Add Domain button // and surfaces `reason` so the user knows why. canAddDomain?: CanAddDomainStatus; // Effective sending limits + graduation dates (internal ref). The // per-DID warming limit that actually binds the member, derived // server-side from member.CreatedAt + the warming config — the same // values submission enforces. The dashboard leads with these (and the // graduation date) instead of the monthly cap while paid tiers are // off. All optional: a pre-enrollment session (no member row) omits // them, and pre-#838 backends won't emit them either. currentTier?: "warming" | "ramping" | "warmed"; effectiveHourlyLimit?: number; effectiveDailyLimit?: number; // Current-window send counts (internal ref): numerators for the // hourly/daily usage bars, against effectiveHourlyLimit/effectiveDailyLimit. // Present whenever there's a member row; pre-#886 backends omit them. hourlyUsed?: number; dailyUsed?: number; // Graduation dates (YYYY-MM-DD). rampingAt: warming→ramping; // warmedAt: ramping→full configured limits. Omitted when no member row. rampingAt?: string; warmedAt?: string; // Authoritative "Day N of M" counter (internal ref), derived // server-side from the same UTC clock that drives tier enforcement. // The card renders these verbatim — never recompute the day in the // browser, which drifted to "Day 15 of 14" at the UTC/local boundary. // warmingDay is clamped to [1, warmingTotalDays]. Omitted by a // pre-#849 backend, in which case the card falls back to the local // computation. warmingDay?: number; warmingTotalDays?: number;}
// CanAddDomainStatus mirrors the Go-side CanAddDomainStatus.// `target` is a path the SPA can deep-link to (typically /account or// /signup) for the next step.export interface CanAddDomainStatus { allowed: boolean; reason: string; target: string;}
// Account-deletion status probe response. Mirrors// internal/admin/ui/apitypes.go AccountDeletionStatusResponse — used// by /account/settings/delete and /account/settings/delete/scheduled// when the backend gains a dedicated status endpoint. Until then the// fields are read straight off AccountResponse.export interface AccountDeletionStatusResponse { pending: boolean; requestedAt?: string; cooldownEndsAt?: string; reason?: string;}
export interface DomainsListResponse { domains: DomainSummary[]; hasOperatorLabel: boolean; labelsKnown: boolean;}
// Per-domain detail (GET /api/v1/account/domains/{domain}). Mirrors// internal/admin/ui/apitypes.go DomainDetailResponse.export interface DomainDetailResponse { domain: string; did: string; handle?: string; createdAt: string; dkimSelector?: string; dkimRsaName?: string; dkimRsaRecord?: string; dkimEdName?: string; dkimEdRecord?: string; spfValue: string; dmarcValue: string; relayPublicIP?: string; attestationPublished: boolean; attestationRkey?: string; attestationPublishedAt?: string; hasOperatorLabel: boolean; hasRelayLabel: boolean; labelsKnown: boolean; contactEmail?: string; // True when the user's PDS holds an at.marque.domain/<domain> // record. Drives the 1-click Marque DNS publish banner on the // per-domain detail page. marqueDetected: boolean; // True when the user's PDS already holds an at.marque.dns/<domain> // record — meaning a prior publish OAuth (legacy or bundle) wrote // the records and Marque is now syncing. Suppresses the // "Publish via Marque" banner so the user isn't re-prompted to // publish what's already live (internal ref). marqueDNSPublished?: boolean; // Per-section verification (internal ref). verificationChecked // gates the pill render — when false the live DNS lookup never // ran (verifier unwired, timeout, etc) so the SPA must suppress // the pill rather than render a "Verify" affordance backed by // nothing. When true, the three per-record booleans carry the // labeler-aligned verdict. verificationChecked?: boolean; dkimVerified?: boolean; spfVerified?: boolean; dmarcVerified?: boolean; // Per-record failure strings emitted by the labeler's verifier // (e.g. "DMARC: policy is none (must be quarantine or reject)", // "DKIM: no record for selector atmos20260516r", // "SPF: no v=spf1 record found"). Surfaced so a member whose // pill is "pending" can self-diagnose without operator help. // Absent when verification didn't run or every check passed. // The SPA filters out MX-prefixed entries — the labeler doesn't // gate on MX (sending-only domains legitimately have none). verificationFailures?: string[]; // Domain-control TXT (internal ref). True when domain control // is proven — via the canonical _atmos-control record, a matching // _atproto record, or handle authority. The Astro page reads this to // (a) gate the Force re-label button and (b) flip the Domain control // section's pill between pending and verified. domainControlVerified?: boolean; // DomainControlConflictDid (internal ref). Set when // _atproto.<domain> is held by a DID other than the member's — the domain // is also another atproto account's handle, so its _atproto record points // elsewhere. Purely informational now: control is proven through the // canonical _atmos-control record either way, so the page surfaces a short // "heads up" note when this is present. Empty in the normal case. domainControlConflictDid?: string; // AtmosControlHost / AtmosControlValue (internal ref). The // comail domain-control record: "_atmos-control.<domain>" and // "did=<member DID>". Decoupled from atproto handle resolution, an accepted // proof in all cases. ALWAYS present for an enrolled domain (harmless string // construction) so the page can render it whether it's the primary record or // the "also accepted" alternative. Use atmosControlCanonical to decide which. atmosControlHost?: string; atmosControlValue?: string; // AtmosControlCanonical (internal ref, narrowing #922). Tells the page // which control record to present as the primary "publish this" record. The // axis is whether the domain is already control-verified through its _atproto // record (so existing-working users see no churn). True only when the handle // does NOT match the domain AND the domain is NOT verified via _atproto: // - handle == domain → false (handle covers control) // - verified via _atproto → false (_atproto stays primary, shown ✓) // - verified via atmos-control / collision / new / unverified → true // When false, _atproto is the canonical control record and _atmos-control is // the optional "also accepted" alternative. NOT gated on marqueDetected (that // drives only the publish banner). Purely UI framing — the verifier accepts // handle authority, _atmos-control, AND _atproto regardless. atmosControlCanonical?: boolean; // CanForceRelabel (internal ref). True when the relay has a wired // LabelReverifier — the Astro page ANDs this with the all-DNS-green // state before surfacing the Force re-label affordance so clicks // never hit a 503. canForceRelabel?: boolean; // Reconciliation lifecycle (internal ref). Sourced from the labeler's // attestations table (NOT the live-DNS pills above) so the page can // render a human status line explaining WHY a domain isn't verified yet. // All absent when the labeler is unwired/unreachable — the page falls // back to the per-section DNS pills. verificationStatus?: "pending" | "verified" | "failed"; verificationFailureKind?: "transient" | "action_required" | ""; verificationReason?: string; verificationAttemptCount?: number; nextCheckAt?: string; // HandleMatchesDomain (internal ref). True when the session // handle equals the enrolled domain — handle authority proves // control, so the _atproto.<domain> TXT becomes optional. handleMatchesDomain?: boolean; canTestSend: boolean; canTestSendReason?: string; // Per-receiver send-test rows (internal ref). Empty / absent when // the test-send gate is closed or no tests have been issued yet — // the SPA hides the results panel in that case. sendTestResults?: DomainDetailTestResult[];}
// VerificationStatusLine is the rendered human status for the per-domain// verification lifecycle (internal ref): a tone the page maps to a colour// token plus the copy to show. `tone` is "ok" for verified, "warn" for a// transient/in-progress wait, "error" for action_required, and "none" when// there's nothing durable to show yet (labeler unwired/unreachable or the// domain has no attestation row) — in which case the caller suppresses the// line and falls back to the per-section DNS pills.export interface VerificationStatusLine { tone: "ok" | "warn" | "error" | "none"; text: string;}
// verificationStatusLine derives the human status line from the labeler's// reconciliation lifecycle fields on a DomainDetailResponse. Pure and// exported so it can be unit-tested and reused outside the Astro island.//// - verified → "Verified ✓"// - failed + action_required → "Action needed: <reason>"// - failed/pending (transient/etc) → "Verifying… (checked N×, waiting on DNS[ — <reason>])"// - absent verificationStatus → tone "none" (suppress the line)export function verificationStatusLine( d: Pick< DomainDetailResponse, | "verificationStatus" | "verificationFailureKind" | "verificationReason" | "verificationAttemptCount" >,): VerificationStatusLine { const status = d.verificationStatus; if (!status) { return { tone: "none", text: "" }; } if (status === "verified") { return { tone: "ok", text: "Verified ✓" }; } const reason = (d.verificationReason ?? "").trim(); if (d.verificationFailureKind === "action_required") { return { tone: "error", text: reason ? `Action needed: ${reason}` : "Action needed: check your DNS records", }; } // Transient failure or still-pending: an in-progress wait the reconciler // will heal on its own. Surface the attempt count and the specific reason // (when present) so the member can self-diagnose a slow DNS propagation. const attempts = d.verificationAttemptCount ?? 0; const checked = attempts > 0 ? `checked ${attempts}×, ` : ""; const tail = reason ? ` — ${reason}` : ""; return { tone: "warn", text: `Verifying… (${checked}waiting on DNS${tail})` };}
// One row in DomainDetailResponse.sendTestResults. Mirrors// internal/admin/ui/apitypes.go DomainDetailTestResult.export interface DomainDetailTestResult { receiver: string; sentAt: string; // "delivered" | "bounced" | "deferred" | "queued" — kept as a free-form // string so a new state added on the backend doesn't break the SPA. state: string;}
// Enrollment funnel resume probe (GET /api/v1/enroll/resume). The SPA// reads this on /signup load to skip steps the user already completed.export type EnrollResumeState = | "needs-contact-step" | "needs-domain-entry" | "needs-attestation-publish" | "complete";
export interface EnrollResumeResponse { state: EnrollResumeState; did?: string; handle?: string; domain?: string;}
// Publish-attestation kickoff (POST /api/v1/account/domains/{d}/publish-attestation).export interface PublishAttestationResponse { authorize_url: string;}
// VerifyState — JSON twin of the DNS-publish + verify wizard// (internal ref slice 5). Mirrors VerifyStateResponse in// internal/admin/ui/apitypes.go. Phase strings:// - "pending" — no member-domain row yet; user must publish the// `_atmos-enroll.<domain>` TXT then POST /verify.// - "verified" — member-domain row landed; DKIM/SPF/DMARC values// surface and the attestation-publish CTA appears.export type VerifyStatePhase = "pending" | "verified";
export interface VerifyStateRecord { name: string; type: string; expectedValue: string; required: boolean; verified: boolean;}
export interface VerifyStateRecords { atmosVerify?: VerifyStateRecord; // Canonical comail domain-control record (_atmos-control.<domain> TXT // did=<did>), the verifier's preferred proof — independent of atproto // handle resolution (internal ref). Primary control // record; atprotoControl below is the accepted-alternative fallback. atmosControl?: VerifyStateRecord; atprotoControl?: VerifyStateRecord; dkimRsa?: VerifyStateRecord; dkimEd?: VerifyStateRecord; spf?: VerifyStateRecord; dmarc?: VerifyStateRecord;}
export interface VerifyStateResponse { domain: string; did: string; handle?: string; phase: VerifyStatePhase; // Pending-phase only: enrollment token that drives the POST /verify // submit. Empty in the verified phase. token?: string; // RFC3339 expiry of the pending row. Empty when no row exists. expiresAt?: string; records: VerifyStateRecords; marqueDetected: boolean; handleMatchesDomain: boolean; verificationChecked: boolean; // Verified-phase only: base DKIM selector for the publish-attestation // form hidden input. dkimSelector?: string; // Verified-phase only: the next-step CTA URL the SPA POSTs to. attestationPublishURL?: string; attestationPublished: boolean; // Optional inline error — surfaced when the pending row can't be // located or the previous POST hit /admin/enroll and was rejected. error?: string;}
// Account email (GET + POST /api/v1/account/email). Mirrors// internal/admin/ui/apitypes.go EmailResponse / EmailChangeResponse.// Replaces the Templ AccountEmailForm render (internal ref Phase 3).export interface EmailResponse { did: string; handle?: string; contactEmail?: string; pendingEmail?: string; emailVerified: boolean;}
export interface EmailChangeResponse { pendingEmail: string; confirmationDispatched: boolean;}
// Account API keys (GET /api/v1/account/api-keys, POST /api/v1/account/// regenerate). Mirrors internal/admin/ui/apitypes.go APIKeysResponse /// RegenerateResponse. Replaces the Templ AccountAPIKeysList render// (internal ref Phase 4).export interface APIKeysRow { domain: string; createdAt: string; // RFC3339 UTC timestamp of the most recent successful API key auth // (SMTP submit, REST /v1/send, /member/status). Absent when the key // has never been used or when the relay version predates the field. lastUsedAt?: string; // RFC3339 UTC timestamp of the most recent key rotation. Absent when // the original enrollment-minted key is still in use, or when the // relay version predates the field. keyRegeneratedAt?: string;}
export interface APIKeysResponse { did: string; handle?: string; rows: APIKeysRow[];}
export interface RegenerateResponse { domain: string; apiKey: string;}
// Deliverability (GET /api/v1/account/domains/{domain}/deliverability).// Mirrors internal/admin/ui/apitypes.go DeliverabilityResponse. Same// data the legacy Templ DeliverabilityPage rendered — 14-day counts,// daily sparkline, warming-tier copy, account-status banner.export interface BounceCodeBucket { code: number; count: number; class: "permanent" | "transient" | "unknown";}
export interface BounceBreakdown { permanent: number; transient: number; unknown: number; codes: BounceCodeBucket[];}
export interface DeliverabilityResponse { domain: string; did: string; handle?: string; // "active" | "suspended" | "no_domain" | etc. The shell renders a // red banner when status==="suspended". status: string; suspendReason?: string; sent14d: number; bounced14d: number; complaints14d: number; bounceRate: number; // 0.0–1.0 dailySends: number[]; // 14 buckets, oldest-to-newest // Per-day bounce + complaint histograms, aligned 1:1 with dailySends // (same 14 oldest-to-newest buckets) so all three overlay on one chart. dailyBounces: number[]; dailyComplaints: number[]; // 14-day SMTP-code bounce rollup. permanent=5xx, transient=4xx, // unknown=0/other. codes is most-frequent-first; empty when no bounces. bounceBreakdown: BounceBreakdown; hourlyLimit: number; dailyLimit: number; // "warming" | "ramping" | "warmed" — empty when the field has the // pre-computed-on-server value. The shell hides the Warming section // when warmingTier === "warmed". warmingTier: string; // Human-readable form ("Warming (0–7 days)", "Ramping (7–14 days)"). // Empty for the warmed tier. warmingLabel?: string; // Graduation dates (YYYY-MM-DD) for the warming timeline (chainlink // #846). rampingAt: warming→ramping (CreatedAt+7d); warmedAt: // ramping→full limits (CreatedAt+14d). Present whenever a member row // exists; pre-#846 backends omit them. rampingAt?: string; warmedAt?: string;}
// Outbound webhooks (internal ref). Mirrors// internal/admin/ui/account_api_webhooks.go DTO shapes — keep the two// in lockstep when fields are added.export interface WebhookEndpoint { id: number; url: string; // CSV of event types or "*" for all. Server canonicalises on write. eventFilter: string; active: boolean; description?: string; createdAt: string; updatedAt: string; lastFiredAt?: string; lastStatus?: number; lastError?: string; deliveredCount: number; failedCount: number; // Populated only on create and rotate-secret responses, never on // list/get — the relay stores the raw secret and reveals it once. secret?: string;}
export interface WebhookEndpointsResponse { endpoints: WebhookEndpoint[]; // Closed enumeration of event types this relay version can emit. // The UI uses it to render the create-form's filter options without // hardcoding strings client-side. supportedEvents: string[];}
export interface WebhookDelivery { id: string; endpointId: number; eventType: string; // "pending" | "delivered" | "dead" status: string; attemptCount: number; nextAttemptAt?: string; lastStatus?: number; lastError?: string; createdAt: string; updatedAt: string; succeededAt?: string; deadAt?: string;}
export interface WebhookDeliveriesResponse { deliveries: WebhookDelivery[];}
export interface CreateWebhookEndpointRequest { url: string; eventFilter?: string; active?: boolean; description?: string;}