Raxx · internal docs

internal · gated

ADR-0080 — Support Portal: API Contract, JWT Shape, and Privacy Boundary Algorithm

Status: Accepted Date: 2026-05-12 UTC Deciders: software-architect Scope: Complete /api/v1/support/* route contract, customer JWT payload, FreeScout customer lookup strategy, and privacy-boundary pseudocode for support.raxx.app Design doc: docs/architecture/support-raxx-app.md Refs: Issue #652 (sub-1) · Epic #651 · ADR-0045 (topology) · ADR-0046 (secret store) · ADR-0001 (WebAuthn-only) · ADR-0002 (no stored credentials) · ADR-0003 (GDPR by default) · #110 (WebAuthn registration endpoint) · #253 (key rotation epic)


Context

support.raxx.app is the customer-facing support portal. All FreeScout interaction is mediated server-side by Raptor; the customer browser never talks to FreeScout directly. This ADR records the exact decisions needed before feature-developer picks up the backend sub-cards (S1–S4 in epic #651):

  1. What routes exist, with exact request/response shapes.
  2. Which FreeScout fields are forwarded and which are stripped for each route.
  3. How Raptor verifies ticket ownership on every request (the privacy boundary algorithm).
  4. The customer JWT payload, signing algorithm, lifetime, and rotation policy.
  5. How Raptor maps a Raxx customer to a FreeScout customer record (email-keyed, not FreeScout-ID-keyed).
  6. The email-change edge case: what happens when a customer's verified email changes after they have open tickets.

ADR-0045 covers topology. ADR-0046 covers where the FreeScout API token lives. This ADR covers the contract between Raptor and the customer browser, and the contract between Raptor and FreeScout.


Decision

1. Route table

All routes live at api.raxx.app/api/v1/support/. Every route requires a valid Raxx Bearer JWT. Rate limits are per-customer, per-window.

Method Path FreeScout backend call Rate limit
GET /api/v1/support/tickets GET /api/conversations?customer[email]={jwt.email}&mailbox={MAILBOX_ID} 60 req/min
GET /api/v1/support/tickets/{id} GET /api/conversations/{id} (ownership check) + GET /api/conversations/{id}/threads 60 req/min
POST /api/v1/support/tickets POST /api/conversations 10 req/min
POST /api/v1/support/tickets/{id}/replies POST /api/conversations/{id}/threads 20 req/min
PUT /api/v1/support/tickets/{id}/resolve PUT /api/conversations/{id} {"status":"closed"} 10 req/min
GET /api/v1/support/tickets/{id}/attachments/{aid} GET /api/conversations/{id}/threads/{tid}/attachments (proxied binary) 30 req/min

FLAG_SUPPORT_PORTAL_ENABLED=false returns HTTP 503 for all routes. This is the kill-switch.


2. Request and response shapes

GET /api/v1/support/tickets

Request: No body. Authorization: Bearer <jwt>.

Response 200:

{
  "tickets": [
    {
      "id": "4821",
      "subject": "Issue with my backtest",
      "status": "open",
      "created_at": "2026-05-01T14:22:00Z",
      "updated_at": "2026-05-03T09:15:00Z",
      "unread": true
    }
  ],
  "total": 1
}

FreeScout fields forwarded: id (as string), subject, status (mapped, see §2 note), createdAt (→ created_at), updatedAt (→ updated_at), lastReplyFrom (→ unread bool: true when lastReplyFrom == "user").

FreeScout fields stripped (never sent to customer): assignee, tags, customFields, createdBy, firstReplyAt, closedAt, mailboxId, source, preview (raw thread snippet), any field whose key begins with _internal_.

Status mapping:

FreeScout status Customer status
active "open"
pending "waiting_for_you"
closed "resolved"
anything else "open" (safe default; log unexpected value)

GET /api/v1/support/tickets/{id}

Request: No body. Authorization: Bearer <jwt>.

Privacy check runs before the thread fetch. See §3 (privacy boundary algorithm).

Response 200:

{
  "id": "4821",
  "subject": "Issue with my backtest",
  "status": "open",
  "created_at": "2026-05-01T14:22:00Z",
  "updated_at": "2026-05-03T09:15:00Z",
  "threads": [
    {
      "id": "t_9901",
      "from": "customer",
      "body": "My backtest shows...",
      "sent_at": "2026-05-01T14:22:00Z",
      "attachments": [
        { "id": "att_001", "filename": "screenshot.png", "size_bytes": 48210 }
      ]
    },
    {
      "id": "t_9902",
      "from": "support",
      "body": "Thanks for writing in...",
      "sent_at": "2026-05-02T10:30:00Z",
      "attachments": []
    }
  ]
}

Thread from mapping:

FreeScout thread createdBy.type Customer from
customer "customer"
user "support"

FreeScout thread fields forwarded per entry: id (prefixed t_), type (used for filtering only, not forwarded), body, createdAt (→ sent_at), attachments array (mapped to {id, filename, size_bytes}).

FreeScout thread fields stripped per entry: createdBy (operator user object), source (email/API/web), status, state, actionType, any field beginning with _internal_.

Entries dropped entirely: Any thread where type == "note". These are internal operator notes. They must not appear in the forwarded threads array.

Response 403: Returned when ownership check fails. Body: {"error": "not_found"}. Deliberately vague — must not confirm the ticket exists.

Response 404: Returned when FreeScout returns 404 (ticket deleted operator-side). Body: {"error": "not_found", "message": "This ticket is no longer available."}.


POST /api/v1/support/tickets

Request body:

{
  "subject": "My backtest won't run",
  "body": "Steps to reproduce: ...",
  "category": "bug_report"
}

category is optional. Accepted values: "account", "billing", "bug_report", "feature_request". Any other value is silently dropped (not validated as an error — graceful degradation).

FreeScout call:

{
  "mailboxId": "<FREESCOUT_SUPPORT_MAILBOX_ID>",
  "subject": "<subject>",
  "customer": { "email": "<jwt.email>" },
  "threads": [
    { "type": "customer", "body": "<body>", "from": { "email": "<jwt.email>" } }
  ],
  "status": "active",
  "tags": ["<category>"]
}

tags is omitted if category was not provided.

Response 201:

{
  "id": "4825",
  "subject": "My backtest won't run",
  "status": "open",
  "created_at": "2026-05-03T11:00:00Z"
}

FreeScout unavailable: Insert into support_pending_submissions, return HTTP 202 with:

{ "status": "queued", "message": "Your message was received and will be delivered shortly." }

FreeScout fields forwarded from creation response: id (as string), subject, status (mapped), createdAt (→ created_at).

FreeScout fields stripped from creation response: all; only the four fields above are extracted.


POST /api/v1/support/tickets/{id}/replies

Privacy check runs before write. See §3.

Request body:

{ "body": "I also noticed that..." }

FreeScout call:

{
  "type": "message",
  "body": "<body>",
  "from": { "email": "<jwt.email>" },
  "fromCustomer": true,
  "status": "active"
}

Response 201:

{ "thread_id": "t_9903", "sent_at": "2026-05-03T11:05:00Z" }

FreeScout fields forwarded: id (→ thread_id, prefixed t_), createdAt (→ sent_at).

FreeScout fields stripped: all others.


PUT /api/v1/support/tickets/{id}/resolve

Privacy check runs before write. See §3.

No request body.

FreeScout call: PUT /api/conversations/{id} with {"status": "closed"}.

Response 200:

{ "id": "4821", "status": "resolved" }

FreeScout fields forwarded: id (as string), status (mapped to "resolved").

FreeScout fields stripped: all others.


GET /api/v1/support/tickets/{id}/attachments/{aid}

Privacy check runs before fetch. See §3.

Raptor fetches the attachment binary from FreeScout and streams it to the client. The FreeScout attachment URL is never returned to the client; Raptor is the proxy.

Headers forwarded to client: Content-Type, Content-Disposition, Content-Length.

Headers stripped: X-FreeScout-*, Server, any FreeScout-identifying header.


3. Privacy boundary algorithm

This algorithm runs on every request that touches a specific ticket (GET thread, POST reply, PUT resolve, GET attachment). It is not cached. It runs on every request.

function assert_ticket_ownership(conversation_id, jwt):
    # Step 1: fetch the conversation header from FreeScout
    conv = freescout_get("/api/conversations/{conversation_id}")

    # Step 2: extract the customer email FreeScout has for this conversation
    ticket_email = conv["customer"]["email"].lower().strip()

    # Step 3: extract the authenticated customer's email from the JWT
    # jwt.email is set at token issuance from the verified email on the Raxx user record
    # the client cannot influence this value
    jwt_email = jwt["email"].lower().strip()

    # Step 4: compare
    if ticket_email != jwt_email:
        audit_log(
            customer_id  = jwt["sub"],
            action       = "ticket.read",
            resource_id  = str(conversation_id),
            success      = False,
            error_code   = "privacy_violation"
        )
        # Return 403 with body {"error": "not_found"}
        # "not_found" is deliberate — do not confirm the ticket exists
        raise HTTP403("not_found")

    # Step 5: ownership confirmed — proceed with the original request
    audit_log(
        customer_id = jwt["sub"],
        action      = <action>,
        resource_id = str(conversation_id),
        success     = True
    )
    return  # caller continues

Why not cache the ownership result? A ticket can be re-assigned by an operator between requests, or a customer's email can be updated. Caching creates a TOCTOU window. The FreeScout GET for a conversation header is cheap (single HTTP call, small payload). The performance trade-off is intentionally accepted for correctness.

Why return "not_found" on a 403? An attacker who can enumerate integer conversation IDs should not receive confirmation that a given ticket exists but belongs to another customer. "not_found" is the same response whether the ticket does not exist (FreeScout 404) or exists but is owned by another customer.


4. Customer JWT shape

The JWT is issued by Raptor after a successful WebAuthn assertion.

Algorithm: ES256 (ECDSA with P-256 and SHA-256). RSA variants (RS256) are not used — smaller tokens, faster verification, and no key modulus size debate.

Signing key: A dedicated ECDSA P-256 key for the support portal JWT, stored as SUPPORT_JWT_SIGNING_KEY in Infisical at /raxx/prod/support-jwt-signing-key. This is a separate key from the platform session key.

Rationale for separate key: A separate signing key means a compromise of the support portal JWT key does not affect the main Raxx session key and vice versa. The blast radius of a key rotation is scoped to support portal sessions only.

Relationship to #253 (key rotation epic): Support JWT key rotation follows the same rotation policy specified in #253 once that epic lands. Until #253 ships, manual rotation: update Infisical secret, push new value to Heroku config var, trigger dyno restart. Tokens signed with the old key expire naturally (15-minute TTL). No explicit revocation list is needed at this TTL.

Token claims:

{
  "iss": "https://api.raxx.app",
  "aud": "support.raxx.app",
  "sub": "<raxx_user_id>",
  "email": "<verified_customer_email>",
  "iat": 1746988800,
  "exp": 1746989700,
  "jti": "<uuid4>"
}
Claim Type Description
iss string Always https://api.raxx.app. Raptor validates this on receipt.
aud string Always support.raxx.app. Raptor validates this on receipt; rejects tokens with any other audience.
sub string The Raxx user_id — stable across email changes. Used as customer_id in audit log.
email string The customer's verified email at token issuance time. Used by Raptor to scope FreeScout queries. Never taken from a request parameter.
iat epoch Issued-at time (UTC seconds).
exp epoch Expiry: iat + 900 (15 minutes).
jti uuid Unique token ID for replay detection. Raptor maintains a short-lived bloom filter / in-memory set of used jti values within the 15-minute window.

Refresh: When a valid JWT has < 5 minutes remaining (exp - now < 300s), any authenticated request causes Raptor to issue a new JWT and return it in the X-Raxx-Token-Refresh response header. The SPA replaces the stored JWT with the refreshed one. No separate refresh endpoint.

Absolute session cap: 12 hours from original issuance. Raptor tracks orig_iat in the Raptor support_sessions table (not in the JWT itself, to avoid a forgeable claim). After 12 hours, no refresh is issued — the customer must re-authenticate.

Storage: The SPA stores the JWT in sessionStorage (not localStorage). Lost on tab close. This is intentional: the support portal is not a long-session surface; re-authentication is low friction with a passkey.


5. FreeScout customer lookup strategy

Decision: Look up by verified customer email, never by FreeScout internal customer ID.

FreeScout's customer_id is numeric and per-mailbox. It can be reassigned if a FreeScout admin deletes and re-creates a customer record. Coupling Raptor to the FreeScout customer_id creates a fragile dependency on FreeScout's internal state.

Lookup flow:

function resolve_freescout_customer(jwt_email, mailbox_id):
    # Check local mapping table first
    row = db.query(
        "SELECT freescout_customer_id FROM support_customer_map
         WHERE customer_email = %s AND freescout_mailbox_id = %s",
        [jwt_email, mailbox_id]
    )

    if row:
        return row.freescout_customer_id

    # Not in local map — look up in FreeScout
    result = freescout_get("/api/customers?email={jwt_email}")

    if result["count"] > 0:
        fs_customer_id = result["_embedded"]["customers"][0]["id"]
        db.insert("support_customer_map", {
            "raxx_user_id": jwt["sub"],
            "freescout_customer_id": fs_customer_id,
            "customer_email": jwt_email,
            "freescout_mailbox_id": mailbox_id,
            "last_verified_at": now()
        })
        return fs_customer_id

    # Customer does not exist in FreeScout yet
    # FreeScout auto-creates the customer on POST /api/conversations
    # The caller stores the returned customer_id after creation
    return None

The support_customer_map table (§4a of the design doc) stores this mapping. It uses the customer's email as the join key, not FreeScout's internal ID. The FreeScout customer_id in the table is a performance cache — it shortens the lookup from a FreeScout API call to a local DB read. The email is always authoritative.

FreeScout server-side filtering caveat: FreeScout's GET /api/conversations endpoint supports customer[email] as a query parameter. Testing against the FreeScout API reference confirms this filter is honored server-side (as of FreeScout v1.x). If a future FreeScout version removes this server-side filter, Raptor must fetch all conversations for the mailbox and filter client-side. This would be a performance regression at scale but is safe for correctness. The mitigation is to check the FreeScout changelog on upgrade and add an integration test for ?customer[email]= filtering.


6. Email-change edge case

Problem: Customer email is the join key between Raxx and FreeScout. If a customer changes their verified email in Raxx after creating tickets, the support_customer_map row holds the old email, and FreeScout conversations are associated with the old email. The new JWT will carry the new email. The lookup in support_customer_map will miss. Prior ticket history becomes unreachable through the portal.

Decision: On a verified email change event (emitted by Queue when a customer's email is updated and re-verified), Raptor handles customer.email_changed and:

  1. Updates support_customer_map.customer_email to the new email.
  2. Calls PUT /api/customers/{freescout_customer_id} on FreeScout to update the email on the FreeScout customer record.
  3. FreeScout will then associate all prior conversations with the new email.
  4. The customer's next JWT (issued after the verified email change is complete) will carry the new email and the lookup will succeed.

Failure mode: If the FreeScout PUT /api/customers/{id} call fails (FreeScout unavailable), the support_customer_map row is updated locally but the FreeScout record is stale. A retry job (same retry infrastructure as support_pending_submissions) attempts the FreeScout update up to 24 hours. During this window the customer can still reach their tickets via portal (local map uses new email; JWT uses new email; list query uses new email → FreeScout filters by new email → returns 0 results since FreeScout still has old email → customer sees empty list). This is a degraded-but-not-broken state. The customer can still submit new tickets. Old tickets appear once the FreeScout record is updated.

This edge case is documented in the DSR runbook and the operator GDPR procedure (OQ-3 from the design doc).


Consequences

Positive

Negative / Risks

Neutral


Alternatives Considered

Alternative A: Share the platform session JWT key

Rejected. A single signing key means a support portal key compromise (or rotation) affects all Raxx sessions, not just support portal sessions. The cost of a separate key is minimal (one extra Infisical secret, one extra Heroku config var); the blast-radius reduction is significant.

Alternative B: RS256 (RSA-based JWT signing)

Rejected. RS256 requires a larger key (2048+ bit) and produces larger tokens. ES256 with P-256 gives equivalent security at smaller key size and token size. The platform already uses ES256 for other tokens (per session-engine.md). Consistency with the platform choice.

Rejected for the support portal. The support.raxx.appapi.raxx.app cross-origin cookie flow requires SameSite=None; Secure and Domain=raxx.app. This is a wider cookie scope than necessary and creates a coupling between the support portal session and any main app cookie. Bearer JWT in sessionStorage is scoped to the support portal origin only. sessionStorage (not localStorage) limits the JWT lifetime to the tab session — matching the 12-hour absolute cap without additional enforcement complexity.

Alternative D: FreeScout customer ID as the primary join key

Rejected. FreeScout customer IDs are internal integers that can be reassigned on customer record deletion. Using them as a durable join key creates a silent failure mode (wrong customer's tickets returned after a reassignment). Email is stable within FreeScout's data model for a given mailbox. The email-change edge case is handled explicitly (§6 above).

Alternative E: Cache the privacy boundary check

Rejected for initial implementation. Caching the ownership check introduces a TOCTOU window. A support ticket can be deleted or re-assigned between the cached check and the thread fetch. The cost of an uncached FreeScout GET for a conversation header is one HTTP call per request — acceptable at launch volume. If benchmarks show FreeScout as a bottleneck, a 60-second TTL cache can be added as an explicit future decision with a matching security review.