← All epicsBRD.md §9 · lines 6098–6364

Read-only per-epic slice. The canonical source of truth is BRD.md — stories are addressed by US-ID, not by this page's line numbers.

<!-- DERIVED — do not edit. Regenerate: `npx tsx tools/brd-epic-view/index.ts`. Source: BRD.md §9. -->

Epic 17 — Subscriber portal access & auth (derived view)

Read-only per-epic slice of BRD.md §9, lines 6098–6364. The canonical source of truth is BRD.md — edit there, never here. The stable address for a story is its US-ID (US-17.x), not a line number. Regenerates on every dev → main sync via derive-state-on-main.

  • Stories (5): US-17.1, US-17.2, US-17.3, US-17.4, US-17.5
  • Generated: 2026-07-01T17:48:39.076Z · as-of commit: b083f095

Epic 17 — Subscriber portal access & auth

<!-- traceability:start:BRD:Epic-17 -->

Prototype: Login · Sessions · Multi-Account · Recovery · Headless Portal SDK · Magic Link Login · Subscriptions List · Subscription Detail · Cancel Flow · Update Payment Method · Payment Method Updated — All Subs · Multi-Actor Subscription Detail · Delivery Schedule (cadence ≠ billing) · Pending start (deferred activation)

<!-- traceability:end:BRD:Epic-17 -->

Value: Subscribers can self-serve in a branded portal with magic-link or storefront-SSO auth — no separate password required.

US-17.1: Magic-link email auth

<!-- traceability:start:US-17.1 -->

Prototype: Login · Magic Link Login

<!-- traceability:end:US-17.1 -->

Phase: MVP · Priority: P0 · Effort: M · Persona: Subscriber

As a Subscriber, I want to log into the portal via an email magic link, so that I don't need a new password.

Acceptance criteria:

  • Given I enter my email on the portal login page, When I submit, Then a magic link is emailed.
  • Given I click the link within 15 minutes, When it resolves, Then I'm logged in with a session cookie scoped to the portal subdomain.
  • Given the link is expired or reused, When I click, Then I'm shown a "Request a new link" page.

