Cooperative email for PDS operators
8
fork

Configure Feed

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

Account verification UX, OAuth handle metrics, and design cleanup

+265 -88
+1
cmd/relay/main.go
··· 1105 1105 pub := &adminui.AtpoauthPublisher{C: oauthClient} 1106 1106 attestHandler := adminui.NewAttestHandler(pub, store) 1107 1107 attestHandler.SetFunnelRecorder(metrics) 1108 + attestHandler.SetDIDHandleResolver(didResolver) 1108 1109 attestHandler.RegisterRoutes(siteMux) 1109 1110 1110 1111 // Self-service credential recovery. Shares the attest OAuth
+29 -4
internal/admin/ui/attest.go
··· 45 45 IssueRecoveryTicket(did, domain string) string 46 46 } 47 47 48 + // DIDHandleResolver resolves a DID to its atproto handle. Used by the 49 + // callback handler to attach human-readable handles to OAuth metrics. 50 + type DIDHandleResolver interface { 51 + ResolveHandleFromDID(ctx context.Context, did string) (string, error) 52 + } 53 + 48 54 // AttestHandler implements the /enroll/attest/start and /enroll/attest/callback 49 55 // routes. It is a peer of EnrollHandler and shares its publicLayout templates. 50 56 type AttestHandler struct { ··· 63 69 // before filling out the enrollment form. May be nil to disable. 64 70 enrollAuthIssuer EnrollAuthIssuer 65 71 funnel FunnelRecorder 72 + didResolver DIDHandleResolver 66 73 } 67 74 68 75 // NewAttestHandler constructs the handler. pub and store must both be non-nil. ··· 89 96 h.funnel = fr 90 97 } 91 98 99 + // SetDIDHandleResolver wires DID→handle resolution for OAuth metrics. 100 + func (h *AttestHandler) SetDIDHandleResolver(r DIDHandleResolver) { 101 + h.didResolver = r 102 + } 103 + 104 + func (h *AttestHandler) resolveHandle(ctx context.Context, did string) string { 105 + if h.didResolver == nil || did == "" { 106 + return did 107 + } 108 + rctx, cancel := context.WithTimeout(ctx, 3*time.Second) 109 + defer cancel() 110 + handle, err := h.didResolver.ResolveHandleFromDID(rctx, did) 111 + if err != nil { 112 + return did 113 + } 114 + return handle 115 + } 116 + 92 117 // RegisterRoutes attaches handlers to the given mux. 93 118 func (h *AttestHandler) RegisterRoutes(mux *http.ServeMux) { 94 119 mux.HandleFunc("/enroll/attest/start", h.handleStart) ··· 181 206 if err != nil { 182 207 log.Printf("attest.callback: callback_error=%v", err) 183 208 if h.funnel != nil { 184 - h.funnel.RecordOAuthCallback("error") 209 + h.funnel.RecordOAuthCallback("error", "unknown") 185 210 } 186 211 msg := "OAuth callback failed — please start over." 187 212 if err == atpoauth.ErrPendingNotFound { ··· 204 229 return 205 230 } 206 231 if h.funnel != nil { 207 - h.funnel.RecordOAuthCallback("recovery") 232 + h.funnel.RecordOAuthCallback("recovery", h.resolveHandle(ctx, sess.AccountDID())) 208 233 } 209 234 target := h.recoveryIssuer.IssueRecoveryTicket(sess.AccountDID(), sess.Domain()) 210 235 log.Printf("attest.callback: did=%s domain=%s handoff=recovery target=%s", ··· 223 248 return 224 249 } 225 250 if h.funnel != nil { 226 - h.funnel.RecordOAuthCallback("enroll_auth") 251 + h.funnel.RecordOAuthCallback("enroll_auth", h.resolveHandle(ctx, sess.AccountDID())) 227 252 } 228 253 target := h.enrollAuthIssuer.IssueEnrollAuthTicket(sess.AccountDID(), sess.Domain(), r.UserAgent()) 229 254 if target == "" { ··· 262 287 } 263 288 264 289 if h.funnel != nil { 265 - h.funnel.RecordOAuthCallback("attestation") 290 + h.funnel.RecordOAuthCallback("attestation", h.resolveHandle(ctx, sess.AccountDID())) 266 291 } 267 292 log.Printf("attest.callback: did=%s domain=%s rkey=%s published=true", 268 293 sess.AccountDID(), sess.Domain(), rkey)
+1 -1
internal/admin/ui/enroll.go
··· 82 82 // FunnelRecorder records enrollment funnel step visits and OAuth callback outcomes. 83 83 type FunnelRecorder interface { 84 84 RecordEnrollStep(step string) 85 - RecordOAuthCallback(callbackType string) 85 + RecordOAuthCallback(callbackType, handle string) 86 86 } 87 87 88 88 type EnrollHandler struct {
+3
internal/admin/ui/handlers.go
··· 412 412 primaryDomain := "" 413 413 allDomains := make([]string, len(domains)) 414 414 allContactEmails := make([]string, len(domains)) 415 + allEmailVerified := make([]bool, len(domains)) 415 416 for i, d := range domains { 416 417 allDomains[i] = d.Domain 417 418 allContactEmails[i] = d.ContactEmail 419 + allEmailVerified[i] = d.EmailVerified 418 420 } 419 421 if len(allDomains) > 0 { 420 422 primaryDomain = allDomains[0] ··· 424 426 Domain: primaryDomain, 425 427 AllDomains: allDomains, 426 428 AllContactEmails: allContactEmails, 429 + AllEmailVerified: allEmailVerified, 427 430 Status: m.Status, 428 431 SuspendReason: m.SuspendReason, 429 432 SendCount: m.SendCount,
+51 -12
internal/admin/ui/recover.go
··· 225 225 mux.Handle("/account/select-domain", wrap(h.handleSelectDomain)) 226 226 mux.Handle("/account/regenerate", wrap(h.handleRegenerate)) 227 227 mux.Handle("/account/contact-email", wrap(h.handleContactEmail)) 228 + mux.Handle("/account/resend-verification", wrap(h.handleResendVerification)) 228 229 mux.Handle("/account/sign-out", wrap(h.handleSignOut)) 229 230 // Back-compat redirects for bookmarks pointing at the old /recover 230 231 // paths. 301 permanent so browsers cache the redirect. ··· 475 476 476 477 w.Header().Set("Content-Type", "text/html; charset=utf-8") 477 478 _ = templates.RecoverManage(templates.RecoverManageData{ 478 - DID: ticket.did, 479 - Domain: ticket.domain, 480 - DKIMSelector: memberDomain.DKIMSelector, 481 - ContactEmail: memberDomain.ContactEmail, 482 - ExpiresAt: ticket.expiry.Format(time.RFC3339), 479 + DID: ticket.did, 480 + Domain: ticket.domain, 481 + DKIMSelector: memberDomain.DKIMSelector, 482 + ContactEmail: memberDomain.ContactEmail, 483 + EmailVerified: memberDomain.EmailVerified, 484 + ExpiresAt: ticket.expiry.Format(time.RFC3339), 483 485 }).Render(r.Context(), w) 484 486 } 485 487 ··· 640 642 h.renderManageWithMessage(w, r, ticket, "Contact email updated.", false) 641 643 } 642 644 645 + func (h *RecoverHandler) handleResendVerification(w http.ResponseWriter, r *http.Request) { 646 + if r.Method != http.MethodPost { 647 + http.Error(w, "method not allowed", http.StatusMethodNotAllowed) 648 + return 649 + } 650 + id, ok := recoveryTicketFromCookie(r) 651 + if !ok { 652 + h.renderLandingErr(w, r, "Session expired or not found. Start over by signing in.") 653 + return 654 + } 655 + ticket, ok := h.lookupTicket(id, r.UserAgent()) 656 + if !ok { 657 + h.renderLandingErr(w, r, "Session expired or not found. Start over by signing in.") 658 + return 659 + } 660 + if ticket.domain == "" { 661 + http.Redirect(w, r, "/account/manage", http.StatusFound) 662 + return 663 + } 664 + memberDomain, err := h.store.GetMemberDomain(r.Context(), ticket.domain) 665 + if err != nil || memberDomain == nil { 666 + http.Error(w, "internal error", http.StatusInternalServerError) 667 + return 668 + } 669 + if memberDomain.ContactEmail == "" || memberDomain.EmailVerified { 670 + h.renderManageWithMessage(w, r, ticket, "No verification needed.", false) 671 + return 672 + } 673 + if h.onContactEmailChanged != nil { 674 + h.onContactEmailChanged(r.Context(), ticket.domain, memberDomain.ContactEmail) 675 + } 676 + log.Printf("account.resend_verification: did_hash=%s domain=%s", 677 + HashForLog(ticket.did), sanitizeForLog(ticket.domain)) 678 + h.renderManageWithMessage(w, r, ticket, "Verification email sent.", false) 679 + } 680 + 643 681 // renderManageWithMessage re-renders /account/manage and injects a 644 682 // top-of-page confirmation (or error) banner. Shares fetch logic with 645 683 // handleManage so the rendered page is otherwise identical. ··· 650 688 return 651 689 } 652 690 data := templates.RecoverManageData{ 653 - DID: ticket.did, 654 - Domain: ticket.domain, 655 - DKIMSelector: memberDomain.DKIMSelector, 656 - ContactEmail: memberDomain.ContactEmail, 657 - ExpiresAt: ticket.expiry.Format(time.RFC3339), 658 - Message: message, 659 - MessageErr: isError, 691 + DID: ticket.did, 692 + Domain: ticket.domain, 693 + DKIMSelector: memberDomain.DKIMSelector, 694 + ContactEmail: memberDomain.ContactEmail, 695 + EmailVerified: memberDomain.EmailVerified, 696 + ExpiresAt: ticket.expiry.Format(time.RFC3339), 697 + Message: message, 698 + MessageErr: isError, 660 699 } 661 700 w.Header().Set("Content-Type", "text/html; charset=utf-8") 662 701 _ = templates.RecoverManage(data).Render(r.Context(), w)
+5 -5
internal/admin/ui/templates/enroll.templ
··· 416 416 padding: 1.25rem 1.5rem; 417 417 background: var(--surface); 418 418 border: 1px solid var(--line); 419 - border-left: 3px solid var(--accent); 420 419 } 421 420 .credential-label { 422 421 font-size: var(--t-xs); ··· 479 478 footer a { color: var(--muted); text-decoration-color: var(--line); } 480 479 footer a:hover { color: var(--ink); text-decoration-color: var(--ink); } 481 480 482 - /* Error state — same visual grammar, accent underlines the issue. */ 483 481 .error-note { 484 482 margin: 2rem 0; 485 483 padding: 1rem 1.25rem; 486 - border-left: 3px solid var(--accent); 487 - background: var(--surface); 484 + background: oklch(0.95 0.03 25); 485 + border: 1px solid oklch(0.85 0.06 25); 488 486 color: var(--ink); 489 487 font-size: var(--t-s); 490 488 } ··· 721 719 identity.setAttribute('aria-expanded', 'false'); 722 720 currentResults = []; 723 721 activeIndex = -1; 724 - resolve(handle); 722 + resolve(handle).then(function() { 723 + if (didField.value) form.submit(); 724 + }); 725 725 } 726 726 727 727 function searchHandles(query) {
+2 -2
internal/admin/ui/templates/enroll_templ.go
··· 82 82 if templ_7745c5c3_Err != nil { 83 83 return templ_7745c5c3_Err 84 84 } 85 - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 3, "\"><link rel=\"preconnect\" href=\"https://fonts.googleapis.com\"><link rel=\"preconnect\" href=\"https://fonts.gstatic.com\" crossorigin><link href=\"https://fonts.googleapis.com/css2?family=Young+Serif&family=Atkinson+Hyperlegible:ital,wght@0,400;0,700;1,400;1,700&display=swap\" rel=\"stylesheet\"><style>\n\t\t\t\t:root {\n\t\t\t\t\t/* OKLCH color tokens — neutrals tinted warm (hue ~70) toward the paper base. */\n\t\t\t\t\t--bg: oklch(0.98 0.005 70);\n\t\t\t\t\t--ink: oklch(0.22 0.02 70);\n\t\t\t\t\t--muted: oklch(0.50 0.01 70);\n\t\t\t\t\t--line: oklch(0.85 0.01 70);\n\t\t\t\t\t--accent: oklch(0.55 0.22 25); /* stamp-red */\n\t\t\t\t\t--accent-ink: oklch(0.38 0.18 25); /* darker for hover/underline */\n\t\t\t\t\t--surface: oklch(1 0 0); /* pure white for credential boxes to contrast paper */\n\n\t\t\t\t\t--font-display: 'Young Serif', 'Iowan Old Style', 'Palatino Linotype', Palatino, serif;\n\t\t\t\t\t--font-body: 'Atkinson Hyperlegible', 'Charter', 'Georgia', serif;\n\n\t\t\t\t\t/* Type scale: 1.25 ratio, fixed rem (product UI, not marketing).\n\t\t\t\t\t Masthead is tuned to fit above the fold on a 720-line\n\t\t\t\t\t laptop without sacrificing the newspaper grammar. */\n\t\t\t\t\t--t-xs: 0.8125rem; /* 13px */\n\t\t\t\t\t--t-s: 0.9375rem; /* 15px */\n\t\t\t\t\t--t-m: 1.0625rem; /* 17px */\n\t\t\t\t\t--t-l: 1.1875rem; /* 19px */\n\t\t\t\t\t--t-xl: 1.375rem; /* 22px */\n\t\t\t\t\t--t-2xl: 2rem; /* 32px */\n\t\t\t\t\t--t-3xl: 3rem; /* 48px — masthead */\n\t\t\t\t}\n\t\t\t\t* { box-sizing: border-box; }\n\t\t\t\thtml, body {\n\t\t\t\t\tmargin: 0;\n\t\t\t\t\tpadding: 0;\n\t\t\t\t\tbackground: var(--bg);\n\t\t\t\t\tcolor: var(--ink);\n\t\t\t\t\tfont-family: var(--font-body);\n\t\t\t\t\tfont-size: var(--t-m);\n\t\t\t\t\tline-height: 1.45;\n\t\t\t\t\t-webkit-font-smoothing: antialiased;\n\t\t\t\t\t-moz-osx-font-smoothing: grayscale;\n\t\t\t\t}\n\t\t\t\t/* Body is a flex column so the footer can stick to the viewport\n\t\t\t\t bottom on short pages (see `footer { margin-top: auto; }` below).\n\t\t\t\t Without this, the footer floats mid-page when the content\n\t\t\t\t column is shorter than the viewport. */\n\t\t\t\thtml { min-height: 100%; }\n\t\t\t\tbody { min-height: 100vh; display: flex; flex-direction: column; }\n\t\t\t\t/* Body links are ink with an accent underline. Reserving stamp-red\n\t\t\t\t for the drop-cap, primary button, and credential callout keeps\n\t\t\t\t the accent color heroic — not stippled across every paragraph. */\n\t\t\t\ta {\n\t\t\t\t\tcolor: var(--ink);\n\t\t\t\t\ttext-decoration: underline;\n\t\t\t\t\ttext-decoration-color: var(--accent);\n\t\t\t\t\ttext-decoration-thickness: 1.5px;\n\t\t\t\t\ttext-underline-offset: 3px;\n\t\t\t\t}\n\t\t\t\ta:hover {\n\t\t\t\t\tcolor: var(--accent-ink);\n\t\t\t\t\ttext-decoration-color: var(--accent-ink);\n\t\t\t\t}\n\t\t\t\tcode {\n\t\t\t\t\tfont-family: 'JetBrains Mono', 'Menlo', 'Consolas', monospace;\n\t\t\t\t\tfont-size: 0.95em;\n\t\t\t\t\t/* Pure white fill against warm paper — same treatment\n\t\t\t\t\t as <pre> and credential boxes. Reads as \"data chit\n\t\t\t\t\t on stationery\" rather than a bordered same-color\n\t\t\t\t\t region that blurs into prose. */\n\t\t\t\t\tbackground: var(--surface);\n\t\t\t\t\tpadding: 0 0.25em;\n\t\t\t\t\tborder: 1px solid var(--line);\n\t\t\t\t\tborder-radius: 2px;\n\t\t\t\t}\n\t\t\t\tpre {\n\t\t\t\t\tfont-family: 'JetBrains Mono', 'Menlo', 'Consolas', monospace;\n\t\t\t\t\tfont-size: 0.9em;\n\t\t\t\t\tbackground: var(--surface);\n\t\t\t\t\tcolor: var(--ink);\n\t\t\t\t\tborder: 1px solid var(--line);\n\t\t\t\t\tpadding: 1rem 1.25rem;\n\t\t\t\t\tborder-radius: 2px;\n\t\t\t\t\toverflow-x: auto;\n\t\t\t\t\twhite-space: pre-wrap;\n\t\t\t\t\tword-break: break-all;\n\t\t\t\t\tline-height: 1.5;\n\t\t\t\t}\n\t\t\t\tpre code { background: none; border: none; padding: 0; }\n\n\t\t\t\t/* Top-of-page nav — subtle home link so every page can\n\t\t\t\t return to the marketing landing with one click. Lives\n\t\t\t\t above the masthead; typography-sized so it doesn't\n\t\t\t\t visually compete with the section mastheads below. */\n\t\t\t\t.topnav {\n\t\t\t\t\tfont-family: var(--font-body);\n\t\t\t\t\tfont-size: var(--t-xs);\n\t\t\t\t\ttext-transform: uppercase;\n\t\t\t\t\tletter-spacing: 0.12em;\n\t\t\t\t\tcolor: var(--muted);\n\t\t\t\t\tmargin-bottom: 1.5rem;\n\t\t\t\t\tpadding-bottom: 0.5rem;\n\t\t\t\t\tborder-bottom: 1px solid var(--line);\n\t\t\t\t}\n\t\t\t\t.topnav-home {\n\t\t\t\t\tcolor: var(--muted);\n\t\t\t\t\ttext-decoration: none;\n\t\t\t\t\tfont-weight: 700;\n\t\t\t\t}\n\t\t\t\t.topnav-home:hover {\n\t\t\t\t\tcolor: var(--accent);\n\t\t\t\t}\n\n\t\t\t\t.page {\n\t\t\t\t\tmax-width: 680px;\n\t\t\t\t\twidth: 100%;\n\t\t\t\t\tmargin: 0 auto;\n\t\t\t\t\tpadding: 2rem 2rem 2.5rem;\n\t\t\t\t\t/* Flex column so the footer can grow to fill vertical space\n\t\t\t\t\t via `margin-top: auto`, pinning it to the viewport bottom\n\t\t\t\t\t on short pages. */\n\t\t\t\t\tflex: 1 0 auto;\n\t\t\t\t\tdisplay: flex;\n\t\t\t\t\tflex-direction: column;\n\t\t\t\t}\n\n\t\t\t\t/* Masthead — deliberately large, newspaper-style. The leading 'A'\n\t\t\t\t of \"Atmosphere\" gets the accent color; the rest is ink. */\n\t\t\t\t.masthead {\n\t\t\t\t\tfont-family: var(--font-display);\n\t\t\t\t\tfont-size: var(--t-3xl);\n\t\t\t\t\tline-height: 1;\n\t\t\t\t\tletter-spacing: -0.03em;\n\t\t\t\t\tmargin: 0 0 0.5rem;\n\t\t\t\t\tcolor: var(--ink);\n\t\t\t\t}\n\t\t\t\t.masthead .drop {\n\t\t\t\t\tcolor: var(--accent);\n\t\t\t\t}\n\t\t\t\t/* Sub-page masthead — smaller, no drop-cap. The drop-cap is reserved\n\t\t\t\t for the landing's brand mark; pages like /terms /privacy /aup /about\n\t\t\t\t are reference docs that cede visual authority to the landing. */\n\t\t\t\t.masthead-sub {\n\t\t\t\t\tfont-size: var(--t-2xl);\n\t\t\t\t\tmargin-bottom: 0.25rem;\n\t\t\t\t}\n\t\t\t\t/* Effective-date marginalia — the typographic convention for legal\n\t\t\t\t documents. Sits directly under the title in small-caps, before\n\t\t\t\t the lede. Replaces the awkward \"Effective X. Lorem ipsum...\"\n\t\t\t\t sentence-opener pattern used in the first draft. */\n\t\t\t\t.effective {\n\t\t\t\t\tfont-family: var(--font-body);\n\t\t\t\t\tfont-size: var(--t-xs);\n\t\t\t\t\tfont-weight: 700;\n\t\t\t\t\ttext-transform: uppercase;\n\t\t\t\t\tletter-spacing: 0.12em;\n\t\t\t\t\tcolor: var(--muted);\n\t\t\t\t\tmargin: 0 0 1.25rem;\n\t\t\t\t}\n\t\t\t\t.lede {\n\t\t\t\t\tfont-family: var(--font-body);\n\t\t\t\t\tfont-style: italic;\n\t\t\t\t\tfont-size: var(--t-l);\n\t\t\t\t\tcolor: var(--muted);\n\t\t\t\t\tline-height: 1.4;\n\t\t\t\t\tmargin: 0 0 1rem;\n\t\t\t\t\tmax-width: 32em;\n\t\t\t\t}\n\n\t\t\t\t/* Section heading — Young Serif, smaller than masthead, with a\n\t\t\t\t hairline rule above. Evokes a typeset page break. */\n\t\t\t\t.section {\n\t\t\t\t\tmargin-top: 1.75rem;\n\t\t\t\t\tpadding-top: 1rem;\n\t\t\t\t\tborder-top: 1px solid var(--line);\n\t\t\t\t}\n\t\t\t\t.section h2 {\n\t\t\t\t\tfont-family: var(--font-display);\n\t\t\t\t\tfont-size: var(--t-xl);\n\t\t\t\t\tfont-weight: 400;\n\t\t\t\t\tletter-spacing: -0.01em;\n\t\t\t\t\tmargin: 0 0 0.35rem;\n\t\t\t\t}\n\t\t\t\t.section p {\n\t\t\t\t\tmargin: 0.35rem 0 0.6rem;\n\t\t\t\t}\n\t\t\t\t.section-lede {\n\t\t\t\t\tfont-size: var(--t-s);\n\t\t\t\t\tcolor: var(--muted);\n\t\t\t\t\tmargin: 0 0 1rem;\n\t\t\t\t\tmax-width: 36em;\n\t\t\t\t}\n\n\t\t\t\t/* Step number — small-caps marginalia, NOT a boxed card number. */\n\t\t\t\t.step-marker {\n\t\t\t\t\tdisplay: block;\n\t\t\t\t\tfont-family: var(--font-body);\n\t\t\t\t\tfont-size: var(--t-xs);\n\t\t\t\t\tfont-weight: 700;\n\t\t\t\t\ttext-transform: uppercase;\n\t\t\t\t\tletter-spacing: 0.12em;\n\t\t\t\t\tcolor: var(--muted);\n\t\t\t\t\tmargin-bottom: 0.25rem;\n\t\t\t\t}\n\n\t\t\t\t/* Form — boxed fields on white surface. Earlier iterations used\n\t\t\t\t hairline-underline inputs (no box, just a bottom border), but\n\t\t\t\t those collided visually with the section dividers and readers\n\t\t\t\t couldn't tell what was input vs. structure. A subtle surface\n\t\t\t\t fill + 1px frame makes \"this is a typeable field\" unambiguous\n\t\t\t\t without dragging the page toward a generic webform aesthetic. */\n\t\t\t\tlabel {\n\t\t\t\t\tdisplay: block;\n\t\t\t\t\tfont-size: var(--t-s);\n\t\t\t\t\tfont-weight: 700;\n\t\t\t\t\tmargin-top: 1rem;\n\t\t\t\t\tmargin-bottom: 0.25rem;\n\t\t\t\t}\n\t\t\t\tlabel + small {\n\t\t\t\t\tdisplay: block;\n\t\t\t\t\tcolor: var(--muted);\n\t\t\t\t\tfont-size: var(--t-xs);\n\t\t\t\t\tmargin-bottom: 0.5rem;\n\t\t\t\t}\n\t\t\t\tinput[type=text],\n\t\t\t\tinput[type=email],\n\t\t\t\ttextarea {\n\t\t\t\t\tdisplay: block;\n\t\t\t\t\twidth: 100%;\n\t\t\t\t\tfont-family: inherit;\n\t\t\t\t\tfont-size: var(--t-m);\n\t\t\t\t\tcolor: var(--ink);\n\t\t\t\t\tbackground: var(--surface);\n\t\t\t\t\tborder: 1px solid var(--line);\n\t\t\t\t\tborder-radius: 2px;\n\t\t\t\t\tpadding: 0.6rem 0.75rem;\n\t\t\t\t\tmargin-bottom: 0.5rem;\n\t\t\t\t\toutline: none;\n\t\t\t\t\ttransition: border-color 120ms ease, box-shadow 120ms ease;\n\t\t\t\t}\n\t\t\t\tinput[type=text]:focus,\n\t\t\t\tinput[type=email]:focus,\n\t\t\t\ttextarea:focus {\n\t\t\t\t\tborder-color: var(--ink);\n\t\t\t\t\tbox-shadow: inset 0 -2px 0 0 var(--accent);\n\t\t\t\t}\n\t\t\t\t.handle-input-wrapper {\n\t\t\t\t\tposition: relative;\n\t\t\t\t}\n\t\t\t\t.handle-input-wrapper input[type=text] {\n\t\t\t\t\tpadding-left: 1.75rem;\n\t\t\t\t}\n\t\t\t\t.handle-input-wrapper::before {\n\t\t\t\t\tcontent: \"@\";\n\t\t\t\t\tposition: absolute;\n\t\t\t\t\tleft: 0.75rem;\n\t\t\t\t\ttop: 0.6rem;\n\t\t\t\t\tfont-family: 'JetBrains Mono', 'Menlo', monospace;\n\t\t\t\t\tfont-size: var(--t-m);\n\t\t\t\t\tcolor: var(--muted);\n\t\t\t\t\tpointer-events: none;\n\t\t\t\t\tz-index: 1;\n\t\t\t\t}\n\t\t\t\t.handle-suggestions {\n\t\t\t\t\tposition: absolute;\n\t\t\t\t\tleft: 0;\n\t\t\t\t\tright: 0;\n\t\t\t\t\tbottom: 100%;\n\t\t\t\t\tbackground: var(--ink);\n\t\t\t\t\tcolor: var(--bg);\n\t\t\t\t\tborder-radius: 2px 2px 0 0;\n\t\t\t\t\tz-index: 10;\n\t\t\t\t\tmax-height: 260px;\n\t\t\t\t\toverflow-y: auto;\n\t\t\t\t\tbox-shadow: 0 -4px 16px rgba(0,0,0,0.15);\n\t\t\t\t}\n\t\t\t\t.handle-suggestion {\n\t\t\t\t\tdisplay: flex;\n\t\t\t\t\talign-items: center;\n\t\t\t\t\tgap: 0.6rem;\n\t\t\t\t\tpadding: 0.5rem 0.75rem;\n\t\t\t\t\tcursor: pointer;\n\t\t\t\t\ttransition: background 0.1s;\n\t\t\t\t}\n\t\t\t\t.handle-suggestion:hover,\n\t\t\t\t.handle-suggestion.active {\n\t\t\t\t\tbackground: oklch(0.30 0.02 70);\n\t\t\t\t}\n\t\t\t\t.suggestion-avatar {\n\t\t\t\t\twidth: 32px;\n\t\t\t\t\theight: 32px;\n\t\t\t\t\tborder-radius: 50%;\n\t\t\t\t\tflex-shrink: 0;\n\t\t\t\t\tobject-fit: cover;\n\t\t\t\t}\n\t\t\t\t.suggestion-avatar-placeholder {\n\t\t\t\t\twidth: 32px;\n\t\t\t\t\theight: 32px;\n\t\t\t\t\tborder-radius: 50%;\n\t\t\t\t\tflex-shrink: 0;\n\t\t\t\t\tbackground: oklch(0.40 0.01 70);\n\t\t\t\t}\n\t\t\t\t.suggestion-text {\n\t\t\t\t\tdisplay: flex;\n\t\t\t\t\tflex-direction: column;\n\t\t\t\t\tmin-width: 0;\n\t\t\t\t}\n\t\t\t\t.suggestion-name {\n\t\t\t\t\tfont-weight: 700;\n\t\t\t\t\tfont-size: var(--t-s);\n\t\t\t\t\toverflow: hidden;\n\t\t\t\t\ttext-overflow: ellipsis;\n\t\t\t\t\twhite-space: nowrap;\n\t\t\t\t}\n\t\t\t\t.suggestion-handle {\n\t\t\t\t\tfont-size: var(--t-xs);\n\t\t\t\t\tcolor: oklch(0.65 0.01 70);\n\t\t\t\t\toverflow: hidden;\n\t\t\t\t\ttext-overflow: ellipsis;\n\t\t\t\t\twhite-space: nowrap;\n\t\t\t\t}\n\t\t\t\ttextarea {\n\t\t\t\t\tresize: vertical;\n\t\t\t\t\tline-height: 1.4;\n\t\t\t\t\tfont-family: 'JetBrains Mono', 'Menlo', monospace;\n\t\t\t\t\tfont-size: var(--t-s);\n\t\t\t\t}\n\t\t\t\t/* Buttons and button-styled links share one base. .btn-secondary\n\t\t\t\t is the ghost variant — muted, reserved for withdrawal\n\t\t\t\t actions (sign out) and secondary CTAs (sign in next to\n\t\t\t\t \"Enroll a domain\"). Keeping them as class variants on a\n\t\t\t\t single base means the aesthetic stays consistent when we\n\t\t\t\t change padding or weight in one place. */\n\t\t\t\tbutton,\n\t\t\t\t.btn {\n\t\t\t\t\tdisplay: inline-block;\n\t\t\t\t\tfont-family: inherit;\n\t\t\t\t\tfont-size: var(--t-s);\n\t\t\t\t\tfont-weight: 700;\n\t\t\t\t\tletter-spacing: 0.02em;\n\t\t\t\t\tcolor: var(--bg);\n\t\t\t\t\tbackground: var(--ink);\n\t\t\t\t\tborder: 1px solid var(--ink);\n\t\t\t\t\tpadding: 0.65rem 1.5rem;\n\t\t\t\t\tmargin-top: 1.25rem;\n\t\t\t\t\tcursor: pointer;\n\t\t\t\t\ttext-decoration: none;\n\t\t\t\t\ttransition: background 120ms ease, color 120ms ease, border-color 120ms ease;\n\t\t\t\t}\n\t\t\t\t.btn-secondary,\n\t\t\t\tbutton.btn-secondary {\n\t\t\t\t\tcolor: var(--ink);\n\t\t\t\t\tbackground: transparent;\n\t\t\t\t\tborder-color: var(--line);\n\t\t\t\t}\n\t\t\t\tbutton:hover,\n\t\t\t\t.btn:hover {\n\t\t\t\t\tbackground: var(--accent);\n\t\t\t\t\tborder-color: var(--accent);\n\t\t\t\t\tcolor: var(--bg);\n\t\t\t\t}\n\t\t\t\t.btn-secondary:hover,\n\t\t\t\tbutton.btn-secondary:hover {\n\t\t\t\t\tbackground: var(--ink);\n\t\t\t\t\tborder-color: var(--ink);\n\t\t\t\t\tcolor: var(--bg);\n\t\t\t\t}\n\n\t\t\t\t/* Credential box — inverse of the page (surface white on paper).\n\t\t\t\t This is the ONE boxed element on the success page; everything\n\t\t\t\t else is just typography. Makes the API key impossible to miss. */\n\t\t\t\t.credential {\n\t\t\t\t\tmargin: 1.5rem 0;\n\t\t\t\t\tpadding: 1.25rem 1.5rem;\n\t\t\t\t\tbackground: var(--surface);\n\t\t\t\t\tborder: 1px solid var(--line);\n\t\t\t\t\tborder-left: 3px solid var(--accent);\n\t\t\t\t}\n\t\t\t\t.credential-label {\n\t\t\t\t\tfont-size: var(--t-xs);\n\t\t\t\t\tfont-weight: 700;\n\t\t\t\t\ttext-transform: uppercase;\n\t\t\t\t\tletter-spacing: 0.12em;\n\t\t\t\t\tcolor: var(--accent);\n\t\t\t\t\tmargin-bottom: 0.5rem;\n\t\t\t\t}\n\t\t\t\t.credential-note {\n\t\t\t\t\tfont-size: var(--t-xs);\n\t\t\t\t\tfont-style: italic;\n\t\t\t\t\tcolor: var(--muted);\n\t\t\t\t\tmargin-top: 0.75rem;\n\t\t\t\t}\n\n\t\t\t\t.dns-block {\n\t\t\t\t\tmargin: 1.25rem 0;\n\t\t\t\t}\n\t\t\t\t.dns-block-label {\n\t\t\t\t\tfont-family: 'JetBrains Mono', monospace;\n\t\t\t\t\tfont-size: var(--t-xs);\n\t\t\t\t\tcolor: var(--muted);\n\t\t\t\t\tmargin-bottom: 0.25rem;\n\t\t\t\t}\n\n\t\t\t\t.bullets {\n\t\t\t\t\tmargin: 0.5rem 0;\n\t\t\t\t\tpadding-left: 1.25rem;\n\t\t\t\t}\n\t\t\t\t.bullets li {\n\t\t\t\t\tmargin-bottom: 0.25rem;\n\t\t\t\t}\n\n\t\t\t\t/* Footer — light, one line, hairline rule. `margin-top: auto`\n\t\t\t\t on a flex child in a column container pushes the footer to\n\t\t\t\t the bottom of whatever space is left, so it sticks to the\n\t\t\t\t viewport bottom on short pages. The 2.25rem minimum keeps\n\t\t\t\t a comfortable gap from tall content pages (Terms, Privacy)\n\t\t\t\t because `auto` collapses to 0 when the flex parent is\n\t\t\t\t already at or beyond its main size. */\n\t\t\t\tfooter {\n\t\t\t\t\tmargin-top: auto;\n\t\t\t\t\tpadding-top: 1rem;\n\t\t\t\t\tborder-top: 1px solid var(--line);\n\t\t\t\t\tfont-size: var(--t-xs);\n\t\t\t\t\tcolor: var(--muted);\n\t\t\t\t\ttext-align: left;\n\t\t\t\t\tline-height: 1.6;\n\t\t\t\t}\n\t\t\t\t/* Reserve breathing room above the footer when content is tall.\n\t\t\t\t `margin-top: auto` alone would push the footer against the\n\t\t\t\t content when the page overflows; this gives it its historical\n\t\t\t\t 2.25rem gap on long pages. */\n\t\t\t\tfooter::before {\n\t\t\t\t\tcontent: \"\";\n\t\t\t\t\tdisplay: block;\n\t\t\t\t\theight: 2.25rem;\n\t\t\t\t}\n\t\t\t\tfooter a { color: var(--muted); text-decoration-color: var(--line); }\n\t\t\t\tfooter a:hover { color: var(--ink); text-decoration-color: var(--ink); }\n\n\t\t\t\t/* Error state — same visual grammar, accent underlines the issue. */\n\t\t\t\t.error-note {\n\t\t\t\t\tmargin: 2rem 0;\n\t\t\t\t\tpadding: 1rem 1.25rem;\n\t\t\t\t\tborder-left: 3px solid var(--accent);\n\t\t\t\t\tbackground: var(--surface);\n\t\t\t\t\tcolor: var(--ink);\n\t\t\t\t\tfont-size: var(--t-s);\n\t\t\t\t}\n\n\t\t\t\t/* Resolver hint — small inline feedback below the identity input\n\t\t\t\t while a handle is being resolved to its DID. */\n\t\t\t\t.resolver-hint {\n\t\t\t\t\tdisplay: none;\n\t\t\t\t\tfont-family: 'JetBrains Mono', 'Menlo', monospace;\n\t\t\t\t\tfont-size: var(--t-xs);\n\t\t\t\t\tmargin: 0.25rem 0 0.5rem;\n\t\t\t\t\tline-height: 1.4;\n\t\t\t\t}\n\t\t\t\t.resolver-hint.is-loading { display: block; color: var(--muted); font-style: italic; font-family: var(--font-body); }\n\t\t\t\t.resolver-hint.is-ok { display: block; color: var(--ink); }\n\t\t\t\t.resolver-hint.is-ok::before { content: \"→ \"; color: var(--accent); font-weight: 700; }\n\t\t\t\t.resolver-hint.is-err { display: block; color: var(--accent-ink); }\n\t\t\t\t.resolver-hint.is-err::before { content: \"⚠ \"; }\n\n\t\t\t\t/* Mobile tightening — the 680px reading column + 2rem padding\n\t\t\t\t already mostly handles this, but at narrow widths the\n\t\t\t\t masthead is too big and forms get cramped. */\n\t\t\t\t@media (max-width: 520px) {\n\t\t\t\t\t.page { padding: 1.5rem 1.25rem 2rem; }\n\t\t\t\t\t.masthead { font-size: 2.25rem; }\n\t\t\t\t\t/* Sub-page masthead must stay smaller than the landing masthead\n\t\t\t\t\t on mobile too — without this rule, the later .masthead size\n\t\t\t\t\t wins by source order (same specificity) and the Round 2\n\t\t\t\t\t landing-only drop-cap motif dilutes on phones. */\n\t\t\t\t\t.masthead-sub { font-size: 1.625rem; }\n\t\t\t\t\t.lede { font-size: var(--t-m); margin-bottom: 1rem; }\n\t\t\t\t\t.section { margin-top: 1.25rem; padding-top: 0.75rem; }\n\t\t\t\t}\n\t\t\t</style></head><body><main class=\"page\">") 85 + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 3, "\"><link rel=\"preconnect\" href=\"https://fonts.googleapis.com\"><link rel=\"preconnect\" href=\"https://fonts.gstatic.com\" crossorigin><link href=\"https://fonts.googleapis.com/css2?family=Young+Serif&family=Atkinson+Hyperlegible:ital,wght@0,400;0,700;1,400;1,700&display=swap\" rel=\"stylesheet\"><style>\n\t\t\t\t:root {\n\t\t\t\t\t/* OKLCH color tokens — neutrals tinted warm (hue ~70) toward the paper base. */\n\t\t\t\t\t--bg: oklch(0.98 0.005 70);\n\t\t\t\t\t--ink: oklch(0.22 0.02 70);\n\t\t\t\t\t--muted: oklch(0.50 0.01 70);\n\t\t\t\t\t--line: oklch(0.85 0.01 70);\n\t\t\t\t\t--accent: oklch(0.55 0.22 25); /* stamp-red */\n\t\t\t\t\t--accent-ink: oklch(0.38 0.18 25); /* darker for hover/underline */\n\t\t\t\t\t--surface: oklch(1 0 0); /* pure white for credential boxes to contrast paper */\n\n\t\t\t\t\t--font-display: 'Young Serif', 'Iowan Old Style', 'Palatino Linotype', Palatino, serif;\n\t\t\t\t\t--font-body: 'Atkinson Hyperlegible', 'Charter', 'Georgia', serif;\n\n\t\t\t\t\t/* Type scale: 1.25 ratio, fixed rem (product UI, not marketing).\n\t\t\t\t\t Masthead is tuned to fit above the fold on a 720-line\n\t\t\t\t\t laptop without sacrificing the newspaper grammar. */\n\t\t\t\t\t--t-xs: 0.8125rem; /* 13px */\n\t\t\t\t\t--t-s: 0.9375rem; /* 15px */\n\t\t\t\t\t--t-m: 1.0625rem; /* 17px */\n\t\t\t\t\t--t-l: 1.1875rem; /* 19px */\n\t\t\t\t\t--t-xl: 1.375rem; /* 22px */\n\t\t\t\t\t--t-2xl: 2rem; /* 32px */\n\t\t\t\t\t--t-3xl: 3rem; /* 48px — masthead */\n\t\t\t\t}\n\t\t\t\t* { box-sizing: border-box; }\n\t\t\t\thtml, body {\n\t\t\t\t\tmargin: 0;\n\t\t\t\t\tpadding: 0;\n\t\t\t\t\tbackground: var(--bg);\n\t\t\t\t\tcolor: var(--ink);\n\t\t\t\t\tfont-family: var(--font-body);\n\t\t\t\t\tfont-size: var(--t-m);\n\t\t\t\t\tline-height: 1.45;\n\t\t\t\t\t-webkit-font-smoothing: antialiased;\n\t\t\t\t\t-moz-osx-font-smoothing: grayscale;\n\t\t\t\t}\n\t\t\t\t/* Body is a flex column so the footer can stick to the viewport\n\t\t\t\t bottom on short pages (see `footer { margin-top: auto; }` below).\n\t\t\t\t Without this, the footer floats mid-page when the content\n\t\t\t\t column is shorter than the viewport. */\n\t\t\t\thtml { min-height: 100%; }\n\t\t\t\tbody { min-height: 100vh; display: flex; flex-direction: column; }\n\t\t\t\t/* Body links are ink with an accent underline. Reserving stamp-red\n\t\t\t\t for the drop-cap, primary button, and credential callout keeps\n\t\t\t\t the accent color heroic — not stippled across every paragraph. */\n\t\t\t\ta {\n\t\t\t\t\tcolor: var(--ink);\n\t\t\t\t\ttext-decoration: underline;\n\t\t\t\t\ttext-decoration-color: var(--accent);\n\t\t\t\t\ttext-decoration-thickness: 1.5px;\n\t\t\t\t\ttext-underline-offset: 3px;\n\t\t\t\t}\n\t\t\t\ta:hover {\n\t\t\t\t\tcolor: var(--accent-ink);\n\t\t\t\t\ttext-decoration-color: var(--accent-ink);\n\t\t\t\t}\n\t\t\t\tcode {\n\t\t\t\t\tfont-family: 'JetBrains Mono', 'Menlo', 'Consolas', monospace;\n\t\t\t\t\tfont-size: 0.95em;\n\t\t\t\t\t/* Pure white fill against warm paper — same treatment\n\t\t\t\t\t as <pre> and credential boxes. Reads as \"data chit\n\t\t\t\t\t on stationery\" rather than a bordered same-color\n\t\t\t\t\t region that blurs into prose. */\n\t\t\t\t\tbackground: var(--surface);\n\t\t\t\t\tpadding: 0 0.25em;\n\t\t\t\t\tborder: 1px solid var(--line);\n\t\t\t\t\tborder-radius: 2px;\n\t\t\t\t}\n\t\t\t\tpre {\n\t\t\t\t\tfont-family: 'JetBrains Mono', 'Menlo', 'Consolas', monospace;\n\t\t\t\t\tfont-size: 0.9em;\n\t\t\t\t\tbackground: var(--surface);\n\t\t\t\t\tcolor: var(--ink);\n\t\t\t\t\tborder: 1px solid var(--line);\n\t\t\t\t\tpadding: 1rem 1.25rem;\n\t\t\t\t\tborder-radius: 2px;\n\t\t\t\t\toverflow-x: auto;\n\t\t\t\t\twhite-space: pre-wrap;\n\t\t\t\t\tword-break: break-all;\n\t\t\t\t\tline-height: 1.5;\n\t\t\t\t}\n\t\t\t\tpre code { background: none; border: none; padding: 0; }\n\n\t\t\t\t/* Top-of-page nav — subtle home link so every page can\n\t\t\t\t return to the marketing landing with one click. Lives\n\t\t\t\t above the masthead; typography-sized so it doesn't\n\t\t\t\t visually compete with the section mastheads below. */\n\t\t\t\t.topnav {\n\t\t\t\t\tfont-family: var(--font-body);\n\t\t\t\t\tfont-size: var(--t-xs);\n\t\t\t\t\ttext-transform: uppercase;\n\t\t\t\t\tletter-spacing: 0.12em;\n\t\t\t\t\tcolor: var(--muted);\n\t\t\t\t\tmargin-bottom: 1.5rem;\n\t\t\t\t\tpadding-bottom: 0.5rem;\n\t\t\t\t\tborder-bottom: 1px solid var(--line);\n\t\t\t\t}\n\t\t\t\t.topnav-home {\n\t\t\t\t\tcolor: var(--muted);\n\t\t\t\t\ttext-decoration: none;\n\t\t\t\t\tfont-weight: 700;\n\t\t\t\t}\n\t\t\t\t.topnav-home:hover {\n\t\t\t\t\tcolor: var(--accent);\n\t\t\t\t}\n\n\t\t\t\t.page {\n\t\t\t\t\tmax-width: 680px;\n\t\t\t\t\twidth: 100%;\n\t\t\t\t\tmargin: 0 auto;\n\t\t\t\t\tpadding: 2rem 2rem 2.5rem;\n\t\t\t\t\t/* Flex column so the footer can grow to fill vertical space\n\t\t\t\t\t via `margin-top: auto`, pinning it to the viewport bottom\n\t\t\t\t\t on short pages. */\n\t\t\t\t\tflex: 1 0 auto;\n\t\t\t\t\tdisplay: flex;\n\t\t\t\t\tflex-direction: column;\n\t\t\t\t}\n\n\t\t\t\t/* Masthead — deliberately large, newspaper-style. The leading 'A'\n\t\t\t\t of \"Atmosphere\" gets the accent color; the rest is ink. */\n\t\t\t\t.masthead {\n\t\t\t\t\tfont-family: var(--font-display);\n\t\t\t\t\tfont-size: var(--t-3xl);\n\t\t\t\t\tline-height: 1;\n\t\t\t\t\tletter-spacing: -0.03em;\n\t\t\t\t\tmargin: 0 0 0.5rem;\n\t\t\t\t\tcolor: var(--ink);\n\t\t\t\t}\n\t\t\t\t.masthead .drop {\n\t\t\t\t\tcolor: var(--accent);\n\t\t\t\t}\n\t\t\t\t/* Sub-page masthead — smaller, no drop-cap. The drop-cap is reserved\n\t\t\t\t for the landing's brand mark; pages like /terms /privacy /aup /about\n\t\t\t\t are reference docs that cede visual authority to the landing. */\n\t\t\t\t.masthead-sub {\n\t\t\t\t\tfont-size: var(--t-2xl);\n\t\t\t\t\tmargin-bottom: 0.25rem;\n\t\t\t\t}\n\t\t\t\t/* Effective-date marginalia — the typographic convention for legal\n\t\t\t\t documents. Sits directly under the title in small-caps, before\n\t\t\t\t the lede. Replaces the awkward \"Effective X. Lorem ipsum...\"\n\t\t\t\t sentence-opener pattern used in the first draft. */\n\t\t\t\t.effective {\n\t\t\t\t\tfont-family: var(--font-body);\n\t\t\t\t\tfont-size: var(--t-xs);\n\t\t\t\t\tfont-weight: 700;\n\t\t\t\t\ttext-transform: uppercase;\n\t\t\t\t\tletter-spacing: 0.12em;\n\t\t\t\t\tcolor: var(--muted);\n\t\t\t\t\tmargin: 0 0 1.25rem;\n\t\t\t\t}\n\t\t\t\t.lede {\n\t\t\t\t\tfont-family: var(--font-body);\n\t\t\t\t\tfont-style: italic;\n\t\t\t\t\tfont-size: var(--t-l);\n\t\t\t\t\tcolor: var(--muted);\n\t\t\t\t\tline-height: 1.4;\n\t\t\t\t\tmargin: 0 0 1rem;\n\t\t\t\t\tmax-width: 32em;\n\t\t\t\t}\n\n\t\t\t\t/* Section heading — Young Serif, smaller than masthead, with a\n\t\t\t\t hairline rule above. Evokes a typeset page break. */\n\t\t\t\t.section {\n\t\t\t\t\tmargin-top: 1.75rem;\n\t\t\t\t\tpadding-top: 1rem;\n\t\t\t\t\tborder-top: 1px solid var(--line);\n\t\t\t\t}\n\t\t\t\t.section h2 {\n\t\t\t\t\tfont-family: var(--font-display);\n\t\t\t\t\tfont-size: var(--t-xl);\n\t\t\t\t\tfont-weight: 400;\n\t\t\t\t\tletter-spacing: -0.01em;\n\t\t\t\t\tmargin: 0 0 0.35rem;\n\t\t\t\t}\n\t\t\t\t.section p {\n\t\t\t\t\tmargin: 0.35rem 0 0.6rem;\n\t\t\t\t}\n\t\t\t\t.section-lede {\n\t\t\t\t\tfont-size: var(--t-s);\n\t\t\t\t\tcolor: var(--muted);\n\t\t\t\t\tmargin: 0 0 1rem;\n\t\t\t\t\tmax-width: 36em;\n\t\t\t\t}\n\n\t\t\t\t/* Step number — small-caps marginalia, NOT a boxed card number. */\n\t\t\t\t.step-marker {\n\t\t\t\t\tdisplay: block;\n\t\t\t\t\tfont-family: var(--font-body);\n\t\t\t\t\tfont-size: var(--t-xs);\n\t\t\t\t\tfont-weight: 700;\n\t\t\t\t\ttext-transform: uppercase;\n\t\t\t\t\tletter-spacing: 0.12em;\n\t\t\t\t\tcolor: var(--muted);\n\t\t\t\t\tmargin-bottom: 0.25rem;\n\t\t\t\t}\n\n\t\t\t\t/* Form — boxed fields on white surface. Earlier iterations used\n\t\t\t\t hairline-underline inputs (no box, just a bottom border), but\n\t\t\t\t those collided visually with the section dividers and readers\n\t\t\t\t couldn't tell what was input vs. structure. A subtle surface\n\t\t\t\t fill + 1px frame makes \"this is a typeable field\" unambiguous\n\t\t\t\t without dragging the page toward a generic webform aesthetic. */\n\t\t\t\tlabel {\n\t\t\t\t\tdisplay: block;\n\t\t\t\t\tfont-size: var(--t-s);\n\t\t\t\t\tfont-weight: 700;\n\t\t\t\t\tmargin-top: 1rem;\n\t\t\t\t\tmargin-bottom: 0.25rem;\n\t\t\t\t}\n\t\t\t\tlabel + small {\n\t\t\t\t\tdisplay: block;\n\t\t\t\t\tcolor: var(--muted);\n\t\t\t\t\tfont-size: var(--t-xs);\n\t\t\t\t\tmargin-bottom: 0.5rem;\n\t\t\t\t}\n\t\t\t\tinput[type=text],\n\t\t\t\tinput[type=email],\n\t\t\t\ttextarea {\n\t\t\t\t\tdisplay: block;\n\t\t\t\t\twidth: 100%;\n\t\t\t\t\tfont-family: inherit;\n\t\t\t\t\tfont-size: var(--t-m);\n\t\t\t\t\tcolor: var(--ink);\n\t\t\t\t\tbackground: var(--surface);\n\t\t\t\t\tborder: 1px solid var(--line);\n\t\t\t\t\tborder-radius: 2px;\n\t\t\t\t\tpadding: 0.6rem 0.75rem;\n\t\t\t\t\tmargin-bottom: 0.5rem;\n\t\t\t\t\toutline: none;\n\t\t\t\t\ttransition: border-color 120ms ease, box-shadow 120ms ease;\n\t\t\t\t}\n\t\t\t\tinput[type=text]:focus,\n\t\t\t\tinput[type=email]:focus,\n\t\t\t\ttextarea:focus {\n\t\t\t\t\tborder-color: var(--ink);\n\t\t\t\t\tbox-shadow: inset 0 -2px 0 0 var(--accent);\n\t\t\t\t}\n\t\t\t\t.handle-input-wrapper {\n\t\t\t\t\tposition: relative;\n\t\t\t\t}\n\t\t\t\t.handle-input-wrapper input[type=text] {\n\t\t\t\t\tpadding-left: 1.75rem;\n\t\t\t\t}\n\t\t\t\t.handle-input-wrapper::before {\n\t\t\t\t\tcontent: \"@\";\n\t\t\t\t\tposition: absolute;\n\t\t\t\t\tleft: 0.75rem;\n\t\t\t\t\ttop: 0.6rem;\n\t\t\t\t\tfont-family: 'JetBrains Mono', 'Menlo', monospace;\n\t\t\t\t\tfont-size: var(--t-m);\n\t\t\t\t\tcolor: var(--muted);\n\t\t\t\t\tpointer-events: none;\n\t\t\t\t\tz-index: 1;\n\t\t\t\t}\n\t\t\t\t.handle-suggestions {\n\t\t\t\t\tposition: absolute;\n\t\t\t\t\tleft: 0;\n\t\t\t\t\tright: 0;\n\t\t\t\t\tbottom: 100%;\n\t\t\t\t\tbackground: var(--ink);\n\t\t\t\t\tcolor: var(--bg);\n\t\t\t\t\tborder-radius: 2px 2px 0 0;\n\t\t\t\t\tz-index: 10;\n\t\t\t\t\tmax-height: 260px;\n\t\t\t\t\toverflow-y: auto;\n\t\t\t\t\tbox-shadow: 0 -4px 16px rgba(0,0,0,0.15);\n\t\t\t\t}\n\t\t\t\t.handle-suggestion {\n\t\t\t\t\tdisplay: flex;\n\t\t\t\t\talign-items: center;\n\t\t\t\t\tgap: 0.6rem;\n\t\t\t\t\tpadding: 0.5rem 0.75rem;\n\t\t\t\t\tcursor: pointer;\n\t\t\t\t\ttransition: background 0.1s;\n\t\t\t\t}\n\t\t\t\t.handle-suggestion:hover,\n\t\t\t\t.handle-suggestion.active {\n\t\t\t\t\tbackground: oklch(0.30 0.02 70);\n\t\t\t\t}\n\t\t\t\t.suggestion-avatar {\n\t\t\t\t\twidth: 32px;\n\t\t\t\t\theight: 32px;\n\t\t\t\t\tborder-radius: 50%;\n\t\t\t\t\tflex-shrink: 0;\n\t\t\t\t\tobject-fit: cover;\n\t\t\t\t}\n\t\t\t\t.suggestion-avatar-placeholder {\n\t\t\t\t\twidth: 32px;\n\t\t\t\t\theight: 32px;\n\t\t\t\t\tborder-radius: 50%;\n\t\t\t\t\tflex-shrink: 0;\n\t\t\t\t\tbackground: oklch(0.40 0.01 70);\n\t\t\t\t}\n\t\t\t\t.suggestion-text {\n\t\t\t\t\tdisplay: flex;\n\t\t\t\t\tflex-direction: column;\n\t\t\t\t\tmin-width: 0;\n\t\t\t\t}\n\t\t\t\t.suggestion-name {\n\t\t\t\t\tfont-weight: 700;\n\t\t\t\t\tfont-size: var(--t-s);\n\t\t\t\t\toverflow: hidden;\n\t\t\t\t\ttext-overflow: ellipsis;\n\t\t\t\t\twhite-space: nowrap;\n\t\t\t\t}\n\t\t\t\t.suggestion-handle {\n\t\t\t\t\tfont-size: var(--t-xs);\n\t\t\t\t\tcolor: oklch(0.65 0.01 70);\n\t\t\t\t\toverflow: hidden;\n\t\t\t\t\ttext-overflow: ellipsis;\n\t\t\t\t\twhite-space: nowrap;\n\t\t\t\t}\n\t\t\t\ttextarea {\n\t\t\t\t\tresize: vertical;\n\t\t\t\t\tline-height: 1.4;\n\t\t\t\t\tfont-family: 'JetBrains Mono', 'Menlo', monospace;\n\t\t\t\t\tfont-size: var(--t-s);\n\t\t\t\t}\n\t\t\t\t/* Buttons and button-styled links share one base. .btn-secondary\n\t\t\t\t is the ghost variant — muted, reserved for withdrawal\n\t\t\t\t actions (sign out) and secondary CTAs (sign in next to\n\t\t\t\t \"Enroll a domain\"). Keeping them as class variants on a\n\t\t\t\t single base means the aesthetic stays consistent when we\n\t\t\t\t change padding or weight in one place. */\n\t\t\t\tbutton,\n\t\t\t\t.btn {\n\t\t\t\t\tdisplay: inline-block;\n\t\t\t\t\tfont-family: inherit;\n\t\t\t\t\tfont-size: var(--t-s);\n\t\t\t\t\tfont-weight: 700;\n\t\t\t\t\tletter-spacing: 0.02em;\n\t\t\t\t\tcolor: var(--bg);\n\t\t\t\t\tbackground: var(--ink);\n\t\t\t\t\tborder: 1px solid var(--ink);\n\t\t\t\t\tpadding: 0.65rem 1.5rem;\n\t\t\t\t\tmargin-top: 1.25rem;\n\t\t\t\t\tcursor: pointer;\n\t\t\t\t\ttext-decoration: none;\n\t\t\t\t\ttransition: background 120ms ease, color 120ms ease, border-color 120ms ease;\n\t\t\t\t}\n\t\t\t\t.btn-secondary,\n\t\t\t\tbutton.btn-secondary {\n\t\t\t\t\tcolor: var(--ink);\n\t\t\t\t\tbackground: transparent;\n\t\t\t\t\tborder-color: var(--line);\n\t\t\t\t}\n\t\t\t\tbutton:hover,\n\t\t\t\t.btn:hover {\n\t\t\t\t\tbackground: var(--accent);\n\t\t\t\t\tborder-color: var(--accent);\n\t\t\t\t\tcolor: var(--bg);\n\t\t\t\t}\n\t\t\t\t.btn-secondary:hover,\n\t\t\t\tbutton.btn-secondary:hover {\n\t\t\t\t\tbackground: var(--ink);\n\t\t\t\t\tborder-color: var(--ink);\n\t\t\t\t\tcolor: var(--bg);\n\t\t\t\t}\n\n\t\t\t\t/* Credential box — inverse of the page (surface white on paper).\n\t\t\t\t This is the ONE boxed element on the success page; everything\n\t\t\t\t else is just typography. Makes the API key impossible to miss. */\n\t\t\t\t.credential {\n\t\t\t\t\tmargin: 1.5rem 0;\n\t\t\t\t\tpadding: 1.25rem 1.5rem;\n\t\t\t\t\tbackground: var(--surface);\n\t\t\t\t\tborder: 1px solid var(--line);\n\t\t\t\t}\n\t\t\t\t.credential-label {\n\t\t\t\t\tfont-size: var(--t-xs);\n\t\t\t\t\tfont-weight: 700;\n\t\t\t\t\ttext-transform: uppercase;\n\t\t\t\t\tletter-spacing: 0.12em;\n\t\t\t\t\tcolor: var(--accent);\n\t\t\t\t\tmargin-bottom: 0.5rem;\n\t\t\t\t}\n\t\t\t\t.credential-note {\n\t\t\t\t\tfont-size: var(--t-xs);\n\t\t\t\t\tfont-style: italic;\n\t\t\t\t\tcolor: var(--muted);\n\t\t\t\t\tmargin-top: 0.75rem;\n\t\t\t\t}\n\n\t\t\t\t.dns-block {\n\t\t\t\t\tmargin: 1.25rem 0;\n\t\t\t\t}\n\t\t\t\t.dns-block-label {\n\t\t\t\t\tfont-family: 'JetBrains Mono', monospace;\n\t\t\t\t\tfont-size: var(--t-xs);\n\t\t\t\t\tcolor: var(--muted);\n\t\t\t\t\tmargin-bottom: 0.25rem;\n\t\t\t\t}\n\n\t\t\t\t.bullets {\n\t\t\t\t\tmargin: 0.5rem 0;\n\t\t\t\t\tpadding-left: 1.25rem;\n\t\t\t\t}\n\t\t\t\t.bullets li {\n\t\t\t\t\tmargin-bottom: 0.25rem;\n\t\t\t\t}\n\n\t\t\t\t/* Footer — light, one line, hairline rule. `margin-top: auto`\n\t\t\t\t on a flex child in a column container pushes the footer to\n\t\t\t\t the bottom of whatever space is left, so it sticks to the\n\t\t\t\t viewport bottom on short pages. The 2.25rem minimum keeps\n\t\t\t\t a comfortable gap from tall content pages (Terms, Privacy)\n\t\t\t\t because `auto` collapses to 0 when the flex parent is\n\t\t\t\t already at or beyond its main size. */\n\t\t\t\tfooter {\n\t\t\t\t\tmargin-top: auto;\n\t\t\t\t\tpadding-top: 1rem;\n\t\t\t\t\tborder-top: 1px solid var(--line);\n\t\t\t\t\tfont-size: var(--t-xs);\n\t\t\t\t\tcolor: var(--muted);\n\t\t\t\t\ttext-align: left;\n\t\t\t\t\tline-height: 1.6;\n\t\t\t\t}\n\t\t\t\t/* Reserve breathing room above the footer when content is tall.\n\t\t\t\t `margin-top: auto` alone would push the footer against the\n\t\t\t\t content when the page overflows; this gives it its historical\n\t\t\t\t 2.25rem gap on long pages. */\n\t\t\t\tfooter::before {\n\t\t\t\t\tcontent: \"\";\n\t\t\t\t\tdisplay: block;\n\t\t\t\t\theight: 2.25rem;\n\t\t\t\t}\n\t\t\t\tfooter a { color: var(--muted); text-decoration-color: var(--line); }\n\t\t\t\tfooter a:hover { color: var(--ink); text-decoration-color: var(--ink); }\n\n\t\t\t\t/* Error state — same visual grammar, accent underlines the issue. */\n\t\t\t\t.error-note {\n\t\t\t\t\tmargin: 2rem 0;\n\t\t\t\t\tpadding: 1rem 1.25rem;\n\t\t\t\t\tbackground: oklch(0.95 0.03 25);\n\t\t\t\t\tborder: 1px solid oklch(0.85 0.06 25);\n\t\t\t\t\tcolor: var(--ink);\n\t\t\t\t\tfont-size: var(--t-s);\n\t\t\t\t}\n\n\t\t\t\t/* Resolver hint — small inline feedback below the identity input\n\t\t\t\t while a handle is being resolved to its DID. */\n\t\t\t\t.resolver-hint {\n\t\t\t\t\tdisplay: none;\n\t\t\t\t\tfont-family: 'JetBrains Mono', 'Menlo', monospace;\n\t\t\t\t\tfont-size: var(--t-xs);\n\t\t\t\t\tmargin: 0.25rem 0 0.5rem;\n\t\t\t\t\tline-height: 1.4;\n\t\t\t\t}\n\t\t\t\t.resolver-hint.is-loading { display: block; color: var(--muted); font-style: italic; font-family: var(--font-body); }\n\t\t\t\t.resolver-hint.is-ok { display: block; color: var(--ink); }\n\t\t\t\t.resolver-hint.is-ok::before { content: \"→ \"; color: var(--accent); font-weight: 700; }\n\t\t\t\t.resolver-hint.is-err { display: block; color: var(--accent-ink); }\n\t\t\t\t.resolver-hint.is-err::before { content: \"⚠ \"; }\n\n\t\t\t\t/* Mobile tightening — the 680px reading column + 2rem padding\n\t\t\t\t already mostly handles this, but at narrow widths the\n\t\t\t\t masthead is too big and forms get cramped. */\n\t\t\t\t@media (max-width: 520px) {\n\t\t\t\t\t.page { padding: 1.5rem 1.25rem 2rem; }\n\t\t\t\t\t.masthead { font-size: 2.25rem; }\n\t\t\t\t\t/* Sub-page masthead must stay smaller than the landing masthead\n\t\t\t\t\t on mobile too — without this rule, the later .masthead size\n\t\t\t\t\t wins by source order (same specificity) and the Round 2\n\t\t\t\t\t landing-only drop-cap motif dilutes on phones. */\n\t\t\t\t\t.masthead-sub { font-size: 1.625rem; }\n\t\t\t\t\t.lede { font-size: var(--t-m); margin-bottom: 1rem; }\n\t\t\t\t\t.section { margin-top: 1.25rem; padding-top: 0.75rem; }\n\t\t\t\t}\n\t\t\t</style></head><body><main class=\"page\">") 86 86 if templ_7745c5c3_Err != nil { 87 87 return templ_7745c5c3_Err 88 88 } ··· 208 208 if templ_7745c5c3_Err != nil { 209 209 return templ_7745c5c3_Err 210 210 } 211 - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 14, "</button></form><p class=\"section-lede\" style=\"margin-top: 1rem; margin-bottom: 0;\">Already enrolled? Sign in at <a href=\"/account\">Account</a> to see DKIM records, rotate your API key, or update your contact email.</p><p class=\"section-lede\" style=\"margin-top: 0.5rem; margin-bottom: 0; font-size: var(--t-xs);\">New here? The <a href=\"/\">landing page</a> covers how this works.</p><script>\n\t\t\t\t\t(function() {\n\t\t\t\t\t\tvar SEARCH_API = 'https://public.api.bsky.app/xrpc/app.bsky.actor.searchActorsTypeahead';\n\t\t\t\t\t\tvar DEBOUNCE_MS = 250;\n\t\t\t\t\t\tvar MIN_QUERY = 2;\n\t\t\t\t\t\tvar MAX_RESULTS = 6;\n\n\t\t\t\t\t\tvar form = document.getElementById('enroll-form');\n\t\t\t\t\t\tvar identity = document.getElementById('identity');\n\t\t\t\t\t\tvar didField = document.getElementById('did');\n\t\t\t\t\t\tvar hint = document.getElementById('resolver-hint');\n\t\t\t\t\t\tvar submit = document.getElementById('enroll-submit');\n\n\t\t\t\t\t\tvar debounceTimer = null;\n\t\t\t\t\t\tvar abortCtrl = null;\n\t\t\t\t\t\tvar activeIndex = -1;\n\t\t\t\t\t\tvar currentResults = [];\n\n\t\t\t\t\t\tfunction esc(s) {\n\t\t\t\t\t\t\treturn s.replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;').replace(/\"/g,'&quot;');\n\t\t\t\t\t\t}\n\t\t\t\t\t\tfunction isDID(s) {\n\t\t\t\t\t\t\treturn /^did:(plc|web):[A-Za-z0-9._%\\-]+$/.test(s.trim());\n\t\t\t\t\t\t}\n\t\t\t\t\t\tfunction setHint(text, cls) {\n\t\t\t\t\t\t\thint.textContent = text;\n\t\t\t\t\t\t\thint.className = 'resolver-hint ' + (cls || '');\n\t\t\t\t\t\t}\n\n\t\t\t\t\t\tvar wrapper = document.createElement('div');\n\t\t\t\t\t\twrapper.className = 'handle-input-wrapper';\n\t\t\t\t\t\tidentity.parentElement.insertBefore(wrapper, identity);\n\t\t\t\t\t\twrapper.appendChild(identity);\n\t\t\t\t\t\twrapper.parentElement.insertBefore(hint, wrapper.nextSibling);\n\n\t\t\t\t\t\tvar dropdown = document.createElement('div');\n\t\t\t\t\t\tdropdown.className = 'handle-suggestions';\n\t\t\t\t\t\tdropdown.setAttribute('role', 'listbox');\n\t\t\t\t\t\tdropdown.style.display = 'none';\n\t\t\t\t\t\twrapper.appendChild(dropdown);\n\t\t\t\t\t\tidentity.setAttribute('role', 'combobox');\n\t\t\t\t\t\tidentity.setAttribute('aria-autocomplete', 'list');\n\t\t\t\t\t\tidentity.setAttribute('aria-expanded', 'false');\n\n\t\t\t\t\t\tfunction renderSuggestions(results) {\n\t\t\t\t\t\t\tif (!results.length) {\n\t\t\t\t\t\t\t\tdropdown.style.display = 'none';\n\t\t\t\t\t\t\t\tidentity.setAttribute('aria-expanded', 'false');\n\t\t\t\t\t\t\t\treturn;\n\t\t\t\t\t\t\t}\n\t\t\t\t\t\t\tdropdown.innerHTML = results.map(function(r, i) {\n\t\t\t\t\t\t\t\treturn '<div class=\"handle-suggestion\" role=\"option\" data-index=\"' + i + '\" data-handle=\"' + esc(r.handle) + '\">'\n\t\t\t\t\t\t\t\t\t+ (r.avatar\n\t\t\t\t\t\t\t\t\t\t? '<img src=\"' + esc(r.avatar) + '\" alt=\"\" class=\"suggestion-avatar\"/>'\n\t\t\t\t\t\t\t\t\t\t: '<div class=\"suggestion-avatar-placeholder\"></div>')\n\t\t\t\t\t\t\t\t\t+ '<div class=\"suggestion-text\">'\n\t\t\t\t\t\t\t\t\t+ '<span class=\"suggestion-name\">' + esc(r.displayName) + '</span>'\n\t\t\t\t\t\t\t\t\t+ '<span class=\"suggestion-handle\">@' + esc(r.handle) + '</span>'\n\t\t\t\t\t\t\t\t\t+ '</div></div>';\n\t\t\t\t\t\t\t}).join('');\n\t\t\t\t\t\t\tdropdown.style.display = '';\n\t\t\t\t\t\t\tidentity.setAttribute('aria-expanded', 'true');\n\t\t\t\t\t\t}\n\n\t\t\t\t\t\tfunction updateActive() {\n\t\t\t\t\t\t\tvar items = dropdown.querySelectorAll('.handle-suggestion');\n\t\t\t\t\t\t\tfor (var i = 0; i < items.length; i++) {\n\t\t\t\t\t\t\t\tif (i === activeIndex) items[i].classList.add('active');\n\t\t\t\t\t\t\t\telse items[i].classList.remove('active');\n\t\t\t\t\t\t\t}\n\t\t\t\t\t\t}\n\n\t\t\t\t\t\tfunction selectHandle(handle) {\n\t\t\t\t\t\t\tidentity.value = handle;\n\t\t\t\t\t\t\tdropdown.style.display = 'none';\n\t\t\t\t\t\t\tidentity.setAttribute('aria-expanded', 'false');\n\t\t\t\t\t\t\tcurrentResults = [];\n\t\t\t\t\t\t\tactiveIndex = -1;\n\t\t\t\t\t\t\tresolve(handle);\n\t\t\t\t\t\t}\n\n\t\t\t\t\t\tfunction searchHandles(query) {\n\t\t\t\t\t\t\tif (abortCtrl) abortCtrl.abort();\n\t\t\t\t\t\t\tif (query.length < MIN_QUERY) return Promise.resolve([]);\n\t\t\t\t\t\t\tabortCtrl = new AbortController();\n\t\t\t\t\t\t\treturn fetch(SEARCH_API + '?q=' + encodeURIComponent(query) + '&limit=' + MAX_RESULTS, { signal: abortCtrl.signal })\n\t\t\t\t\t\t\t\t.then(function(r) { return r.ok ? r.json() : { actors: [] }; })\n\t\t\t\t\t\t\t\t.then(function(data) {\n\t\t\t\t\t\t\t\t\treturn (data.actors || []).map(function(a) {\n\t\t\t\t\t\t\t\t\t\treturn { handle: a.handle, displayName: a.displayName || a.handle, avatar: a.avatar || null };\n\t\t\t\t\t\t\t\t\t});\n\t\t\t\t\t\t\t\t})\n\t\t\t\t\t\t\t\t.catch(function() { return []; });\n\t\t\t\t\t\t}\n\n\t\t\t\t\t\tfunction debouncedSearch(query) {\n\t\t\t\t\t\t\tif (debounceTimer) clearTimeout(debounceTimer);\n\t\t\t\t\t\t\tif (query.length < MIN_QUERY) { renderSuggestions([]); return; }\n\t\t\t\t\t\t\tdebounceTimer = setTimeout(function() {\n\t\t\t\t\t\t\t\tsearchHandles(query).then(function(results) {\n\t\t\t\t\t\t\t\t\tcurrentResults = results;\n\t\t\t\t\t\t\t\t\tactiveIndex = -1;\n\t\t\t\t\t\t\t\t\trenderSuggestions(results);\n\t\t\t\t\t\t\t\t});\n\t\t\t\t\t\t\t}, DEBOUNCE_MS);\n\t\t\t\t\t\t}\n\n\t\t\t\t\t\tasync function resolve(raw) {\n\t\t\t\t\t\t\tvar v = (raw || '').replace(/^@/, '').trim();\n\t\t\t\t\t\t\tif (!v) { setHint('', ''); didField.value = ''; return; }\n\t\t\t\t\t\t\tif (isDID(v)) { setHint(v, 'is-ok'); didField.value = v; return; }\n\t\t\t\t\t\t\tsetHint('Resolving ' + v + '…', 'is-loading');\n\t\t\t\t\t\t\tdidField.value = '';\n\t\t\t\t\t\t\ttry {\n\t\t\t\t\t\t\t\tvar r = await fetch('/enroll/resolve?handle=' + encodeURIComponent(v), {\n\t\t\t\t\t\t\t\t\theaders: { Accept: 'application/json' },\n\t\t\t\t\t\t\t\t});\n\t\t\t\t\t\t\t\tif (!r.ok) {\n\t\t\t\t\t\t\t\t\tvar body = await r.json().catch(function() { return {error:'resolution failed'}; });\n\t\t\t\t\t\t\t\t\tsetHint(body.error || 'resolution failed', 'is-err');\n\t\t\t\t\t\t\t\t\treturn;\n\t\t\t\t\t\t\t\t}\n\t\t\t\t\t\t\t\tvar data = await r.json();\n\t\t\t\t\t\t\t\tif (data.did) {\n\t\t\t\t\t\t\t\t\tsetHint(data.did, 'is-ok');\n\t\t\t\t\t\t\t\t\tdidField.value = data.did;\n\t\t\t\t\t\t\t\t}\n\t\t\t\t\t\t\t} catch (e) {\n\t\t\t\t\t\t\t\tsetHint('Network error — try again or paste a DID directly', 'is-err');\n\t\t\t\t\t\t\t}\n\t\t\t\t\t\t}\n\n\t\t\t\t\t\tidentity.addEventListener('input', function() {\n\t\t\t\t\t\t\tvar q = identity.value.trim().replace(/^@/, '');\n\t\t\t\t\t\t\tif (didField.value) { didField.value = ''; setHint('', ''); }\n\t\t\t\t\t\t\tif (isDID(q)) {\n\t\t\t\t\t\t\t\trenderSuggestions([]);\n\t\t\t\t\t\t\t\tsetHint(q, 'is-ok');\n\t\t\t\t\t\t\t\tdidField.value = q;\n\t\t\t\t\t\t\t\treturn;\n\t\t\t\t\t\t\t}\n\t\t\t\t\t\t\tdebouncedSearch(q);\n\t\t\t\t\t\t});\n\n\t\t\t\t\t\tidentity.addEventListener('keydown', function(e) {\n\t\t\t\t\t\t\tif (!currentResults.length) return;\n\t\t\t\t\t\t\tif (e.key === 'ArrowDown') {\n\t\t\t\t\t\t\t\te.preventDefault();\n\t\t\t\t\t\t\t\tactiveIndex = Math.min(activeIndex + 1, currentResults.length - 1);\n\t\t\t\t\t\t\t\tupdateActive();\n\t\t\t\t\t\t\t} else if (e.key === 'ArrowUp') {\n\t\t\t\t\t\t\t\te.preventDefault();\n\t\t\t\t\t\t\t\tactiveIndex = Math.max(activeIndex - 1, 0);\n\t\t\t\t\t\t\t\tupdateActive();\n\t\t\t\t\t\t\t} else if (e.key === 'Enter' && activeIndex >= 0) {\n\t\t\t\t\t\t\t\te.preventDefault();\n\t\t\t\t\t\t\t\te.stopPropagation();\n\t\t\t\t\t\t\t\tselectHandle(currentResults[activeIndex].handle);\n\t\t\t\t\t\t\t} else if (e.key === 'Escape') {\n\t\t\t\t\t\t\t\tdropdown.style.display = 'none';\n\t\t\t\t\t\t\t\tidentity.setAttribute('aria-expanded', 'false');\n\t\t\t\t\t\t\t\tactiveIndex = -1;\n\t\t\t\t\t\t\t}\n\t\t\t\t\t\t});\n\n\t\t\t\t\t\tdropdown.addEventListener('mousedown', function(e) {\n\t\t\t\t\t\t\te.preventDefault();\n\t\t\t\t\t\t\tvar target = e.target.closest('.handle-suggestion');\n\t\t\t\t\t\t\tif (target) selectHandle(target.dataset.handle);\n\t\t\t\t\t\t});\n\n\t\t\t\t\t\tidentity.addEventListener('blur', function() {\n\t\t\t\t\t\t\tsetTimeout(function() {\n\t\t\t\t\t\t\t\tdropdown.style.display = 'none';\n\t\t\t\t\t\t\t\tidentity.setAttribute('aria-expanded', 'false');\n\t\t\t\t\t\t\t}, 150);\n\t\t\t\t\t\t\tif (!didField.value) resolve(identity.value);\n\t\t\t\t\t\t});\n\n\t\t\t\t\t\tform.addEventListener('submit', async function(ev) {\n\t\t\t\t\t\t\tif (didField.value) return;\n\t\t\t\t\t\t\tev.preventDefault();\n\t\t\t\t\t\t\tsubmit.disabled = true;\n\t\t\t\t\t\t\tsubmit.textContent = 'Resolving identity…';\n\t\t\t\t\t\t\tawait resolve(identity.value);\n\t\t\t\t\t\t\tsubmit.disabled = false;\n\t\t\t\t\t\t\tsubmit.textContent = submit.getAttribute('data-default-text') || 'Start enrollment';\n\t\t\t\t\t\t\tif (didField.value) form.submit();\n\t\t\t\t\t\t});\n\t\t\t\t\t})();\n\t\t\t\t</script></section>") 211 + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 14, "</button></form><p class=\"section-lede\" style=\"margin-top: 1rem; margin-bottom: 0;\">Already enrolled? Sign in at <a href=\"/account\">Account</a> to see DKIM records, rotate your API key, or update your contact email.</p><p class=\"section-lede\" style=\"margin-top: 0.5rem; margin-bottom: 0; font-size: var(--t-xs);\">New here? The <a href=\"/\">landing page</a> covers how this works.</p><script>\n\t\t\t\t\t(function() {\n\t\t\t\t\t\tvar SEARCH_API = 'https://public.api.bsky.app/xrpc/app.bsky.actor.searchActorsTypeahead';\n\t\t\t\t\t\tvar DEBOUNCE_MS = 250;\n\t\t\t\t\t\tvar MIN_QUERY = 2;\n\t\t\t\t\t\tvar MAX_RESULTS = 6;\n\n\t\t\t\t\t\tvar form = document.getElementById('enroll-form');\n\t\t\t\t\t\tvar identity = document.getElementById('identity');\n\t\t\t\t\t\tvar didField = document.getElementById('did');\n\t\t\t\t\t\tvar hint = document.getElementById('resolver-hint');\n\t\t\t\t\t\tvar submit = document.getElementById('enroll-submit');\n\n\t\t\t\t\t\tvar debounceTimer = null;\n\t\t\t\t\t\tvar abortCtrl = null;\n\t\t\t\t\t\tvar activeIndex = -1;\n\t\t\t\t\t\tvar currentResults = [];\n\n\t\t\t\t\t\tfunction esc(s) {\n\t\t\t\t\t\t\treturn s.replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;').replace(/\"/g,'&quot;');\n\t\t\t\t\t\t}\n\t\t\t\t\t\tfunction isDID(s) {\n\t\t\t\t\t\t\treturn /^did:(plc|web):[A-Za-z0-9._%\\-]+$/.test(s.trim());\n\t\t\t\t\t\t}\n\t\t\t\t\t\tfunction setHint(text, cls) {\n\t\t\t\t\t\t\thint.textContent = text;\n\t\t\t\t\t\t\thint.className = 'resolver-hint ' + (cls || '');\n\t\t\t\t\t\t}\n\n\t\t\t\t\t\tvar wrapper = document.createElement('div');\n\t\t\t\t\t\twrapper.className = 'handle-input-wrapper';\n\t\t\t\t\t\tidentity.parentElement.insertBefore(wrapper, identity);\n\t\t\t\t\t\twrapper.appendChild(identity);\n\t\t\t\t\t\twrapper.parentElement.insertBefore(hint, wrapper.nextSibling);\n\n\t\t\t\t\t\tvar dropdown = document.createElement('div');\n\t\t\t\t\t\tdropdown.className = 'handle-suggestions';\n\t\t\t\t\t\tdropdown.setAttribute('role', 'listbox');\n\t\t\t\t\t\tdropdown.style.display = 'none';\n\t\t\t\t\t\twrapper.appendChild(dropdown);\n\t\t\t\t\t\tidentity.setAttribute('role', 'combobox');\n\t\t\t\t\t\tidentity.setAttribute('aria-autocomplete', 'list');\n\t\t\t\t\t\tidentity.setAttribute('aria-expanded', 'false');\n\n\t\t\t\t\t\tfunction renderSuggestions(results) {\n\t\t\t\t\t\t\tif (!results.length) {\n\t\t\t\t\t\t\t\tdropdown.style.display = 'none';\n\t\t\t\t\t\t\t\tidentity.setAttribute('aria-expanded', 'false');\n\t\t\t\t\t\t\t\treturn;\n\t\t\t\t\t\t\t}\n\t\t\t\t\t\t\tdropdown.innerHTML = results.map(function(r, i) {\n\t\t\t\t\t\t\t\treturn '<div class=\"handle-suggestion\" role=\"option\" data-index=\"' + i + '\" data-handle=\"' + esc(r.handle) + '\">'\n\t\t\t\t\t\t\t\t\t+ (r.avatar\n\t\t\t\t\t\t\t\t\t\t? '<img src=\"' + esc(r.avatar) + '\" alt=\"\" class=\"suggestion-avatar\"/>'\n\t\t\t\t\t\t\t\t\t\t: '<div class=\"suggestion-avatar-placeholder\"></div>')\n\t\t\t\t\t\t\t\t\t+ '<div class=\"suggestion-text\">'\n\t\t\t\t\t\t\t\t\t+ '<span class=\"suggestion-name\">' + esc(r.displayName) + '</span>'\n\t\t\t\t\t\t\t\t\t+ '<span class=\"suggestion-handle\">@' + esc(r.handle) + '</span>'\n\t\t\t\t\t\t\t\t\t+ '</div></div>';\n\t\t\t\t\t\t\t}).join('');\n\t\t\t\t\t\t\tdropdown.style.display = '';\n\t\t\t\t\t\t\tidentity.setAttribute('aria-expanded', 'true');\n\t\t\t\t\t\t}\n\n\t\t\t\t\t\tfunction updateActive() {\n\t\t\t\t\t\t\tvar items = dropdown.querySelectorAll('.handle-suggestion');\n\t\t\t\t\t\t\tfor (var i = 0; i < items.length; i++) {\n\t\t\t\t\t\t\t\tif (i === activeIndex) items[i].classList.add('active');\n\t\t\t\t\t\t\t\telse items[i].classList.remove('active');\n\t\t\t\t\t\t\t}\n\t\t\t\t\t\t}\n\n\t\t\t\t\t\tfunction selectHandle(handle) {\n\t\t\t\t\t\t\tidentity.value = handle;\n\t\t\t\t\t\t\tdropdown.style.display = 'none';\n\t\t\t\t\t\t\tidentity.setAttribute('aria-expanded', 'false');\n\t\t\t\t\t\t\tcurrentResults = [];\n\t\t\t\t\t\t\tactiveIndex = -1;\n\t\t\t\t\t\t\tresolve(handle).then(function() {\n\t\t\t\t\t\t\t\tif (didField.value) form.submit();\n\t\t\t\t\t\t\t});\n\t\t\t\t\t\t}\n\n\t\t\t\t\t\tfunction searchHandles(query) {\n\t\t\t\t\t\t\tif (abortCtrl) abortCtrl.abort();\n\t\t\t\t\t\t\tif (query.length < MIN_QUERY) return Promise.resolve([]);\n\t\t\t\t\t\t\tabortCtrl = new AbortController();\n\t\t\t\t\t\t\treturn fetch(SEARCH_API + '?q=' + encodeURIComponent(query) + '&limit=' + MAX_RESULTS, { signal: abortCtrl.signal })\n\t\t\t\t\t\t\t\t.then(function(r) { return r.ok ? r.json() : { actors: [] }; })\n\t\t\t\t\t\t\t\t.then(function(data) {\n\t\t\t\t\t\t\t\t\treturn (data.actors || []).map(function(a) {\n\t\t\t\t\t\t\t\t\t\treturn { handle: a.handle, displayName: a.displayName || a.handle, avatar: a.avatar || null };\n\t\t\t\t\t\t\t\t\t});\n\t\t\t\t\t\t\t\t})\n\t\t\t\t\t\t\t\t.catch(function() { return []; });\n\t\t\t\t\t\t}\n\n\t\t\t\t\t\tfunction debouncedSearch(query) {\n\t\t\t\t\t\t\tif (debounceTimer) clearTimeout(debounceTimer);\n\t\t\t\t\t\t\tif (query.length < MIN_QUERY) { renderSuggestions([]); return; }\n\t\t\t\t\t\t\tdebounceTimer = setTimeout(function() {\n\t\t\t\t\t\t\t\tsearchHandles(query).then(function(results) {\n\t\t\t\t\t\t\t\t\tcurrentResults = results;\n\t\t\t\t\t\t\t\t\tactiveIndex = -1;\n\t\t\t\t\t\t\t\t\trenderSuggestions(results);\n\t\t\t\t\t\t\t\t});\n\t\t\t\t\t\t\t}, DEBOUNCE_MS);\n\t\t\t\t\t\t}\n\n\t\t\t\t\t\tasync function resolve(raw) {\n\t\t\t\t\t\t\tvar v = (raw || '').replace(/^@/, '').trim();\n\t\t\t\t\t\t\tif (!v) { setHint('', ''); didField.value = ''; return; }\n\t\t\t\t\t\t\tif (isDID(v)) { setHint(v, 'is-ok'); didField.value = v; return; }\n\t\t\t\t\t\t\tsetHint('Resolving ' + v + '…', 'is-loading');\n\t\t\t\t\t\t\tdidField.value = '';\n\t\t\t\t\t\t\ttry {\n\t\t\t\t\t\t\t\tvar r = await fetch('/enroll/resolve?handle=' + encodeURIComponent(v), {\n\t\t\t\t\t\t\t\t\theaders: { Accept: 'application/json' },\n\t\t\t\t\t\t\t\t});\n\t\t\t\t\t\t\t\tif (!r.ok) {\n\t\t\t\t\t\t\t\t\tvar body = await r.json().catch(function() { return {error:'resolution failed'}; });\n\t\t\t\t\t\t\t\t\tsetHint(body.error || 'resolution failed', 'is-err');\n\t\t\t\t\t\t\t\t\treturn;\n\t\t\t\t\t\t\t\t}\n\t\t\t\t\t\t\t\tvar data = await r.json();\n\t\t\t\t\t\t\t\tif (data.did) {\n\t\t\t\t\t\t\t\t\tsetHint(data.did, 'is-ok');\n\t\t\t\t\t\t\t\t\tdidField.value = data.did;\n\t\t\t\t\t\t\t\t}\n\t\t\t\t\t\t\t} catch (e) {\n\t\t\t\t\t\t\t\tsetHint('Network error — try again or paste a DID directly', 'is-err');\n\t\t\t\t\t\t\t}\n\t\t\t\t\t\t}\n\n\t\t\t\t\t\tidentity.addEventListener('input', function() {\n\t\t\t\t\t\t\tvar q = identity.value.trim().replace(/^@/, '');\n\t\t\t\t\t\t\tif (didField.value) { didField.value = ''; setHint('', ''); }\n\t\t\t\t\t\t\tif (isDID(q)) {\n\t\t\t\t\t\t\t\trenderSuggestions([]);\n\t\t\t\t\t\t\t\tsetHint(q, 'is-ok');\n\t\t\t\t\t\t\t\tdidField.value = q;\n\t\t\t\t\t\t\t\treturn;\n\t\t\t\t\t\t\t}\n\t\t\t\t\t\t\tdebouncedSearch(q);\n\t\t\t\t\t\t});\n\n\t\t\t\t\t\tidentity.addEventListener('keydown', function(e) {\n\t\t\t\t\t\t\tif (!currentResults.length) return;\n\t\t\t\t\t\t\tif (e.key === 'ArrowDown') {\n\t\t\t\t\t\t\t\te.preventDefault();\n\t\t\t\t\t\t\t\tactiveIndex = Math.min(activeIndex + 1, currentResults.length - 1);\n\t\t\t\t\t\t\t\tupdateActive();\n\t\t\t\t\t\t\t} else if (e.key === 'ArrowUp') {\n\t\t\t\t\t\t\t\te.preventDefault();\n\t\t\t\t\t\t\t\tactiveIndex = Math.max(activeIndex - 1, 0);\n\t\t\t\t\t\t\t\tupdateActive();\n\t\t\t\t\t\t\t} else if (e.key === 'Enter' && activeIndex >= 0) {\n\t\t\t\t\t\t\t\te.preventDefault();\n\t\t\t\t\t\t\t\te.stopPropagation();\n\t\t\t\t\t\t\t\tselectHandle(currentResults[activeIndex].handle);\n\t\t\t\t\t\t\t} else if (e.key === 'Escape') {\n\t\t\t\t\t\t\t\tdropdown.style.display = 'none';\n\t\t\t\t\t\t\t\tidentity.setAttribute('aria-expanded', 'false');\n\t\t\t\t\t\t\t\tactiveIndex = -1;\n\t\t\t\t\t\t\t}\n\t\t\t\t\t\t});\n\n\t\t\t\t\t\tdropdown.addEventListener('mousedown', function(e) {\n\t\t\t\t\t\t\te.preventDefault();\n\t\t\t\t\t\t\tvar target = e.target.closest('.handle-suggestion');\n\t\t\t\t\t\t\tif (target) selectHandle(target.dataset.handle);\n\t\t\t\t\t\t});\n\n\t\t\t\t\t\tidentity.addEventListener('blur', function() {\n\t\t\t\t\t\t\tsetTimeout(function() {\n\t\t\t\t\t\t\t\tdropdown.style.display = 'none';\n\t\t\t\t\t\t\t\tidentity.setAttribute('aria-expanded', 'false');\n\t\t\t\t\t\t\t}, 150);\n\t\t\t\t\t\t\tif (!didField.value) resolve(identity.value);\n\t\t\t\t\t\t});\n\n\t\t\t\t\t\tform.addEventListener('submit', async function(ev) {\n\t\t\t\t\t\t\tif (didField.value) return;\n\t\t\t\t\t\t\tev.preventDefault();\n\t\t\t\t\t\t\tsubmit.disabled = true;\n\t\t\t\t\t\t\tsubmit.textContent = 'Resolving identity…';\n\t\t\t\t\t\t\tawait resolve(identity.value);\n\t\t\t\t\t\t\tsubmit.disabled = false;\n\t\t\t\t\t\t\tsubmit.textContent = submit.getAttribute('data-default-text') || 'Start enrollment';\n\t\t\t\t\t\t\tif (didField.value) form.submit();\n\t\t\t\t\t\t});\n\t\t\t\t\t})();\n\t\t\t\t</script></section>") 212 212 if templ_7745c5c3_Err != nil { 213 213 return templ_7745c5c3_Err 214 214 }
+8
internal/admin/ui/templates/member_detail.templ
··· 12 12 // domain in the admin UI so operators can see at a glance whether 13 13 // operator-to-member mail will reach anyone. 14 14 AllContactEmails []string 15 + AllEmailVerified []bool 15 16 Status string 16 17 SuspendReason string 17 18 SendCount int64 ··· 89 90 <code>{ d }</code> 90 91 if i < len(m.AllContactEmails) && m.AllContactEmails[i] != "" { 91 92 <small>{ " · " } <code>{ m.AllContactEmails[i] }</code></small> 93 + if i < len(m.AllEmailVerified) { 94 + if m.AllEmailVerified[i] { 95 + <span class="badge badge-active" style="margin-left: 0.5em; font-size: 0.75em;">verified</span> 96 + } else { 97 + <span class="badge badge-suspended" style="margin-left: 0.5em; font-size: 0.75em;">unverified</span> 98 + } 99 + } 92 100 } else { 93 101 <small style="font-style: italic;">{ " · no contact email" }</small> 94 102 }
+1 -1
internal/admin/ui/templates/member_detail_rich.go
··· 257 257 msg = "OK" 258 258 } 259 259 return fmt.Sprintf( 260 - `<article style="border-left: 4px solid %s; padding: 0.5rem 1rem;"><header style="color: %s; font-weight: 600;">%s %s</header><small>%s</small></article>`, 260 + `<article style="border: 1px solid %s; padding: 0.5rem 1rem;"><header style="color: %s; font-weight: 600;">%s %s</header><small>%s</small></article>`, 261 261 color, color, icon, html.EscapeString(c.Title), html.EscapeString(msg), 262 262 ) 263 263 }
+63 -49
internal/admin/ui/templates/member_detail_templ.go
··· 20 20 // domain in the admin UI so operators can see at a glance whether 21 21 // operator-to-member mail will reach anyone. 22 22 AllContactEmails []string 23 + AllEmailVerified []bool 23 24 Status string 24 25 SuspendReason string 25 26 SendCount int64 ··· 286 287 if templ_7745c5c3_Err != nil { 287 288 return templ_7745c5c3_Err 288 289 } 290 + if i < len(m.AllEmailVerified) { 291 + if m.AllEmailVerified[i] { 292 + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 18, "<span class=\"badge badge-active\" style=\"margin-left: 0.5em; font-size: 0.75em;\">verified</span>") 293 + if templ_7745c5c3_Err != nil { 294 + return templ_7745c5c3_Err 295 + } 296 + } else { 297 + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 19, "<span class=\"badge badge-suspended\" style=\"margin-left: 0.5em; font-size: 0.75em;\">unverified</span>") 298 + if templ_7745c5c3_Err != nil { 299 + return templ_7745c5c3_Err 300 + } 301 + } 302 + } 289 303 } else { 290 - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 18, "<small style=\"font-style: italic;\">") 304 + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 20, "<small style=\"font-style: italic;\">") 291 305 if templ_7745c5c3_Err != nil { 292 306 return templ_7745c5c3_Err 293 307 } ··· 300 314 if templ_7745c5c3_Err != nil { 301 315 return templ_7745c5c3_Err 302 316 } 303 - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 19, "</small>") 317 + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 21, "</small>") 304 318 if templ_7745c5c3_Err != nil { 305 319 return templ_7745c5c3_Err 306 320 } 307 321 } 308 - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 20, "</li>") 322 + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 22, "</li>") 309 323 if templ_7745c5c3_Err != nil { 310 324 return templ_7745c5c3_Err 311 325 } 312 326 } 313 - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 21, "</ul></article>") 327 + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 23, "</ul></article>") 314 328 if templ_7745c5c3_Err != nil { 315 329 return templ_7745c5c3_Err 316 330 } 317 331 } 318 - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 22, "<article><header>Labels</header>") 332 + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 24, "<article><header>Labels</header>") 319 333 if templ_7745c5c3_Err != nil { 320 334 return templ_7745c5c3_Err 321 335 } 322 336 if len(m.Labels) == 0 { 323 - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 23, "<p><em>No labels found (labeler may be unreachable)</em></p>") 337 + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 25, "<p><em>No labels found (labeler may be unreachable)</em></p>") 324 338 if templ_7745c5c3_Err != nil { 325 339 return templ_7745c5c3_Err 326 340 } 327 341 } else { 328 - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 24, "<div>") 342 + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 26, "<div>") 329 343 if templ_7745c5c3_Err != nil { 330 344 return templ_7745c5c3_Err 331 345 } ··· 334 348 if templ_7745c5c3_Err != nil { 335 349 return templ_7745c5c3_Err 336 350 } 337 - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 25, " ") 351 + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 27, " ") 338 352 if templ_7745c5c3_Err != nil { 339 353 return templ_7745c5c3_Err 340 354 } ··· 348 362 return templ_7745c5c3_Err 349 363 } 350 364 } 351 - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 26, "</div>") 365 + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 28, "</div>") 352 366 if templ_7745c5c3_Err != nil { 353 367 return templ_7745c5c3_Err 354 368 } 355 369 } 356 - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 27, "</article>") 370 + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 29, "</article>") 357 371 if templ_7745c5c3_Err != nil { 358 372 return templ_7745c5c3_Err 359 373 } ··· 365 379 if templ_7745c5c3_Err != nil { 366 380 return templ_7745c5c3_Err 367 381 } 368 - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 28, "</div>") 382 + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 30, "</div>") 369 383 if templ_7745c5c3_Err != nil { 370 384 return templ_7745c5c3_Err 371 385 } ··· 395 409 } 396 410 ctx = templ.ClearChildren(ctx) 397 411 if m.WarmupSeeds > 0 && m.Status == "active" { 398 - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 29, "<article id=\"warmup-section\"><header>IP Warmup</header><p>Send a batch of ") 412 + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 31, "<article id=\"warmup-section\"><header>IP Warmup</header><p>Send a batch of ") 399 413 if templ_7745c5c3_Err != nil { 400 414 return templ_7745c5c3_Err 401 415 } ··· 408 422 if templ_7745c5c3_Err != nil { 409 423 return templ_7745c5c3_Err 410 424 } 411 - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 30, " warmup emails from <code>") 425 + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 32, " warmup emails from <code>") 412 426 if templ_7745c5c3_Err != nil { 413 427 return templ_7745c5c3_Err 414 428 } ··· 421 435 if templ_7745c5c3_Err != nil { 422 436 return templ_7745c5c3_Err 423 437 } 424 - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 31, "</code> to seed addresses.</p><button hx-post=\"") 438 + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 33, "</code> to seed addresses.</p><button hx-post=\"") 425 439 if templ_7745c5c3_Err != nil { 426 440 return templ_7745c5c3_Err 427 441 } ··· 434 448 if templ_7745c5c3_Err != nil { 435 449 return templ_7745c5c3_Err 436 450 } 437 - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 32, "\" hx-target=\"#warmup-section\" hx-swap=\"innerHTML\" hx-confirm=\"") 451 + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 34, "\" hx-target=\"#warmup-section\" hx-swap=\"innerHTML\" hx-confirm=\"") 438 452 if templ_7745c5c3_Err != nil { 439 453 return templ_7745c5c3_Err 440 454 } ··· 447 461 if templ_7745c5c3_Err != nil { 448 462 return templ_7745c5c3_Err 449 463 } 450 - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 33, "\">Send warmup batch</button></article>") 464 + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 35, "\">Send warmup batch</button></article>") 451 465 if templ_7745c5c3_Err != nil { 452 466 return templ_7745c5c3_Err 453 467 } ··· 477 491 templ_7745c5c3_Var22 = templ.NopComponent 478 492 } 479 493 ctx = templ.ClearChildren(ctx) 480 - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 34, "<header>IP Warmup</header><p><strong>") 494 + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 36, "<header>IP Warmup</header><p><strong>") 481 495 if templ_7745c5c3_Err != nil { 482 496 return templ_7745c5c3_Err 483 497 } ··· 490 504 if templ_7745c5c3_Err != nil { 491 505 return templ_7745c5c3_Err 492 506 } 493 - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 35, "</strong> sent, <strong>") 507 + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 37, "</strong> sent, <strong>") 494 508 if templ_7745c5c3_Err != nil { 495 509 return templ_7745c5c3_Err 496 510 } ··· 503 517 if templ_7745c5c3_Err != nil { 504 518 return templ_7745c5c3_Err 505 519 } 506 - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 36, "</strong> failed</p>") 520 + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 38, "</strong> failed</p>") 507 521 if templ_7745c5c3_Err != nil { 508 522 return templ_7745c5c3_Err 509 523 } 510 524 if len(errors) > 0 { 511 - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 37, "<ul>") 525 + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 39, "<ul>") 512 526 if templ_7745c5c3_Err != nil { 513 527 return templ_7745c5c3_Err 514 528 } 515 529 for _, e := range errors { 516 - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 38, "<li><small>") 530 + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 40, "<li><small>") 517 531 if templ_7745c5c3_Err != nil { 518 532 return templ_7745c5c3_Err 519 533 } ··· 526 540 if templ_7745c5c3_Err != nil { 527 541 return templ_7745c5c3_Err 528 542 } 529 - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 39, "</small></li>") 543 + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 41, "</small></li>") 530 544 if templ_7745c5c3_Err != nil { 531 545 return templ_7745c5c3_Err 532 546 } 533 547 } 534 - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 40, "</ul>") 548 + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 42, "</ul>") 535 549 if templ_7745c5c3_Err != nil { 536 550 return templ_7745c5c3_Err 537 551 } ··· 564 578 templ_7745c5c3_Var26 = templ.NopComponent 565 579 } 566 580 ctx = templ.ClearChildren(ctx) 567 - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 41, "<article><header>Review history</header>") 581 + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 43, "<article><header>Review history</header>") 568 582 if templ_7745c5c3_Err != nil { 569 583 return templ_7745c5c3_Err 570 584 } 571 585 if len(entries) == 0 { 572 - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 42, "<p><em>No review actions recorded for this member.</em></p>") 586 + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 44, "<p><em>No review actions recorded for this member.</em></p>") 573 587 if templ_7745c5c3_Err != nil { 574 588 return templ_7745c5c3_Err 575 589 } 576 590 } else { 577 - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 43, "<ul>") 591 + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 45, "<ul>") 578 592 if templ_7745c5c3_Err != nil { 579 593 return templ_7745c5c3_Err 580 594 } 581 595 for _, e := range entries { 582 - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 44, "<li><strong>") 596 + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 46, "<li><strong>") 583 597 if templ_7745c5c3_Err != nil { 584 598 return templ_7745c5c3_Err 585 599 } ··· 592 606 if templ_7745c5c3_Err != nil { 593 607 return templ_7745c5c3_Err 594 608 } 595 - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 45, "</strong> ") 609 + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 47, "</strong> ") 596 610 if templ_7745c5c3_Err != nil { 597 611 return templ_7745c5c3_Err 598 612 } ··· 619 633 if templ_7745c5c3_Err != nil { 620 634 return templ_7745c5c3_Err 621 635 } 622 - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 46, " <code>") 636 + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 48, " <code>") 623 637 if templ_7745c5c3_Err != nil { 624 638 return templ_7745c5c3_Err 625 639 } ··· 632 646 if templ_7745c5c3_Err != nil { 633 647 return templ_7745c5c3_Err 634 648 } 635 - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 47, "</code> ") 649 + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 49, "</code> ") 636 650 if templ_7745c5c3_Err != nil { 637 651 return templ_7745c5c3_Err 638 652 } 639 653 } 640 654 if e.Note != "" { 641 - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 48, "<div><small>") 655 + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 50, "<div><small>") 642 656 if templ_7745c5c3_Err != nil { 643 657 return templ_7745c5c3_Err 644 658 } ··· 651 665 if templ_7745c5c3_Err != nil { 652 666 return templ_7745c5c3_Err 653 667 } 654 - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 49, "</small></div>") 668 + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 51, "</small></div>") 655 669 if templ_7745c5c3_Err != nil { 656 670 return templ_7745c5c3_Err 657 671 } 658 672 } 659 - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 50, "</li>") 673 + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 52, "</li>") 660 674 if templ_7745c5c3_Err != nil { 661 675 return templ_7745c5c3_Err 662 676 } 663 677 } 664 - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 51, "</ul>") 678 + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 53, "</ul>") 665 679 if templ_7745c5c3_Err != nil { 666 680 return templ_7745c5c3_Err 667 681 } 668 682 } 669 - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 52, "</article>") 683 + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 54, "</article>") 670 684 if templ_7745c5c3_Err != nil { 671 685 return templ_7745c5c3_Err 672 686 } ··· 696 710 } 697 711 ctx = templ.ClearChildren(ctx) 698 712 if action == "reactivated" { 699 - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 53, "<span class=\"badge badge-active\">reactivated</span>") 713 + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 55, "<span class=\"badge badge-active\">reactivated</span>") 700 714 if templ_7745c5c3_Err != nil { 701 715 return templ_7745c5c3_Err 702 716 } 703 717 } else if action == "keep_suspended" { 704 - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 54, "<span class=\"badge badge-suspended\">kept suspended</span>") 718 + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 56, "<span class=\"badge badge-suspended\">kept suspended</span>") 705 719 if templ_7745c5c3_Err != nil { 706 720 return templ_7745c5c3_Err 707 721 } 708 722 } else { 709 - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 55, "<span class=\"badge\">") 723 + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 57, "<span class=\"badge\">") 710 724 if templ_7745c5c3_Err != nil { 711 725 return templ_7745c5c3_Err 712 726 } ··· 719 733 if templ_7745c5c3_Err != nil { 720 734 return templ_7745c5c3_Err 721 735 } 722 - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 56, "</span>") 736 + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 58, "</span>") 723 737 if templ_7745c5c3_Err != nil { 724 738 return templ_7745c5c3_Err 725 739 } ··· 749 763 templ_7745c5c3_Var34 = templ.NopComponent 750 764 } 751 765 ctx = templ.ClearChildren(ctx) 752 - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 57, "<p>") 766 + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 59, "<p>") 753 767 if templ_7745c5c3_Err != nil { 754 768 return templ_7745c5c3_Err 755 769 } ··· 757 771 if templ_7745c5c3_Err != nil { 758 772 return templ_7745c5c3_Err 759 773 } 760 - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 58, "</p>") 774 + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 60, "</p>") 761 775 if templ_7745c5c3_Err != nil { 762 776 return templ_7745c5c3_Err 763 777 } 764 778 if m.SuspendReason != "" { 765 - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 59, "<p><small>Reason: ") 779 + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 61, "<p><small>Reason: ") 766 780 if templ_7745c5c3_Err != nil { 767 781 return templ_7745c5c3_Err 768 782 } ··· 775 789 if templ_7745c5c3_Err != nil { 776 790 return templ_7745c5c3_Err 777 791 } 778 - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 60, "</small></p>") 792 + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 62, "</small></p>") 779 793 if templ_7745c5c3_Err != nil { 780 794 return templ_7745c5c3_Err 781 795 } 782 796 } 783 - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 61, "<div role=\"group\">") 797 + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 63, "<div role=\"group\">") 784 798 if templ_7745c5c3_Err != nil { 785 799 return templ_7745c5c3_Err 786 800 } 787 801 if m.Status == "active" { 788 - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 62, "<button class=\"secondary\" hx-post=\"") 802 + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 64, "<button class=\"secondary\" hx-post=\"") 789 803 if templ_7745c5c3_Err != nil { 790 804 return templ_7745c5c3_Err 791 805 } ··· 798 812 if templ_7745c5c3_Err != nil { 799 813 return templ_7745c5c3_Err 800 814 } 801 - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 63, "\" hx-target=\"#member-status\" hx-swap=\"innerHTML\" hx-confirm=\"Suspend this member?\">Suspend</button>") 815 + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 65, "\" hx-target=\"#member-status\" hx-swap=\"innerHTML\" hx-confirm=\"Suspend this member?\">Suspend</button>") 802 816 if templ_7745c5c3_Err != nil { 803 817 return templ_7745c5c3_Err 804 818 } 805 819 } else { 806 - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 64, "<button hx-post=\"") 820 + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 66, "<button hx-post=\"") 807 821 if templ_7745c5c3_Err != nil { 808 822 return templ_7745c5c3_Err 809 823 } ··· 816 830 if templ_7745c5c3_Err != nil { 817 831 return templ_7745c5c3_Err 818 832 } 819 - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 65, "\" hx-target=\"#member-status\" hx-swap=\"innerHTML\" hx-confirm=\"Reactivate this member?\">Reactivate</button>") 833 + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 67, "\" hx-target=\"#member-status\" hx-swap=\"innerHTML\" hx-confirm=\"Reactivate this member?\">Reactivate</button>") 820 834 if templ_7745c5c3_Err != nil { 821 835 return templ_7745c5c3_Err 822 836 } 823 837 } 824 - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 66, "</div>") 838 + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 68, "</div>") 825 839 if templ_7745c5c3_Err != nil { 826 840 return templ_7745c5c3_Err 827 841 }
+22 -4
internal/admin/ui/templates/recover.go
··· 32 32 DID string 33 33 Domain string 34 34 DKIMSelector string // base selector; full names are <sel>r and <sel>e 35 - ContactEmail string // current value; may be empty 36 - ExpiresAt string // RFC3339 display for the session-expiry footer 35 + ContactEmail string // current value; may be empty 36 + EmailVerified bool 37 + ExpiresAt string // RFC3339 display for the session-expiry footer 37 38 38 39 // Message / MessageErr drive an optional banner rendered at the top 39 40 // of the page — populated after a contact-email update or any ··· 157 158 identity.setAttribute('aria-expanded', 'false'); 158 159 currentResults = []; 159 160 activeIndex = -1; 160 - resolve(handle); 161 + resolve(handle).then(function() { 162 + if (didField.value) form.submit(); 163 + }); 161 164 } 162 165 163 166 function searchHandles(query) { ··· 331 334 332 335 // Contact email 333 336 b.WriteString(`<section class="section">`) 334 - b.WriteString(`<h2>Contact email</h2>`) 337 + if d.ContactEmail != "" && d.EmailVerified { 338 + b.WriteString(`<div style="display: flex; align-items: baseline; gap: 0.75rem;">`) 339 + b.WriteString(`<h2 style="margin-bottom: 0;">Contact email</h2>`) 340 + b.WriteString(`<span style="font-size: var(--t-xs); font-weight: 700; text-transform: uppercase; letter-spacing: 0.12em; color: var(--muted);">Verified</span>`) 341 + b.WriteString(`</div>`) 342 + } else { 343 + b.WriteString(`<h2>Contact email</h2>`) 344 + } 335 345 b.WriteString(`<p class="section-lede">Where we send approval notices, key-rotation confirmations, and account notifications. Not displayed publicly.</p>`) 336 346 // Ticket travels in the HttpOnly cookie; the form carries 337 347 // only the user-editable contact_email field. ··· 340 350 html.EscapeString(d.ContactEmail)) 341 351 b.WriteString(`<button type="submit">Save contact email</button>`) 342 352 b.WriteString(`</form>`) 353 + if d.ContactEmail != "" && !d.EmailVerified { 354 + b.WriteString(`<div class="error-note" style="margin-top: 0.75rem; margin-bottom: 0;">`) 355 + b.WriteString(`<p style="font-size: var(--t-s); margin: 0 0 0.5rem; color: var(--ink);">This email hasn't been verified yet.</p>`) 356 + b.WriteString(`<form method="POST" action="/account/resend-verification" style="margin: 0;">`) 357 + b.WriteString(`<button type="submit" class="btn-secondary" style="margin-top: 0; font-size: var(--t-xs); padding: 0.4rem 1rem;">Resend verification email</button>`) 358 + b.WriteString(`</form>`) 359 + b.WriteString(`</div>`) 360 + } 343 361 b.WriteString(`</section>`) 344 362 345 363 // DKIM records
+1 -1
internal/admin/ui/templates/review_queue.templ
··· 110 110 // actions per card: keep suspended (records an annotation) or 111 111 // reactivate (flips status + records a note). 112 112 templ ReviewQueueCard(row ReviewQueueRow) { 113 - <article style="border-left: 4px solid var(--pico-del-color); padding: 1rem;"> 113 + <article style="border: 1px solid var(--pico-del-color); padding: 1rem;"> 114 114 <hgroup style="margin-bottom: 0.75rem;"> 115 115 <h3 style="margin-bottom: 0;">{ row.Domain }</h3> 116 116 <p style="margin: 0;"><code>{ row.DID }</code></p>
+1 -1
internal/admin/ui/templates/review_queue_templ.go
··· 302 302 templ_7745c5c3_Var11 = templ.NopComponent 303 303 } 304 304 ctx = templ.ClearChildren(ctx) 305 - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 22, "<article style=\"border-left: 4px solid var(--pico-del-color); padding: 1rem;\"><hgroup style=\"margin-bottom: 0.75rem;\"><h3 style=\"margin-bottom: 0;\">") 305 + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 22, "<article style=\"border: 1px solid var(--pico-del-color); padding: 1rem;\"><hgroup style=\"margin-bottom: 0.75rem;\"><h3 style=\"margin-bottom: 0;\">") 306 306 if templ_7745c5c3_Err != nil { 307 307 return templ_7745c5c3_Err 308 308 }
+17
internal/relay/didresolver.go
··· 14 14 // DIDDocument represents the relevant fields of an AT Protocol DID document. 15 15 type DIDDocument struct { 16 16 ID string `json:"id"` 17 + AlsoKnownAs []string `json:"alsoKnownAs"` 17 18 VerificationMethod []VerificationMethod `json:"verificationMethod"` 18 19 } 19 20 ··· 98 99 } 99 100 100 101 return &doc, nil 102 + } 103 + 104 + // ResolveHandleFromDID fetches the DID document and extracts the atproto 105 + // handle from the alsoKnownAs field. Returns the bare handle (no "at://" 106 + // prefix). Returns an error if no handle is found. 107 + func (r *DIDResolver) ResolveHandleFromDID(ctx context.Context, did string) (string, error) { 108 + doc, err := r.resolve(ctx, did) 109 + if err != nil { 110 + return "", err 111 + } 112 + for _, aka := range doc.AlsoKnownAs { 113 + if strings.HasPrefix(aka, "at://") { 114 + return strings.TrimPrefix(aka, "at://"), nil 115 + } 116 + } 117 + return "", fmt.Errorf("no at:// handle in alsoKnownAs for %s", did) 101 118 } 102 119 103 120 // didRegex matches valid atproto DIDs — did:plc: or did:web:.
+54
internal/relay/didresolver_test.go
··· 215 215 } 216 216 } 217 217 218 + func TestResolveHandleFromDID_Success(t *testing.T) { 219 + doc := DIDDocument{ 220 + ID: "did:plc:testuser123", 221 + AlsoKnownAs: []string{"at://alice.example.com"}, 222 + } 223 + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 224 + w.Header().Set("Content-Type", "application/json") 225 + json.NewEncoder(w).Encode(doc) 226 + })) 227 + defer srv.Close() 228 + 229 + resolver := NewDIDResolver(srv.Client(), srv.URL) 230 + handle, err := resolver.ResolveHandleFromDID(context.Background(), "did:plc:testuser123") 231 + if err != nil { 232 + t.Fatalf("ResolveHandleFromDID: %v", err) 233 + } 234 + if handle != "alice.example.com" { 235 + t.Errorf("handle = %q, want alice.example.com", handle) 236 + } 237 + } 238 + 239 + func TestResolveHandleFromDID_NoHandle(t *testing.T) { 240 + doc := DIDDocument{ 241 + ID: "did:plc:nohandle", 242 + AlsoKnownAs: []string{"https://example.com"}, 243 + } 244 + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 245 + w.Header().Set("Content-Type", "application/json") 246 + json.NewEncoder(w).Encode(doc) 247 + })) 248 + defer srv.Close() 249 + 250 + resolver := NewDIDResolver(srv.Client(), srv.URL) 251 + _, err := resolver.ResolveHandleFromDID(context.Background(), "did:plc:nohandle") 252 + if err == nil { 253 + t.Error("expected error when no at:// handle in alsoKnownAs") 254 + } 255 + } 256 + 257 + func TestResolveHandleFromDID_EmptyAlsoKnownAs(t *testing.T) { 258 + doc := DIDDocument{ID: "did:plc:empty"} 259 + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 260 + w.Header().Set("Content-Type", "application/json") 261 + json.NewEncoder(w).Encode(doc) 262 + })) 263 + defer srv.Close() 264 + 265 + resolver := NewDIDResolver(srv.Client(), srv.URL) 266 + _, err := resolver.ResolveHandleFromDID(context.Background(), "did:plc:empty") 267 + if err == nil { 268 + t.Error("expected error when alsoKnownAs is empty") 269 + } 270 + } 271 + 218 272 func TestResolveHandle_UnknownHandleFailsCleanly(t *testing.T) { 219 273 // A handle whose HTTPS well-known 404s AND whose DNS has no record. 220 274 // Uses the .invalid TLD (RFC 2606 reserved) so DNS resolution fails
+6 -8
internal/relay/metrics.go
··· 137 137 }, []string{"status"}), 138 138 OAuthCallbacks: prometheus.NewCounterVec(prometheus.CounterOpts{ 139 139 Name: "atmos_enroll_oauth_callbacks_total", 140 - Help: "OAuth callback completions, by result type.", 141 - }, []string{"type"}), 140 + Help: "OAuth callback completions, by result type and handle.", 141 + }, []string{"type", "handle"}), 142 142 } 143 143 144 144 reg.MustRegister( ··· 214 214 for _, step := range []string{"marketing", "landing", "auth_start", "enroll_start", "enroll_verify", "enroll_success", "attest_start", "attest_callback"} { 215 215 m.EnrollFunnel.WithLabelValues(step) 216 216 } 217 - for _, t := range []string{"enroll_auth", "recovery", "attestation", "error"} { 218 - m.OAuthCallbacks.WithLabelValues(t) 219 - } 217 + // OAuthCallbacks: handle label is dynamic, skip pre-initialization. 220 218 221 219 return m 222 220 } ··· 226 224 m.EnrollFunnel.WithLabelValues(step).Inc() 227 225 } 228 226 229 - // RecordOAuthCallback increments the OAuth callback counter for the given type. 230 - func (m *Metrics) RecordOAuthCallback(callbackType string) { 231 - m.OAuthCallbacks.WithLabelValues(callbackType).Inc() 227 + // RecordOAuthCallback increments the OAuth callback counter for the given type and handle. 228 + func (m *Metrics) RecordOAuthCallback(callbackType, handle string) { 229 + m.OAuthCallbacks.WithLabelValues(callbackType, handle).Inc() 232 230 } 233 231 234 232 // RecordInbound implements relay.InboundMetrics.