Raxx · internal docs

internal · gated

Account Merge — Console Admin UI Design (Layer-1 RBAC Proxy + UI + Antlers Verify)

Date: 2026-06-21 UTC Status: Active — answers three groomer-flagged open questions; drives #3249 sub-cards Author: software-architect agent Scope: Console (Flask, Layer-1 RBAC proxy + admin UI), Antlers (raxx-next, customer verify page) Parent design: docs/architecture/account-merge-2026-06-05.md Parent card: #3249 (account-merge: Console admin UI) Blocked cards unblocked: #3249 splits into #3768, #3769, #3770 (see §8)


1. Context

The merge engine (#3248), internal Raptor routes (#3253 / PR #3760), schema (#3246), and email flow (#3247) are built or in-flight. What remains is:

  1. The Console Layer-1 RBAC proxy — the gateway that verifies Console-session RBAC before injecting X-Admin-Service-Token + X-Merge-Permission-Granted headers and proxying to Raptor's /api/internal/merges/* routes.
  2. The Console admin UI pages (list, detail, initiate, reversal flow) — all CE-skinned.
  3. The Antlers (raxx-next) customer-facing verify page.

The groomer flagged three open questions before #3249 could be claimed. This document answers all three and specifies every surface the developer needs.


2. Mandatory Invariants (applied to this sub-design)

From the parent design (account-merge-2026-06-05.md §2 and §3):


3. Answers to the Three Open Questions

Q1 — Where does the Console Layer-1 RBAC proxy live?

Decision: New blueprint console/app/blueprints/merges.py, registered at url_prefix="/console/api/merges".

Not middleware. Not an extension of existing middleware.

Rationale: The existing rbac.py middleware provides decorators (@require_rbac_permission, @require_rbac_role). Those decorators are the right tool — they check rbac_user_groupsrbac_group_rolesrbac_roles/rbac_permissions exactly as designed. The proxy logic (HTTP call to Raptor with injected headers, response forwarding) is per-route business logic, not cross-cutting middleware concern. A dedicated blueprint follows the existing Console pattern: customers.py, billing.py, flags.py are all standalone blueprints, not middleware extensions.

Request flow:

Console session (cookie) → validate_session() → admin.id
  → _admin_has_rbac_permission(admin.id, "customers:merge:<X>")
      [via rbac_user_groups → rbac_group_roles → rbac_roles → rbac_permissions]
  → 403 if denied (hide, don't gray — button not rendered if role absent)
  → inject headers: X-Admin-Service-Token, X-Merge-Permission-Granted,
                    X-Merge-CS-Actor-Id (SHA-256 of admin.email)
  → forward to RAPTOR_INTERNAL_BASE_URL/api/internal/merges/<path>
  → return Raptor response body + status to Console UI / API caller

Module path: console/app/blueprints/merges.py

Blueprint registration:

# console/app/__init__.py (in create_app)
from app.blueprints.merges import bp as merges_bp
app.register_blueprint(merges_bp)

The blueprint defines two URL families: - /console/merges and /console/merges/<id> — HTML UI routes (Jinja templates) - /console/api/merges and /console/api/merges/<id>/* — JSON API proxy routes called by the UI via fetch()

There is no url_prefix on the blueprint itself; routes declare their full path to keep the distinction between HTML and JSON routes explicit.

Q2 — What value does X-Merge-Permission-Granted carry?

Decision: The exact customers:merge:* permission string that Console verified.

Specifically, one of: - customers:merge:read - customers:merge:initiate - customers:merge:cancel - customers:merge:reverse - customers:merge:approve_reversal

Rationale: Reading internal_merges.py _check_merge_permission() (line 138–174): Raptor compares X-Merge-Permission-Granted against the permission string the route requires using _hmac.compare_digest(granted.encode(), permission.encode()). This is a plain string equality check (constant-time). The value must therefore be the exact permission string — not a role enum, not a signed token.

A signed token would require a shared secret and a decode step in Raptor; that would couple Raptor to Console's signing key rotation and add latency. A role enum would require a role-to-permission mapping in Raptor, but Raptor explicitly does not query rbac_* tables (those tables live in the Console DB). The plain permission string is the correct, minimal, and already-implemented choice.

Security note: X-Merge-Permission-Granted is only trusted by Raptor when X-Admin-Service-Token also matches ADMIN_SERVICE_TOKEN (via _hmac.compare_digest). Without the service token, the permission header is ignored and the request returns 401. Since only Console holds ADMIN_SERVICE_TOKEN, only Console can forge a trusted X-Merge-Permission-Granted value.

Q3 — Does Raptor enforce X-Merge-Permission-Granted, or is that #3249's job?

Decision: Raptor already enforces this. #3249's job is Console-side injection only.

Raptor's _require_merge_permission(permission) decorator (lines 177–195 of internal_merges.py) is applied to every /api/internal/merges/* route. It: 1. Returns 404 if FLAG_ACCOUNT_MERGE is off. 2. Returns 401 if X-Admin-Service-Token is missing or invalid. 3. Returns 403 if X-Merge-Permission-Granted does not equal the required permission.

This is already implemented and merged in PR #3760. #3249 (Console) is purely responsible for: - Checking the Console session + RBAC tables before forwarding. - Injecting X-Admin-Service-Token (from ADMIN_SERVICE_TOKEN env var) and X-Merge-Permission-Granted (the permission string Console just verified) and X-Merge-CS-Actor-Id (SHA-256 of admin.email for Raptor's audit hashing). - Rendering the UI and forwarding Raptor responses.

No Raptor code changes are needed from #3249.


4. Data Model

4.1 Console DB — No new tables required

The Console DB already has (from migration 0215): - rbac_roles rows for all five customers:merge:* permissions - rbac_permissions rows for all five permission strings - rbac_role_permissions join rows - Group assignments for raxx-support-team, raxx-platform-admins, etc.

No Console migration 0216 or 0217 is needed for #3249. All RBAC rows were seeded in migration 0215. The Console proxy blueprint reads Console DB only for session validation and RBAC resolution.

Note on migration numbering: The next available Console migration slot is 0216. It is explicitly NOT consumed by #3249. Reserve 0216 for whatever ships next.

4.2 Raptor DB — No new tables

All merge state lives in Raptor's account_merges table (migration 0046). Console reads and mutates it exclusively via Raptor's internal API.


5. APIs / Contracts

5.1 Console JSON API proxy routes

All routes in console/app/blueprints/merges.py. All return 404 when FLAG_ACCOUNT_MERGE is off.

GET  /console/api/merges
     @require_rbac_permission("customers:merge:read")
     Proxies: GET RAPTOR_INTERNAL_BASE_URL/api/internal/merges
     Query params forwarded: page, per_page, status
     Response: Raptor response body, status code forwarded

GET  /console/api/merges/<merge_id>
     @require_rbac_permission("customers:merge:read")
     Proxies: GET RAPTOR_INTERNAL_BASE_URL/api/internal/merges/<merge_id>

POST /console/api/merges
     @require_rbac_permission("customers:merge:initiate")
     Proxies: POST RAPTOR_INTERNAL_BASE_URL/api/internal/merges
     Body forwarded: { primary_user_id, secondary_user_id, freescout_ticket_id? }
     X-Merge-Permission-Granted: "customers:merge:initiate"

POST /console/api/merges/<merge_id>/cancel
     @require_rbac_permission("customers:merge:cancel")
     Proxies: POST RAPTOR_INTERNAL_BASE_URL/api/internal/merges/<merge_id>/cancel
     X-Merge-Permission-Granted: "customers:merge:cancel"

POST /console/api/merges/<merge_id>/reversal/initiate
     @require_rbac_permission("customers:merge:reverse")
     Proxies: POST RAPTOR_INTERNAL_BASE_URL/api/internal/merges/<merge_id>/reversal/initiate
     X-Merge-Permission-Granted: "customers:merge:reverse"

POST /console/api/merges/<merge_id>/reversal/approve
     @require_rbac_permission("customers:merge:approve_reversal")
     Proxies: POST RAPTOR_INTERNAL_BASE_URL/api/internal/merges/<merge_id>/reversal/approve
     X-Merge-Permission-Granted: "customers:merge:approve_reversal"

POST /console/api/merges/<merge_id>/reversal/abort
     @require_rbac_permission("customers:merge:approve_reversal")
     Proxies: POST RAPTOR_INTERNAL_BASE_URL/api/internal/merges/<merge_id>/reversal/abort
     X-Merge-Permission-Granted: "customers:merge:approve_reversal"

GET  /console/api/merges/<merge_id>/events
     @require_rbac_permission("customers:merge:read")
     Proxies: GET RAPTOR_INTERNAL_BASE_URL/api/internal/merges/<merge_id>/events
     Returns: Raptor audit event timeline for this merge (filtered merge.* events)

Headers injected on every proxy call:

X-Admin-Service-Token:      <ADMIN_SERVICE_TOKEN env var>
X-Merge-Permission-Granted: <permission string verified by Console RBAC check>
X-Merge-CS-Actor-Id:        <SHA-256 hex of g.admin.email>

Error forwarding: If Raptor returns 4xx/5xx, Console forwards the status code and JSON body verbatim. The UI handles Raptor error codes (conflict, not_found, four_eyes_violation, hold_window_active, reversal_window_expired, dsr_pending).

5.2 Console HTML UI routes

GET  /console/merges
     @require_rbac_permission("customers:merge:read")
     Renders: templates/merges/list.html
     Template context: merges list (from proxy), can_initiate (bool), flag_on (bool)

GET  /console/merges/<merge_id>
     @require_rbac_permission("customers:merge:read")
     Renders: templates/merges/detail.html
     Template context: merge record, events, can_cancel, can_reverse,
                       can_approve_reversal (all bools derived from actor role)

Template boolean helpers are derived from _admin_has_rbac_permission(admin.id, perm), called once per render. A CS operator lacking customers:merge:cancel sees no Cancel button. An operator lacking customers:merge:approve_reversal sees no Approve/Abort buttons. (Hide, don't gray.)

5.3 Antlers customer-facing verify page

In frontend/raxx-next/:

GET  /merge/verify/<merge_id>
     Auth: customer session (raxx.app customer JWT, NOT Console session)
     Renders: Antlers page component — MergeVerifyPage
     Guard: calls Raptor's GET /merges/<merge_id> to confirm merge status and
            that session.user_id is primary_user_id or secondary_user_id
     Hidden (not rendered) when FLAG_ACCOUNT_MERGE is off

POST /merge/verify/<merge_id>
     Body: { "code": "<8-char code>" }
     Calls: Raptor's POST /merges/<merge_id>/verify (customer-facing route, no service token)
     On success with both_verified: renders billing-choice prompt inline

GET/POST /merge/verify/<merge_id>/billing-choice
     Body: { "choice": "apply_to_primary" | "stripe_refund" }
     Calls: Raptor's POST /merges/<merge_id>/billing-choice
     On success: renders "Merge is underway" confirmation

Verify page content (hide-don't-gray rules): - Shows the submitting user's display name or email for confirmation. - Shows "Enter the 8-character code from the email you received." - No merge details (primary/secondary user IDs, other account's email) exposed. - On expired code: "This code has expired. Contact support@raxx.app to request a new one." - On rate-limit (10 attempts): "Too many attempts. Contact support@raxx.app." - On wrong code: generic "Incorrect code." (no hint about remaining attempts — avoids timing oracle for brute-force enumeration). - No swap-primary affordance anywhere on this page. - Hidden (route returns 404) when FLAG_ACCOUNT_MERGE is off.


6. Sequence Diagrams

6.1 Console Proxy — Initiate Merge

sequenceDiagram
    participant UI as Console UI (browser)
    participant C as Console Flask (merges.py)
    participant RDB as Console DB (rbac_*)
    participant R as Raptor /api/internal/merges

    UI->>C: POST /console/api/merges {primary_user_id, secondary_user_id}
    C->>C: validate_session(cookie) → admin
    C->>RDB: _admin_has_rbac_permission(admin.id, "customers:merge:initiate")
    alt permission denied
        C-->>UI: 403 {error: "forbidden"}
    else permission granted
        C->>R: POST /api/internal/merges\n  X-Admin-Service-Token: <token>\n  X-Merge-Permission-Granted: customers:merge:initiate\n  X-Merge-CS-Actor-Id: <SHA-256(admin.email)>
        R->>R: _check_service_token() OK\n_check_merge_permission("customers:merge:initiate") OK
        R->>R: INSERT account_merges; emit audit merge.initiated (M5)
        R-->>C: 201 {merge_id, status: "initiated"}
        C-->>UI: 201 {merge_id, status: "initiated"}
    end

6.2 Console Proxy — Request Reversal (four-eyes)

sequenceDiagram
    participant CS1 as CS Operator (browser)
    participant C as Console (merges.py)
    participant R as Raptor

    CS1->>C: POST /console/api/merges/42/reversal/initiate
    C->>C: RBAC check: customers:merge:reverse
    C->>R: POST /api/internal/merges/42/reversal/initiate\n  X-Merge-Permission-Granted: customers:merge:reverse\n  X-Merge-CS-Actor-Id: <SHA-256(CS1.email)>
    R->>R: four-eyes check: SHA-256(CS1.email) != initiated_by_cs
    alt four-eyes violation
        R-->>C: 403 {error: "four_eyes_violation"}
        C-->>CS1: 403 — "A different team member must initiate the reversal."
    else OK
        R->>R: status=reversal_pending; audit merge.reversal_initiated (M5)
        R-->>C: 200 {status: "reversal_pending", hold_expires_at: ...}
        C-->>CS1: 200
    end

6.3 Antlers Customer Verify

sequenceDiagram
    participant A as Customer (browser, primary account)
    participant ANT as Antlers (raxx-next)
    participant R as Raptor /merges/<id>/verify

    A->>ANT: GET /merge/verify/<merge_id>
    ANT->>R: GET /api/merges/<merge_id> (customer session)
    R-->>ANT: 200 {status, primary_user_id, secondary_user_id}
    ANT->>ANT: confirm session.user_id matches one of the two IDs
    ANT-->>A: render verify form

    A->>ANT: POST /merge/verify/<merge_id> {code: "ABCD1234"}
    ANT->>R: POST /api/merges/<merge_id>/verify {code: "ABCD1234"}\n  (customer session cookie)
    R->>R: argon2 verify code; set primary_verified_at (M4, M5)
    alt secondary not yet verified
        R-->>ANT: 200 {status: "waiting_for_other_account"}
        ANT-->>A: "Waiting for the other account to verify."
    else both verified
        R-->>ANT: 200 {status: "verified", billing_choice_required: true}
        ANT-->>A: render billing-choice prompt
    end

7. Console UI Specification (CE-Skinned)

All UI uses the Confidence Engine look and feel (Direction C). No raw react-bootstrap chrome. No broker names in customer-facing copy.

7.1 Merge List View (/console/merges)

7.2 Initiate Modal (inline, visible to initiate role only)

7.3 Merge Detail View (/console/merges/<id>)

Header: Status badge + merge ID + FreeScout ticket link (if set).

Timeline section: Ordered list of audit events from /console/api/merges/<id>/events showing: event name, timestamp (UTC), actor hash prefix (first 8 chars — never full hash), key fields. Events with dimension: operator_interaction show an actor column.

Verification status: - Per-account row: "Primary (user_id: N) — verified / awaiting code" - Per-account row: "Secondary (user_id: N) — verified / awaiting code" - If initiated status and either code not verified: "Resend code" button per account (visible to customers:merge:read; triggers POST /api/internal/merges/{id}/resend which Raptor exposes — see note below).

Billing choice display (D4): - Shown only after billing_choice is set on the merge record. - Label: "Billing disposition" — value: "Credit applied to primary account" or "Refund to payment method on file."

Actions panel (visibility gated on role):

Action Required permission Visible when
Cancel merge customers:merge:cancel status = 'initiated'
Request Reversal customers:merge:reverse status = 'completed' AND within 14-day window
Approve Reversal customers:merge:approve_reversal status = 'reversal_pending' AND hold window elapsed
Abort Reversal customers:merge:approve_reversal status = 'reversal_pending'

"Approve Reversal" button additionally shows the reversal_hold_expires_at timestamp. If the hold window has not yet elapsed, the button is absent and a countdown is shown: "24-hour customer hold window expires at ."

No swap-primary button exists anywhere. No disabled/grayed swap button. It does not exist.

Error states: - error_detail = 'dsr_pending' (status='failed'): "Merge was blocked by a pending data request on one of the accounts." - status = 'failed': red banner with error_detail text (sanitized before render).

7.4 Antlers Customer Verify Page

Component: frontend/raxx-next/app/merge/verify/[mergeId]/page.tsx

CE-skinned. Minimal — no merge metadata exposed.

Heading: "Verify account merge"
Sub-line: "You're signed in as <email>."
Input: "Enter the 8-character code from the email you received."  [text, maxlength=8]
Submit: "Verify"

Success (waiting): "Verification received. Waiting for the other account."
Success (billing choice): show billing-choice form (two radio options):
  - "Apply remaining subscription balance to my account"
  - "Refund balance to payment method on file"
  [Submit: "Confirm"]
Success (all done): "You're all set. Your accounts are being merged."

Error — wrong code: "Incorrect code."
Error — expired: "This code has expired. Contact support@raxx.app to request a new one."
Error — rate limited: "Too many attempts. Contact support@raxx.app."
Error — already consumed: "This code has already been used."
Error — FLAG off: 404 (route does not render).

Note on billing copy: "subscription balance" and "payment method on file" are the permitted operational terms. No mention of Stripe or any payment processor.


8. Migrations

No Console migration is needed for #3249. All RBAC seed data for merge permissions landed in migration 0215. The Console proxy blueprint uses only existing tables.

No Raptor migration is needed for #3249. account_merges and related tables landed in Alembic migration 0046.

The next available Console migration slot is 0216. It is not consumed here.


9. Rollout Plan

Phase 1 — Dark (FLAG_ACCOUNT_MERGE=off) All merge routes return 404. Blueprint registered but all routes guarded. No Console navigation item shown. Antlers verify page route returns 404. CI passes.

Phase 2 — CS-Restricted (FLAG_ACCOUNT_MERGE=on, staging) Console "Account Merges" section live on staging. Internal team performs 2-3 synthetic merge round-trips: - Initiate → verify both accounts → billing choice → completion. - Reversal: initiate → 24h hold → approve. - Cancel flow. FreeScout auto-comment tested. Antlers verify page tested.

Phase 3 — Limited GA (FLAG_ACCOUNT_MERGE=on, prod) Console merge UI live. CS can initiate real customer merges on request. No customer-facing self-serve affordance exists.

Phase 4 — Docs GA Privacy Policy update + public docs page describing the merge process and customer rights.


10. Security Considerations


11. Security / GDPR Checklist


12. Open Questions

None that block implementation. All three groomer questions answered above.

One forward-looking note for the operator:

Resend code button: The card spec mentions "Resend code" buttons in the Console detail view (per account, up to 5 each). Raptor's /api/internal/merges/<id>/resend is referenced in the design doc §6.1 but the stub in internal_merges.py does not implement a resend endpoint yet. Feature-developer on #3769 (detail view sub-card) should confirm whether the resend endpoint is available from #3247's scope or needs a Raptor patch sub-card. If the endpoint is missing, the resend button is implementation-blocked (not design-blocked). Flag it at claim time.