UI states. (rendering contract — ui-states block convention, #1851)

<!-- ui-states US-17.1 -->
surface: "/account/login (Magic link tab, +page.svelte) + the SubscriberPortalApp.svelte sign-in gate (mounted at /subscriptions, /account/subscriptions, /account/addresses); SubscriberPortalApp.onMount also owns the ?token= verify + expired-link handling."
idle:
  render: "A single email input ('you@example.com') above an 'Email me a magic link' / 'Email me a sign-in link' CTA, with copy that no password is needed and the link expires in 15 minutes."
  primary_action: "Submit the email — POST /api/v1/portal/auth/request-link with {email, store_hash}."
loading:
  trigger: "POST /api/v1/portal/auth/request-link (requestingLink, or mlStatus='sending' on /account/login)."
  render: "The CTA label switches to 'Sending…' and both the button and the email input are disabled; no double-submit. (The follow-on ?token= verify on link-click has no dedicated spinner — see gap notes.)"
error:
  surfaced_at: "Inline, directly beneath the email form as role=alert (SubscriberPortalApp.svelte:316), scoped to the login surface — never a toast that vanishes."
  render: "The API failure reason (e.g. a send failure or rate-limit message), shown verbatim as the alert text."
  recovery: "Correct the email and resubmit from the same form; the CTA re-enables the moment the trimmed field is non-empty."
empty:
  render: "Not a list surface — the magic-link login is a single email form. The post-auth landing this story authenticates into (the SubscriberPortalApp subscriptions list) renders its own empty copy: 'No subscriptions yet. When you subscribe to a product, it'll show up here.' (SubscriberPortalApp.svelte:365-368)."
edge_status:
  - status: "Link requested — confirmation shown (linkSent, or mlStatus='sent')."
    badge: "link sent"
    affordance: "'Send to a different email' resets the form so the user can re-request against a corrected address (SubscriberPortalApp.svelte:279-288, login/+page.svelte:114-123)."
  - status: "Link expired or reused (AC3) — the ?token= verify call fails."
    badge: "expired"
    affordance: "Re-enter the email and request a fresh link from the same sign-in form; the verify error surfaces via role=alert. NORTH-STAR target: a dedicated 'Request a new link' state with the email pre-filled from session — not yet built (see gap notes)."
  - status: "Already signed in — a session token is present (sessionStorage, or initialAuthToken from BC SSO)."
    badge: "authenticated"
    affordance: "Skip the form entirely; land on the subscriptions list with a 'Sign out' control (SubscriberPortalApp.svelte:324-330)."
  - status: "Rate-limited — more than 5 magic-link requests for one email in an hour (BRD Non-functional)."
    badge: "rate limited"
    affordance: "The API error surfaces in the inline alert; wait out the rate-limit window, then resubmit — the form stays usable, never a dead-end."
inputs:
  - field: "email"
    control: "email"
    required: "true"
    note: "type=email + native required; the CTA stays disabled until the trimmed value is non-empty."
disabled_focus:
  keyboard: "The email <input> and the submit <button> are real form controls reached in tab order; submit also fires on Enter inside the <form>. The disabled CTA is a real <button> with the disabled attribute, never a div-onClick."
  focus_move: "On 'Link sent', the SubscriberPortalApp confirmation renders in place (role=status, aria-live=polite) and the 'Send to a different email' reset is a real <button> reachable by Tab; focus is never trapped."
  guard: "The CTA is disabled while a request is in flight and until the email field is non-empty (requestingLink || !email.trim()), preventing empty and double submits; the browser additionally enforces type=email validity."

UX notes.

  • Login surface: single email input, "Send me a login link" CTA
  • Sent confirmation: "Check your email — the link expires in 15 minutes"
  • Link target: /portal/auth/verify?token=X → sets session, redirects to subscriptions list
  • Expired: "Link expired, request a new one" with pre-filled email if still in session

Data contract.

  • Our API: POST /api/v1/portal/auth/request-link with {email, store_hash}
  • Token: generated with crypto.randomBytes(32), hashed stored in Postgres magic_links table with expires_at = now + 15min, used_at = null, email, store_hash
  • Email: Resend API with the token in the link URL
  • On click: verify hash, check not-used + not-expired, mark used, mint session JWT, set cookie
  • Session: 30-day rolling, SameSite=lax, portal-subdomain-scoped

Success metrics.

  • Functional (target): ≥ 99% of sent magic links arrive within 60s
  • Product (target): ≥ 75% of login attempts convert to authenticated sessions within 10 min
  • Operational: zero reuse of magic links (DB constraint)

Dependencies.

  • Resend API; fallback to SES in case of outage (P2)

Non-functional.

  • Rate limit: 5 magic-link requests per email per hour
  • Bot protection: hCaptcha on the login form (P2)

US-17.2: BC storefront SSO

<!-- traceability:start:US-17.2 -->

Prototype: Sessions

<!-- traceability:end:US-17.2 -->

Phase: P2 · Persona: Subscriber

As a Subscriber already logged into the merchant's BC storefront, I want to click into the subscription portal without re-authenticating, so that I stay in-flow.

Acceptance criteria:

  • Given I am logged into the BC storefront and click "Manage subscriptions," When I land on our portal, Then the BC customer JWT is exchanged for a portal session without an additional login prompt.

UI states.

<!-- ui-states US-17.2 -->
surface: "Transparent BC storefront SSO — apps/storefront-svelte/src/routes/account/subscriptions/+page.server.ts exchanges a BC-logged-in customer session for a portal session via apps/storefront-svelte/src/lib/server/sso-handoff.ts (trySsoExchange, line 117), so SubscriberPortalApp lands authenticated with no magic-link prompt. Persona: Subscriber."
idle:
  render: "A BC-logged-in subscriber navigating to /account/subscriptions is silently authenticated: the server mints a 60s handoff JWT (mintHandoffJwt, line 59), exchanges it at /api/v1/portal/auth/exchange-bc-session (exchangeHandoffForPortalSession, line 84), and SubscriberPortalApp renders the subscription list directly — the magic-link email form (the US-17.1 fallback) is skipped."
  primary_action: "None required from the user — SSO is transparent. On exchange failure the user falls back to the magic-link form."
loading:
  trigger: "trySsoExchange runs server-side in the page load (Promise.all, +page.server.ts lines 34-37) before render; then SubscriberPortalApp.loadSubscriptions() runs client-side once authToken is set (lines 117-123)."
  render: "No SSO-specific spinner — the exchange completes during SSR. Once mounted, the list shows the two-card skeleton while subscriptions load (lines 356-364)."
error:
  surfaced_at: "SSO failure is INVISIBLE by design — exchangeHandoffForPortalSession returns null on any non-200/throw (sso-handoff.ts lines 88, 96, 105) and the page renders the magic-link entry instead (no error banner). A downstream auth failure surfaces inline as role=alert in the portal list card (SubscriberPortalApp lines 586-600)."
  render: "On a graceful SSO miss: the magic-link sign-in form (no error text). On a real token failure: the getSubscriptions error message + a 'Sign in again' affordance."
  recovery: "SSO miss → request a magic link from the same surface (US-17.1). Token error → 'Sign in again' (handleSignOut) returns to the magic-link form."
empty:
  render: "Not a list surface — SSO is an auth handoff. The authenticated landing renders the list own empty copy ('No subscriptions yet…', SubscriberPortalApp lines 365-368)."
edge_status:
  - status: "BC customer logged in + SSO configured — successful exchange"
    affordance: "Contract north-star: land authenticated on the subscription list, no prompt. SEE gaps + defects — today the page passes the whole SsoExchangeResult object (not its sessionToken string) as initialAuthToken, so the bearer header becomes 'Bearer [object Object]' and the authenticated path actually fails to the error state."
  - status: "SSO not configured / exchange fails (SSO_HANDOFF_SECRET unset, API unreachable, 401)"
    affordance: "Graceful fallback to the magic-link request form (US-17.1) — never a dead-end."
  - status: "Public demo visitor (bc_subs_demo_session cookie)"
    affordance: "The pre-minted demo session string is used directly as initialAuthToken (+page.server.ts lines 29-32) — this path works because it returns a STRING token."
inputs: []
disabled_focus:
  keyboard: "SSO requires no interactive control on the happy path (transparent). The fallback magic-link form and the 'Sign in again' recovery are native focusable button/input elements reached in tab order (SubscriberPortalApp lines 297-311, 591)."
gaps: "BUILT but with a high-severity wiring defect. The SSO machinery is complete (mint handoff JWT, exchange endpoint call, optional B2B context). BUG: +page.server.ts returns initialAuthToken = trySsoExchange(...) which is an SsoExchangeResult OBJECT { sessionToken, b2bCompanyName, … } (sso-handoff.ts lines 38-44), not the string token. +page.svelte (line 60) passes that object to SubscriberPortalApp.initialAuthToken and to createApiClient.authToken (line 17); authHeaders() then emits Bearer ${authToken} = 'Bearer [object Object]' (api-client.ts line 241), which the API rejects. So a real BC-logged-in subscriber hits the unauthorized error + 'Sign in again' instead of a transparent landing. The demo path is unaffected (it returns a string), which masks the bug. One-line fix: initialAuthToken: ssoResult?.sessionToken ?? null (and forward the b2b fields)."

US-17.3: Portal custom domain

<!-- traceability:start:US-17.3 -->

Prototype: Multi-Account

<!-- traceability:end:US-17.3 -->

Phase: P3 · Persona: Merchant Admin

As a Merchant Admin, I want the subscriber portal served from a custom subdomain (e.g., subscribe.mystore.com), so that the experience is fully branded.

Acceptance criteria:

  • Given I add a CNAME pointing to our portal hostname, When I verify it, Then the portal serves on my custom domain with a provisioned TLS cert.

UI states.

<!-- ui-states US-17.3 -->
surface: "NOT YET BUILT — forward-looking contract. Merchant Admin (React/BigDesign) — Portal settings page, 'Custom domain' section; Phase 3 feature for serving the subscriber portal on a merchant-owned subdomain (e.g. subscribe.mystore.com) with CNAME verification and automatic TLS provisioning. Persona: Merchant Admin."
idle:
  render: "A 'Custom portal domain' BigDesign Panel shows: the current default portal URL as read-only informational text (e.g. subscriptions.bc-subs.app/stores/[store_hash]), a labeled BigDesign Input for the merchant's chosen custom subdomain (placeholder 'subscribe.mystore.com'), step-by-step DNS instructions ('Point a CNAME for your subdomain to [our hostname] in your DNS provider, then click Verify'), and a 'Verify CNAME' primary Button. Before any custom domain is configured the Panel shows the default URL and the ready-to-accept Input."
  primary_action: "Verify CNAME → POST /api/v1/admin/settings/custom-domain/verify with {domain}; on success TLS provisioning begins automatically."
loading:
  trigger: "POST /api/v1/admin/settings/custom-domain/verify while the verification request is in-flight."
  render: "The 'Verify CNAME' Button shows 'Verifying…' with BigDesign isLoading; the domain Input is read-only; the Button is disabled — no double-submit during the DNS lookup."
error:
  surfaced_at: "Inline within the 'Custom domain' Panel as a BigDesign Message(type='error') below the domain Input, scoped to the verification attempt — never a toast that vanishes."
  render: "The DNS or TLS failure reason (e.g. 'CNAME not found — DNS may still be propagating (typically 15–60 min). Check your DNS provider and try again.' or 'TLS certificate provisioning failed — ensure the CNAME target is [our hostname] and retry.')."
  recovery: "The domain Input and 'Verify CNAME' Button re-enable; the merchant can correct the DNS record in their provider, wait for propagation, and click 'Verify CNAME' again. A 'Why isn't this working?' help link opens DNS troubleshooting documentation."
empty:
  render: "Before any custom domain is saved the Panel shows the default portal URL in a read-only 'Current portal URL' field and the prompt 'Add a custom domain to fully brand the subscriber experience.' The verify Input and Button are present and ready to accept a subdomain."
  cta: "Add a custom domain — enter a subdomain and click 'Verify CNAME' to begin."
edge_status:
  - status: "verification_pending — DNS not yet propagated"
    badge: "Pending verification"
    affordance: "Panel shows 'CNAME submitted — DNS propagation typically takes 15–60 minutes. Return and click Re-verify once propagation is complete.' A 'Re-verify' Button is shown; the domain Input is read-only until re-verification is attempted."
  - status: "verified — TLS provisioning in progress"
    badge: "Provisioning TLS"
    affordance: "Panel shows 'Custom domain verified. TLS certificate is being provisioned (usually 1–5 minutes).' A 'Refresh status' Button re-polls the provisioning state; no merchant action is required."
  - status: "active — custom domain live and serving"
    badge: "Active"
    affordance: "Panel shows the custom domain as a live <a> link (opens the portal in a new tab); a 'Remove domain' destructive Button is shown. Clicking 'Remove domain' triggers a BigDesign Modal confirmation before reverting to the default URL and deprovisioning the TLS cert."
  - status: "CNAME misconfigured — pointing to wrong target"
    badge: "Misconfigured"
    affordance: "BigDesign Message(type='error'): 'Your CNAME for [domain] does not point at [our hostname]. Update your DNS record to target [our hostname], then click Re-verify.' The domain Input is editable."
inputs:
  - field: "custom_domain"
    control: "text"
    label: "Custom domain"
    validation: "valid subdomain or hostname (e.g. subscribe.mystore.com); free-form DNS hostname, not an enumerable set"
disabled_focus:
  keyboard: "The domain BigDesign Input, 'Verify CNAME' Button, 'Re-verify' Button (when shown), 'Refresh status' Button (when shown), 'Remove domain' Button (when active), and the live domain <a> link are all real focusable elements in tab order — never div-onClick. The domain Input is read-only (not removed from tab order) during in-flight verification."
  focus_move: "On 'Remove domain' click, a BigDesign Modal opens with 'Confirm removal' and 'Cancel' buttons; focus moves into the Modal on open and returns to the 'Remove domain' Button on cancel. On successful domain removal focus returns to the verify Input."
  guard: "'Remove domain' is destructive (reverts to default URL, deprovisions TLS) and requires BigDesign Modal confirmation before executing — no single-click removal."

US-17.4: Portal theming

<!-- traceability:start:US-17.4 -->

Prototype: Recovery

<!-- traceability:end:US-17.4 -->

Phase: MVP · Persona: Merchant Admin

As a Merchant Admin, I want the portal colors, typography, and logo to match my brand, so that subscribers feel they're still on my store.

Acceptance criteria:

  • Given I upload logo + pick colors + pick a Google Font in admin, When a subscriber loads the portal, Then those values apply via CSS variables.

UI states.

<!-- ui-states US-17.4 -->
surface: "NOT YET BUILT — forward-looking contract. Merchant Admin (React/BigDesign) — Portal settings page, 'Portal appearance' (branding and theming) panel; allows merchants to upload a logo, choose a brand color, and select a Google Font, applied via CSS variables to the subscriber portal. Persona: Merchant Admin."
idle:
  render: "A 'Portal appearance' BigDesign Panel with three BigDesign FormGroups: 'Logo' (a file upload control; accepted formats PNG, SVG, JPEG; max 1 MB; shows a thumbnail of the currently saved logo or a placeholder 'No logo uploaded'), 'Brand color' (a hex color BigDesign Input with an inline color swatch preview updating on blur), and 'Google Font' (a BigDesign Select of supported fonts with a live preview line 'The quick brown fox' rendered in the selected typeface). A 'Preview portal' link opens the subscriber portal in a new tab with pending unsaved styles applied. A 'Save appearance' primary Button and a 'Reset to defaults' secondary link are shown."
  primary_action: "Save appearance → PATCH /api/v1/admin/settings/portal-theme with {logo_url, brand_color, google_font}; on success the subscriber portal immediately reflects the updated CSS variables."
loading:
  trigger: "PATCH /api/v1/admin/settings/portal-theme while the save request is in-flight."
  render: "'Save appearance' Button shows 'Saving…' with BigDesign isLoading; all three form controls (logo upload, color Input, font Select) are disabled; no double-submit."
error:
  surfaced_at: "Inline within the 'Portal appearance' Panel as a BigDesign Message(type='error') below the form controls and above the 'Save appearance' Button — never a toast; scoped to the save attempt."
  render: "The save or upload failure reason (e.g. 'Logo upload failed: file exceeds 1 MB' or 'Invalid hex color — enter a 6-digit hex value (e.g. #3B82F6)')."
  recovery: "The BigDesign Message persists; all form controls re-enable; the merchant corrects the offending field (reduces logo file size, enters a valid hex color, or re-selects a font) and clicks 'Save appearance' again."
empty:
  render: "Before any branding is configured, the Panel shows the system defaults: a placeholder logo area reading 'No logo uploaded', brand color defaulting to the platform default, and Google Font defaulting to 'Inter'. All controls are functional and ready for input — the Panel is never blank."
  cta: "Upload a logo and pick brand colors to match your store — click 'Save appearance' to apply."
edge_status:
  - status: "logo file too large (>1 MB) or unsupported format"
    badge: "Upload error"
    affordance: "Inline validation beneath the logo upload control: 'File must be a PNG, SVG, or JPEG under 1 MB. Resize the image and upload again.' The file Input re-enables immediately so the merchant can choose a different file."
  - status: "invalid hex color entered"
    affordance: "Inline validation beneath the color Input: 'Enter a valid 6-digit hex color (e.g. #3B82F6).' The 'Save appearance' Button remains disabled until the color field passes client-side validation."
  - status: "Google Font CDN unavailable on the subscriber portal (CSP or network block)"
    affordance: "A BigDesign Message(type='warning') beneath the font Select: 'Custom fonts require the subscriber portal to reach fonts.googleapis.com. If your portal uses a restrictive Content Security Policy, ensure this domain is allowed — the portal falls back to the system font if the CDN is unreachable.'"
  - status: "unsaved changes (merchant has edited but not saved)"
    badge: "Unsaved"
    affordance: "The 'Save appearance' Button is enabled; a BigDesign Text secondary indicator 'Unsaved changes' appears beside it. Navigating away from the page triggers a browser-native 'Leave page? Changes you made may not be saved.' prompt."
inputs:
  - field: "logo"
    control: "file"
    label: "Logo"
    allowed_values: "image/png | image/svg+xml | image/jpeg; max 1 MB"
  - field: "brand_color"
    control: "text"
    label: "Brand color (hex)"
    validation: "6-digit hex color string beginning with # (e.g. #3B82F6)"
  - field: "google_font"
    control: "select"
    label: "Google Font"
    allowed_values: "Inter | DM Sans | Poppins | Raleway | Playfair Display | Merriweather | Lato | Nunito | Source Sans 3 | (full list defined by platform configuration)"
disabled_focus:
  keyboard: "The logo file Input is triggered via its associated <label> — never a div-onClick. The color hex BigDesign Input, font BigDesign Select, 'Save appearance' Button, 'Reset to defaults' link, and 'Preview portal' link are all real focusable elements in tab order. Tab order: logo upload → color input → font select → 'Save appearance' → 'Reset to defaults' → 'Preview portal'. All controls are disabled (removed from tab order) while the save is in-flight."
  focus_move: "On successful save a BigDesign Message(type='success') 'Portal appearance updated' renders and is announced via aria-live=polite so the merchant receives an accessible confirmation. On error the Message is announced via aria-live=assertive."

US-17.5: Headless portal SDK

<!-- traceability:start:US-17.5 -->

Prototype: Headless Portal SDK

<!-- traceability:end:US-17.5 -->

Phase: MVP · Priority: P0 · Effort: L · Persona: Developer

As a Developer on a headless store, I want an SDK so I can build the portal UI in my own stack, so that I achieve full brand control.

Acceptance criteria:

  • Given the headless SDK is installed, When I call getSubscriptions(customerToken), Then I get a typed list.
  • Given I call performAction(subscriptionId, 'pause', params), When it resolves, Then the action is applied server-side and the updated subscription is returned.

Data contract additions.

  • Hooks: useMySubscriptions(), useSkipNextCharge(subId), useSwapVariant(subId, newVariantId), usePauseSubscription(subId, { until }), useReschedule(subId, { date }), useCancelFlow(subId) (returns a stateful flow object)
  • All require authenticated subscriber token in createClient({ token })
  • (See US-8.3 for shared SDK architecture; this story focuses on subscriber-scoped APIs vs. storefront-browsing APIs in US-8.3.)