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.
Epic 1 — Merchant install & BC app authentication (derived view)
Read-only per-epic slice of
BRD.md§9, lines 212–580. The canonical source of truth isBRD.md— edit there, never here. The stable address for a story is its US-ID (US-1.x), not a line number. Regenerates on everydev → mainsync viaderive-state-on-main.
- Stories (7): US-1.1, US-1.2, US-1.3, US-1.4, US-1.5, US-1.6, US-1.7
- Generated: 2026-07-01T17:48:39.076Z · as-of commit:
b083f095
Epic 1 — Merchant install & BC app authentication
<!-- traceability:start:BRD:Epic-1 --><!-- traceability:end:BRD:Epic-1 -->Prototype: OAuth Handshake · Welcome · Setup Checklist · Registration Health · Uninstall Preview · Setup Error
Value: Merchant can install the app from the BC App Marketplace and load it inside the BC control panel in under 2 minutes.
Epic context. The install flow is the first impression and the only irreversible step: a failure here leaves orphaned webhook registrations, unbound app extensions, and the merchant confused. Every story in this epic must assume transient BC API failures and retry gracefully. Full install + first load must complete in ≤ 10 seconds end-to-end (P95).
US-1.1: OAuth install handshake
<!-- traceability:start:US-1.1 --><!-- traceability:end:US-1.1 -->Prototype: OAuth Handshake
Phase: MVP · Priority: P0 · Effort: M · Persona: Merchant Admin
As a Merchant Admin, I want to install the subscription app from the BC Marketplace with a single click, so that I can start configuring subscriptions without manual API setup.
Acceptance criteria:
- Given I click "Install" from the BC Marketplace, When BC redirects to our
/api/authwithcode,scope,context, Then we exchange the code for an access token and persist encrypted credentials. - Given the install succeeds, When I return to the BC control panel, Then I see the app listed under "My Apps" with the correct label and icon.
- Given the install fails mid-flight, When the handshake errors out, Then the merchant sees a descriptive message and the BC side does not show the app as installed.
UX notes.
- Surface: no user-visible UI during handshake — merchant lands on our
/api/authendpoint and is redirected either to the first-run setup page or an error page. - Success path: redirect to
/stores/[storeHash]/setupwith a "Welcome" toast. - Error states: (a) OAuth code exchange 4xx/5xx → generic "Install failed, please retry" page with support link; (b) code already used (merchant hit refresh) → detect and redirect to setup page without error; (c) BC API 5xx during downstream setup → queue retry, show "Setting things up…" page with polling.
- Accessibility: error pages must have live-region announcements of state changes.
Data contract.
- Inputs (GET query):
code,scope,context - BC API calls: POST
https://login.bigcommerce.com/oauth2/token(code exchange) - Our DB writes:
storesrow (encrypted access token, scope, store_hash from context),eventsrow (store.installed) - Events emitted:
store.installed - Secrets:
BIGCOMMERCE_CLIENT_SECRET,CREDENTIAL_ENCRYPTION_KEY
Success metrics.
- Functional: 100% of successful OAuth callbacks result in a
storesrow and a redirect to setup - Product (target): install success rate ≥ 99% (by install-initiated / install-completed ratio)
- Operational (target): P95 latency of handshake ≤ 3s; error rate < 0.5%
Dependencies.
- Upstream: none
- Downstream: US-1.3 (App Extensions — triggered on successful install), US-1.4 (webhook registration), US-2.3 (processor compat detection at first load)
Non-functional.
- Idempotency: exchanging the same
codetwice must not create duplicatestoresrows (check for existing before insert) - Encryption: access token encrypted at rest with AES-256-GCM using per-store nonce
- Rate: expect burst installs from marketing campaigns — handshake must scale horizontally
Risks / open questions.
- What if BC deprecates the OAuth v2 endpoint? Track BC platform changelog.
- Do we need to support installation from within BC control panel (no Marketplace click path)? Yes — the
signed_payload_jwtflow handles that case (US-1.2).
UI states.
<!-- ui-states US-1.1 -->surface: "Admin (React/BigDesign) post-OAuth onboarding. TWO panels: (1) Welcome hero card — apps/admin/src/pages/onboarding/Welcome.tsx, routed at /v2/ and /v2/home via the `Home` re-export in apps/admin/src/routes/v2/home/index.tsx (line 21); runs a first-visit gate then renders a success hero + next-step cards. (2) SetupError recovery panel — apps/admin/src/pages/onboarding/SetupError.tsx — the install-failure landing for extension-scope-missing / webhook-5xx / scope-drift / duplicate-install. Persona: Merchant Admin. NOTE: SetupError is NOT in the App.tsx route table (only onboarding/setup + onboarding/health are routed, lines 133-134) so the error surface is unreachable in the SPA — see gaps."
idle:
render: "Welcome: once the first-visit gate resolves (gateChecked=true, lines 124/149) a HeroCard with a success CheckCircleIcon, a 'you are set up' title/subtitle, and a single primary 'Open checklist' Button (line 166), plus a Panel of numbered next-step cards (NEXT_STEPS create_first_plan + storefront_activation, line 54). SetupError (north-star landing): an ErrorHero (role=alert, line 79) with an ErrorIcon, the scenario title + error-code chip, a 'What is next' Panel with recovery copy, and a diagnostic Panel."
primary_action: "Welcome 'Open checklist' navigates to /v2/onboarding/setup (lines 166/132). SetupError: for retriable scenarios a primary 'Open registration health' Button navigates to /v2/onboarding/health (line 108); a secondary 'Back to Welcome' Button navigates to / (line 112)."
loading:
trigger: "Welcome on mount calls getOnboardingChecklist() (line 128) to evaluate shouldRedirectToOnboarding (line 114)."
render: "While the gate probe is in flight gateChecked stays false and the component renders nothing — `if (!gateChecked) return null` (line 149) — deliberately, to avoid a Welcome to setup flicker. No spinner. SetupError has no async load (the scenario is local useState, line 43)."
error:
surfaced_at: "Welcome: the gate probe is best-effort — on failure the .catch sets gateChecked=true and renders Welcome anyway (lines 137-141), so a probe error never blocks the merchant (no banner; the redirect simply does not fire). SetupError IS the error surface for install failures: the failure is shown inline in the ErrorHero (role=alert, aria-live=polite, line 79) with the scenario body + a monospace error code (SCENARIO_DETAIL, lines 33-38)."
recovery: "Welcome probe failure: the merchant lands on Welcome and proceeds via the hero CTA (no retry needed). SetupError: retriable scenarios (extension_scope_missing, webhook_register_5xx) expose 'Open registration health' to /v2/onboarding/health to re-register (line 108); non-retriable scenarios (scope_drift, duplicate_install) expose only 'Back to Welcome' (line 112). REALITY GAP: because SetupError is route-orphaned a real install failure cannot route here — see gaps."
empty:
render: "Not a collection surface — both panels are single hero/recovery cards. Welcome's next-step Panel always renders the fixed NEXT_STEPS array (line 54), never empty; SetupError always renders one of four fixed scenarios. The subscriptions-list empty state is owned elsewhere (US-17.1)."
edge_status:
- status: "install partial-failure: extension_scope_missing (retriable)"
affordance: "ErrorHero explains the missing scope; primary 'Open registration health' to /v2/onboarding/health re-registers extensions (line 108)."
- status: "install partial-failure: webhook_register_5xx (retriable)"
affordance: "ErrorHero explains the webhook failure; 'Open registration health' to /v2/onboarding/health re-subscribes failed webhooks (line 108)."
- status: "install failure: scope_drift (not retriable)"
affordance: "ErrorHero explains the drift; no health CTA (detail.retriable=false, line 107) — 'Back to Welcome' is the only path; north-star is a re-consent/reinstall link (not built)."
- status: "install failure: duplicate_install (not retriable)"
affordance: "ErrorHero explains the duplicate; 'Back to Welcome' returns to the app root (line 112)."
inputs: []
disabled_focus:
keyboard: "Welcome's hero CTA and next-step CTAs are real BigDesign <Button>s reachable in Tab order (lines 166, 203); during the gate the page renders nothing so there is no focus trap. SetupError's scenario <Select> (line 63), the retriable primary Button (line 108) and the back Button (line 112) are real focusable BigDesign controls in DOM order; the ErrorHero carries role=alert + aria-live=polite (line 79) so its content is announced when rendered."
gaps: "SetupError's ErrorHero announces via aria-live, but Welcome's next-step Panel has no focus management on the gate-to-render transition; acceptable since the gate renders synchronously."
gaps: "SetupError.tsx is route-orphaned — App.tsx registers only onboarding/setup and onboarding/health (lines 133-134), so the documented install-failure landing is unreachable from the SPA; a real OAuth-install error has no in-app error UI to land on. Worse, SetupError is a prototype port driven by a manual `scenario` useState + <Select> picker (lines 43, 63-73), NOT wired to real install-failure data — even if routed it could not receive an actual error code. The contract north-star (merchant lands on SetupError on a real failure with the failing scope/webhook pre-selected) requires BOTH a route registration AND wiring the scenario from server-provided install-failure state."
US-1.2: Signed payload JWT verification on app load
<!-- traceability:start:US-1.2 --><!-- traceability:end:US-1.2 -->Prototype: Welcome
Phase: MVP · Priority: P0 · Effort: S · Persona: Merchant Admin
As a Merchant Admin, I want to open the app from within BC and be authenticated automatically, so that I don't enter credentials a second time.
Acceptance criteria:
- Given I click the app icon in BC control panel, When BC redirects to
/api/load?signed_payload_jwt=..., Then we verify the JWT againstBIGCOMMERCE_CLIENT_SECRETand mint an internal session cookie. - Given the JWT is malformed or signed with a wrong secret, When verification fails, Then we return 401 and do not set a cookie.
- Given a valid load, When the cookie is set, Then it is
httpOnly,Secure,SameSite=none,Partitioned, scoped to/stores/{storeHash}.
UX notes.
- Surface: this is a middleware redirect; the user sees a flash of loading state at most.
- Loading state: branded "Loading your subscriptions…" skeleton — do not show BC's generic loading.
- Error state: 401 error page styled to match BC's admin shell so it doesn't look like a crash.
Data contract.
- Input: GET
/api/load?signed_payload_jwt=<JWT>+ query (channel_id,urlfor extension loads) - JWT verification: HS256 against
BIGCOMMERCE_CLIENT_SECRET - Extract:
sub(→stores/{hash}),user.{id,email},owner.{id,email},channel_id,url - Our DB: no writes on load (stateless auth)
- Session: mint internal JWT with
{userId, email, storeHash, channelId}, sign withJWT_KEY, 24h expiry - Cookie:
session-token,httpOnly,Secure,SameSite=none,Partitioned, path=/stores/{hash} - Redirect: to
/stores/{hash}/or extension-specific path if JWTurlfield contains an extension path
Success metrics.
- Functional: every valid
signed_payload_jwtresults in authenticated session - Operational (target): P95 JWT verification < 50ms; 401 rate < 0.1% (mostly merchants with stale clipboard-copied URLs)
Dependencies.
- Upstream:
BIGCOMMERCE_CLIENT_SECRETconfigured - Downstream: every authenticated admin route (all of Epic 21, 22, etc.)
Non-functional.
Partitionedcookie is required for iframe context (Chrome 3rd-party cookie restrictions)- Clock skew: accept JWTs issued ≤ 5 minutes in the future to tolerate BC/our clock drift
Risks / open questions.
- If browsers further restrict partitioned cookies, fall back to
postMessage-based session handoff.
UI states.
<!-- ui-states US-1.2 -->surface: 'NOT YET BUILT — forward-looking contract. Admin app-load gate — branded loading skeleton and 401 error page rendered
before the main React/BigDesign shell mounts. Persona: Merchant Admin.'
idle:
render: No true idle — GET /api/load always transitions immediately to the loading state on entry from the BC control-panel
app tile.
primary_action: n/a (no interactive controls in idle; the route is a one-shot redirect gate)
loading:
trigger: GET /api/load?signed_payload_jwt=<JWT> received; JWT is being verified against BIGCOMMERCE_CLIENT_SECRET.
render: 'Full-viewport branded skeleton: BC-blue header bar + ''Loading your subscriptions…'' centered headline + pulsing
skeleton rows beneath — matches BC admin shell chrome so the transition feels native, not a flash of an unrelated app.'
error:
surfaced_at: Full-page 401 error view styled to match BC admin shell — headline 'Unable to authenticate', BC-blue header
intact so it does not look like a crash. No toast (redirect context means no prior page to anchor one to).
recovery: A 'Return to BigCommerce' <a> link (href to BC control panel root) as the single primary affordance. Error copy
explains in one sentence that re-entry via the app tile generates a fresh signed payload.
empty:
render: 'Not a list surface — no empty state applies here. Documented so the state is considered: if the JWT is valid but
scopes to a store with zero subscriptions, the route redirects normally and the main admin shell owns the empty-subscriptions
state.'
edge_status:
- status: JWT malformed — HS256 signature verification fails
affordance: '''Return to BigCommerce'' link — re-entering via the BC app tile generates a fresh signed payload.'
- status: JWT expired — issued more than 5 minutes ago (outside clock-skew tolerance)
affordance: '''Return to BigCommerce'' link — re-entry produces a new JWT with a current iat claim.'
- status: JWT valid but store_hash not found in our database — store not installed
affordance: Redirect to the OAuth install flow rather than a 401 — surfaced here so this case does not fall through to the
generic error page.
disabled_focus:
keyboard: The 'Return to BigCommerce' affordance in the 401 error page is a real <a> anchor reachable via Tab and activatable
with Enter — never a div-onClick. The loading skeleton has no interactive controls so no focus management is required
during the JWT verification window.
US-1.3: App Extensions registered on install
<!-- traceability:start:US-1.3 --><!-- traceability:end:US-1.3 -->Prototype: Setup Checklist · Registration Health · Setup Error
Phase: MVP · Persona: Merchant Admin
As a Merchant Admin, I want to see "Subscriptions" panels appear on Orders, Products, and Customers pages after install, so that I can access subscription info in context.
Acceptance criteria:
- Given the OAuth handshake completes, When the install callback finishes, Then we issue GraphQL
createAppExtensionmutations for PANEL context on Orders, Products, and Customers models. (CARTS is not a supported App Extension model per BC's GraphQL schema; cart-side merchant UX uses a different surface — seedocs/architecture/app-extension-model-enum.md.) - Given an extension already exists (reinstall), When we try to create it, Then we detect the conflict and skip gracefully.
- Given extension creation partially fails, When I open the app, Then a health badge warns me and offers "Re-register extensions."
UI states.
<!-- ui-states US-1.3 -->surface: "Admin (React/BigDesign) onboarding — registration health. apps/admin/src/pages/onboarding/RegistrationHealth.tsx, routed at /v2/onboarding/health (App.tsx line 134). Two BigDesign <Table>s (extensions, webhooks) driven by GET /api/v1/onboarding/health (fetchHealth, line 66), with per-row recovery buttons, a batch 'Re-register all' CTA, and an always-visible 'Re-register extensions' action. Persona: Merchant Admin."
idle:
render: "A 'Back to Welcome' link, a title/subtitle, then (when totalFailed>0) a top-right primary 'Re-register all' Button (line 252). A summary Message — green 'all healthy' with counts (line 308) or amber 'N failed' (line 320). An Extensions Panel with a <Table> of Surface/Label/Status badge/Verified-time/Action (line 349), a Message type=error per failed extension (line 412), and an always-visible 'Re-register extensions' Button (line 477). A Webhooks Panel with a <Table> of Scope/Status badge/Delivery (timestamp + delivery sub-badge via deliveryBadge, line 194)/Action, and a Message type=error per failed webhook (line 561)."
primary_action: "Per-row 'Re-register' (failed extensions, line 392) / 'Re-subscribe' (failed webhooks, line 541); batch 'Re-register all' for all failing (onReRegisterAll, line 145); always-visible 'Re-register extensions' (onReRegisterExtensions, line 160)."
loading:
trigger: "On mount reload() to fetchHealth() GET /api/v1/onboarding/health (lines 93/110)."
render: "While data===null and no error, a plain 'Loading' Text (line 299). Each in-flight action shows isLoading on its own Button: per-row buttons gate on pendingIds (lines 396/545), batch on batchPending (line 256), re-register-extensions on extReRegisterPending (line 481); other buttons disable while a batch runs (disabled includes ...||batchPending)."
error:
surfaced_at: "Inline BigDesign <Message> banners, never toasts. A page-level dismissable Message type=warning carries fetch/action errors (line 268, onClose line 274). Per failed extension/webhook a Message type=error shows the row's error_message (lines 412-420, 561-569). A batch partial-failure Message type=error reports how many re-registers failed (line 279)."
recovery: "Every failure is actionable: failed rows expose 'Re-register'/'Re-subscribe' (lines 392/541); the batch 'Re-register all' retries every failing item (line 252); the always-visible 'Re-register extensions' re-runs extension registration regardless of row state (line 477). After any action reload() refetches health (lines 125/136/150/164) so the tables reflect the new state."
empty:
render: "When the store has no registered extensions/webhooks, data.extensions and data.webhooks are empty: each <Table> renders BigDesign's default empty body and the summary Message shows green 'all healthy' with 0 extensions / 0 webhooks (line 308). The always-visible 'Re-register extensions' Button (line 477) is still the recovery path to (re)create the extension set — the empty table is never a dead-end."
edge_status:
- status: "extension status=failed"
affordance: "danger badge + a Message type=error with the error_message; per-row 'Re-register' Button (line 392) and the batch 'Re-register all' both retry."
- status: "extension status=pending (registration in flight)"
affordance: "warning badge, row Action shows a dash (line 405); the always-visible 'Re-register extensions' Button (line 477) re-runs registration; resolves to registered/failed on the next health fetch."
- status: "webhook status=failed"
affordance: "danger badge + Message type=error; per-row 'Re-subscribe' Button (line 541) and 'Re-register all' (which targets failing items) retry."
- status: "webhook status=pending (subscription in flight)"
affordance: "warning badge, row Action shows a dash; transient — resolves to active/failed on the next health fetch. NOTE: there is no manual webhook recovery for a stuck-pending webhook (the per-row button gates on status==='failed' at line 540 and there is no webhook-level always-visible batch like the extensions') — see gaps."
- status: "webhook delivery=retry / never"
affordance: "delivery sub-badge under the timestamp via deliveryBadge (line 194); informational — if the underlying webhook is failed, 'Re-subscribe' restores delivery; 'never'/'retry' on an active webhook resolve as deliveries succeed."
disabled_focus:
keyboard: "All actions are real BigDesign <Button>s inside <Table> rows / panel footers, reachable in Tab order; the 'Back to Welcome' link, 'Re-register all', per-row buttons and 'Re-register extensions' are native focusable elements. In-flight buttons use native disabled (removed from tab order) with an isLoading spinner; no div-onClick dead-ends."
gaps: "The page-level error <Message> (line 268) and the per-row error Messages (lines 412/561) are inserted on state change with no role=alert/aria-live wrapper and no programmatic focus move — a keyboard/screen-reader merchant gets no announcement when a re-register fails (focus stays on the re-enabled Button). Same class as the US-22.1 finding."
gaps: "Webhook-pending has no manual recovery affordance: the per-row 'Re-subscribe' renders only for status==='failed' (line 540) and there is no webhook equivalent of the always-visible 'Re-register extensions' button (line 477), so a webhook stuck in 'pending' has no operator-driven path — only auto-resolve on the next fetch. Low severity; pending is normally transient. Secondary: dynamic error Messages lack aria-live announcement (see disabled_focus.gaps)."
US-1.4: Webhook subscriptions registered on install
<!-- traceability:start:US-1.4 --><!-- traceability:end:US-1.4 -->Prototype: Registration Health · Setup Error
Phase: MVP · Persona: System
As the System, I want to register for all BC webhooks I depend on during install, so that downstream flows work without merchant intervention.
Acceptance criteria:
- Given install completes, When we subscribe to webhooks, Then
store/order/created,store/order/updated,store/cart/created,store/cart/updated,store/cart/deleted,store/customer/updated,store/product/updated,store/product/deletedare all registered to our endpoints. (store/app/uninstalledis auto-fired by BC and does not require explicit registration.) - Given any webhook subscription fails, When we detect the failure, Then we queue a retry and surface the gap on the settings page.
US-1.5: Uninstall hook cleans up credentials and extensions
<!-- traceability:start:US-1.5 --><!-- traceability:end:US-1.5 -->Prototype: Uninstall Preview
Phase: MVP · Priority: P0 · Effort: M · Persona: Merchant Admin
As a Merchant Admin, I want my credentials and app extensions to be removed when I uninstall, so that I don't leave stale resources in my store.
Acceptance criteria:
- Given I uninstall from BC, When BC POSTs to
/api/uninstall, Then we verify the signed payload, revoke credentials, deregister extensions, and pause all active subscriptions. - Given subscriptions exist on uninstall, When uninstall processes, Then we retain subscription data for 30 days for potential reinstall recovery.
- Given 30 days pass post-uninstall without reinstall, When the retention window closes, Then we hard-delete subscriber PII and retain only anonymized revenue records.
UX notes.
- Not user-facing in our app (user is uninstalling and won't see our UI)
- If there are active subscriptions, BC itself may prompt the merchant about data retention — we emit
uninstalled_with_active_subs: trueto a post-uninstall survey email
Data contract.
- Input: BC
store/app/uninstalledwebhook (POST withstoreHash) - Our DB writes:
stores.uninstalled_at = now(),subscriptions.status = 'paused'for all active subs (do not cancel yet),eventsrow - Our DB retention: subscription data retained 30 days (queryable by merchant for recovery)
- External: deregister App Extensions via GraphQL
deleteAppExtensionmutations - External: cancel all our webhook subscriptions (we can't use BC webhooks post-uninstall anyway)
- Events emitted:
store.uninstalled,subscription.paused(× N) - Scheduled task: enqueue
store.purgeworkflow for T+30 days
Success metrics.
- Functional: 100% of uninstall webhooks result in subs paused + extensions deleted
- Product [estimate]: reinstall-within-30-days rate (should be ~5–10% — they often come back)
- Operational (target): purge job completion rate ≥ 99.5%; zero PII retained post-purge
Dependencies.
- Upstream: US-1.4 (webhooks must be registered to receive the uninstall event)
- Downstream: data-subject-access + erasure flows (Epic 28)
Non-functional.
- Uninstall is idempotent — repeating the webhook does not double-pause
- Access token may already be revoked by BC by the time we try to delete extensions — treat those failures as non-fatal
Risks / open questions.
- If a merchant reinstalls at T+31 (1 day after purge), we've lost their subscription data. Consider a 60-day retention with explicit merchant opt-out for shorter.
UI states.
<!-- ui-states US-1.5 -->surface: "Admin (React/BigDesign) onboarding — uninstall policy disclosure. apps/admin/src/pages/onboarding/UninstallPolicy.tsx. A static, read-only 3-phase timeline (immediate cleanup / 30-day recovery window / 30-day PII purge) built from a fixed PHASES array (lines 50-88), each phase a TimelineCard with a badge + bullet list. Persona: Merchant Admin. NOTE: not registered in App.tsx (only onboarding/setup + onboarding/health are routed) so the page is unreachable from the SPA; per the BRD this story is 'Not user-facing in our app' — the real AC (backend webhook cleanup) is not a UI surface — see gaps."
idle:
render: "A 'Back to Welcome' subtle Button, a title/subtitle, an info <Message> callout (line 111), then a row of three TimelineCards (PHASES.map, line 119) — each with an H4 header, a phase Badge (primary/warning/danger), and a BulletList of check/warning/delete-iconed bullets — followed by a 'Data retention' Panel with body + GDPR note (line 145)."
primary_action: "The only action is the 'Back to Welcome' Button to navigate('/') (line 96); the panel is otherwise informational."
loading:
trigger: "None — fully static i18n content; no data fetch, no async, no mutation."
render: "Renders synchronously on mount; there is no loading state because there is nothing to load. North-star: if the retention dates ever became live store config, a load skeleton would attach to the retention Panel."
error:
surfaced_at: "No data fetch or mutation, so there is no runtime error surface. The only failure mode is a missing i18n key, in which case FormattedMessage renders the raw message id in place (a visible-but-degraded label), not a thrown error or blank panel."
recovery: "The always-present 'Back to Welcome' Button (line 96) lets the merchant leave the panel; nothing to retry. A north-star live-config variant would surface an inline <Message> + retry, consistent with the sibling onboarding panels."
empty:
render: "Never empty — the three phase cards are statically defined in PHASES (lines 50-88) and always render; the retention Panel is always present. There is no collection that could be empty."
inputs: []
disabled_focus:
keyboard: "The single interactive element — the 'Back to Welcome' BigDesign <Button> (line 96) — is a real focusable control reachable via Tab and activatable with Enter/Space. The phase bullets are a non-interactive <ul>/<li> (BulletList/Bullet) and the icons are decorative; there are no div-onClick handlers, so no keyboard dead-ends."
gaps: "UninstallPolicy.tsx is route-orphaned — it is imported nowhere and has no <Route> in App.tsx, so a merchant cannot reach this informational disclosure from the SPA. The core US-1.5 acceptance criteria (verify signed payload, revoke creds, deregister extensions, pause subs, 30-day retention, T+30 PII purge) are backend webhook behavior with no UI surface; this panel is only the optional pre-uninstall disclosure and it is unreachable. North-star: register the route and link it from Settings/Welcome before uninstall."
US-1.6: Persist BC staff on first load
Status: PROPOSED — Hive proposal
44f9cae7. Synthesis target 2026-05-08. Final acceptance criteria + final placement land after synthesis approval, citing the synthesis ID in the commit message perWAYS-OF-WORKING.mdspec-change rule.
Phase: MVP · Priority: P1 · Effort: S · Persona: System
As the System, I want to persist BC staff identities on first app-load, so that we can attribute mutating actions to specific users without per-merchant configuration.
Acceptance criteria (proposed):
- Given a successful
signed_payload_jwtverify on/api/load, When we extractuser.{id, email}(andowner.{id, email}if present), Then we upsert ausersrow keyed by(store_hash, bc_user_id)withemail,is_owner,first_seen_at,last_seen_at. - Given the same user loads again, When the upsert runs, Then
last_seen_atupdates andfirst_seen_atis unchanged. - Given the user's email changes in BC, When we observe the new email at next load, Then we update the persisted email and emit a
user.email_changedevent.
Data contract.
- Trigger: every successful
/api/loadJWT verification (US-1.2) - DB writes:
usersrow upsert keyed(store_hash, bc_user_id) - Schema:
users(store_hash, bc_user_id, email, is_owner, first_seen_at, last_seen_at, email_history_jsonb) - BC remains the source of truth for who can access; we mirror identity only.
Success metrics.
- Functional: every authenticated
/api/loadresults in ausersrow present. - Operational (target): P95 upsert overhead < 5ms on the existing JWT-verify hot path.
Dependencies.
- Upstream: US-1.2 (signed JWT verify already extracts
user.id/user.email) - Downstream: US-1.7 (depends on
usersrow existing for FK reference)
Non-functional.
- Idempotent — same
(store_hash, bc_user_id)on second load updateslast_seen_atonly. - No invite UI; no role assignment. RBAC layer deferred to Epic 29 (post-MVP).
US-1.7: Stamp mutating events with actor_user_id
Status: PROPOSED — Hive proposal
44f9cae7. Synthesis target 2026-05-08.
Phase: MVP · Priority: P1 · Effort: M · Persona: System
As the System, I want every mutating action to carry the acting user's identity, so that audit reports (Epic 28) and internal investigations can answer "who changed X."
Acceptance criteria (proposed):
- Given a session-authenticated mutating call (any POST/PATCH/DELETE on subscription/plan/payment/charge/customer routes), When we write the change, Then the resulting
eventsrow carriesactor_user_idresolved from the session JWT. - Given a system-initiated mutation (scheduled charge, webhook handler, cron), When we write the change, Then
actor_user_idisnullandevents.actor_kindis'system' | 'webhook_bc' | 'webhook_processor' | 'subscriber'. - Given Epic 28 audit-report queries, When they filter by
actor_user_id, Then they return the per-user mutation history.
Data contract.
- Schema additions:
events.actor_user_id— nullable FK →users.bc_user_idscoped bystore_hashevents.actor_kindenum ('merchant_user' | 'subscriber' | 'system' | 'webhook_bc' | 'webhook_processor') — already present per PRD §8.1 line 327; this story confirms its usesubscriptions.created_by,subscriptions.updated_by— nullable FK →users.bc_user_id- Same
created_by/updated_bycolumns added to mutating tables:plans,payment_methods,charges,customers
- Session JWT already carries
userIdper US-1.2; thread it through the request context to the persistence layer.
Success metrics.
- Functional: 100% of mutating events have a non-null
actor_kind; merchant-user-driven mutations have non-nullactor_user_id. - Compliance: Epic 28 DSAR / audit reports can answer "show me everything user X did across this store" without ambiguity.
Dependencies.
- Upstream: US-1.6 (
userstable existence) - Downstream: Epic 28 audit reports (US-28.4); compliance and dispute-defense flows
Non-functional.
- Backfill: NOT required for MVP. Existing events without
actor_user_idpredate this feature. - Performance: column adds are zero-cost on read; write path adds ~one cookie-parse + 8-byte FK write per mutation — negligible.
Deferred to Epic 29 (post-MVP RBAC layer).
- App-side roles (admin / billing-only / read-only) on top of BC's permissions
- Staff list mirror UI in our admin
- Role assignment flow
- BC staff-removed webhook → revoke our session
- Staff invite flow (none — BC is the authority on who can install/access)