support.raxx.app — Customer Support Portal Design
Status: Accepted Date: 2026-05-03 UTC Owner: software-architect Parent epic: #651 ADRs: 0046 · 0045 Downstream sub-cards: TBD — filed with this design
1. Context
support.raxx.app is the customer-facing support portal for Raxx. It is the front door customers walk through to open, track, and reply to support tickets. It launches in the first 30 days post-v1.0 — after #707 establishes the email-intake pipeline that serves as the v1.0-day-1 support experience.
The portal sits on top of FreeScout (running at tickets.raxx.app, CF Access gated — operators only). All FreeScout interaction is mediated server-side by Raptor. No FreeScout name, URL, or credential ever reaches the customer browser.
The design mirrors the status.raxx.app pattern (ADR-0028): a CF Pages static React SPA calling Raptor API endpoints that act as a privacy-enforcing proxy.
2. Invariants
All platform invariants apply. Support-portal-specific constraints:
- No stored credentials. The FreeScout API key is a server-side secret only. It never touches CF Pages build output, the browser bundle, or any env var accessible to client code. It sits in Infisical (see ADR-0046) and is loaded into Raptor at runtime via env.
- Passkeys / WebAuthn only. Customers authenticate with the existing Raxx passkey flow (#110 / epic #146). No password, no SMS OTP, no email OTP fallback. If a customer cannot authenticate, per the A+B recovery policy (#670, confirmed 2026-04-30), the account is unrecoverable. The portal must not create an alternate auth path.
- Privacy boundary is absolute. Raptor MUST verify on every
/api/v1/support/tickets/{id}response — not just the list endpoint — that the ticket's customer email matches the JWTcustomer_emailclaim. Customer A must never see Customer B's ticket. This check happens server-side, not in the React app. - Internal notes are never exposed. FreeScout thread entries with
type: noteare stripped server-side before any response leaves Raptor. Operator fields (assignee,tags,component_tag,incident_severity,_internal_*custom fields) are never included in customer responses. - No forward-looking copy. Error states, status messages, and ticket summaries describe what has happened, not what will happen. No ETAs, no "we expect to resolve by." This applies to in-portal copy and email notification templates.
- Audit trail for every support interaction. Every ticket create, reply, status change, and customer-identity lookup is written to the audit log with
actor=customer:{customer_id}, timestamp, and action. Audit rows are retained per the GDPR retention policy (90 days for interaction audit; see §8). - No vendor names visible to customers. The words "FreeScout," "Tagras," or any underlying vendor name never appear in customer-facing copy, URL structures, error messages, or email templates.
3. Topology Decision
Selected: Option A — New CF Pages project raxx-support with Raptor API proxy.
See ADR-0045 for the full decision record and alternatives.
customer browser
└── support.raxx.app (CF Pages, static React SPA — project: raxx-support)
│ HTTPS, CF-proxied, no CF Access gate
│
└── api.raxx.app/api/v1/support/* (Raptor — raxx-api-prod)
│ service-to-service with FreeScout API token
│ (Infisical secret, server-side only)
│
└── tickets.raxx.app (FreeScout REST API — CF Access gated)
Key topology properties:
- CF Pages project raxx-support mirrors the raxx-status pattern (ADR-0028).
- Raptor endpoints are added to the existing raxx-api-prod Heroku dyno. No new Heroku app. This satisfies the BLR constraint from #908.
- The FreeScout API token is a Raptor-side secret only. CF Pages has no access to it.
- support.raxx.app has no Cloudflare Access gate — it is a public-access surface (Class 1 per docs/security/web-surface-posture.md). All ticket data requires app-level authentication.
4. Data Model
4a. Customer ↔ FreeScout mapping table
FreeScout identifies customers by email within a mailbox. Its customer_id is per-mailbox and may be reassigned if a customer is deleted and re-created. Raptor needs a stable raxx_user_id ↔ freescout_customer_id mapping with the customer email as the join key.
-- Migration: backend_v2/db/migrations/NNNN_support_customer_map.sql
CREATE TABLE support_customer_map (
raxx_user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
freescout_customer_id INTEGER NOT NULL, -- FreeScout numeric customer ID
customer_email TEXT NOT NULL, -- denormalized; the join key; indexed
freescout_mailbox_id INTEGER NOT NULL, -- which FreeScout mailbox
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
last_verified_at TIMESTAMPTZ, -- last time Raptor confirmed the mapping is live
PRIMARY KEY (raxx_user_id, freescout_mailbox_id)
);
CREATE UNIQUE INDEX support_customer_map_email_mailbox
ON support_customer_map (customer_email, freescout_mailbox_id);
Resolution logic: When a customer's first support request arrives, Raptor calls GET /api/customers?email={email} on FreeScout. If a customer record exists, store the freescout_customer_id. If not, FreeScout auto-creates a customer on first POST /api/conversations — Raptor reads the created customer ID from the response and stores it. The email in the mapping always mirrors the JWT customer_email claim.
On user deletion (GDPR DSR): the ON DELETE CASCADE removes the mapping row. FreeScout conversations remain for the operator's retention window; the mapping row is gone so Raptor can no longer serve them via this customer's account. See §8 for retention details.
4b. Support audit log
CREATE TABLE support_audit_log (
id BIGSERIAL PRIMARY KEY,
customer_id TEXT NOT NULL, -- raxx_user_id; NOT email
action TEXT NOT NULL, -- 'ticket.list' | 'ticket.read' | 'ticket.create' | 'ticket.reply' | 'ticket.resolve'
resource_id TEXT, -- freescout conversation_id (opaque string to customer)
ip_prefix TEXT, -- /24 or /48 of requester IP
session_id_hash TEXT, -- SHA-256 of the session ID; not the raw session
success BOOLEAN NOT NULL DEFAULT TRUE,
error_code TEXT, -- null on success; 'privacy_violation' | 'not_found' | etc.
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE INDEX support_audit_log_customer ON support_audit_log (customer_id, created_at DESC);
CREATE INDEX support_audit_log_created ON support_audit_log (created_at DESC); -- for retention job
Audit rows with action = 'ticket.list' are retained for 30 days (low-sensitivity). Rows with action touching create, reply, or resolve are retained for 90 days. The nightly retention job deletes rows per these windows.
4c. Offline ticket queue (FreeScout unavailable)
CREATE TABLE support_pending_submissions (
id TEXT PRIMARY KEY DEFAULT gen_random_uuid(),
customer_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
submission_type TEXT NOT NULL CHECK (submission_type IN ('new_ticket', 'reply')),
conversation_id TEXT, -- null for new_ticket
subject TEXT,
body TEXT NOT NULL,
submitted_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
attempts INTEGER NOT NULL DEFAULT 0,
last_attempt_at TIMESTAMPTZ,
delivered_at TIMESTAMPTZ, -- null = not yet delivered
error TEXT -- last error message on delivery failure
);
When Raptor cannot reach FreeScout, new ticket and reply submissions are held here. A background task retries delivery on a 5-minute interval, up to 24 hours. After 24 hours, the submission is marked error = 'expired' and the customer is notified via email that their message was not delivered and they should re-submit via email to support@raxx.app.
5. API Contract
All endpoints are under api.raxx.app/api/v1/support/*. Every endpoint requires a valid Raxx session (JWT Bearer token or session cookie). Rate limit: 60 req/min per customer (shared with the default Free tier bucket per session-engine.md §6).
The support endpoints are not behind the paper-first gate — support is not a trading code path.
5a. Endpoint table
| Method | Path | FreeScout proxy | Auth | Rate limit |
|---|---|---|---|---|
| GET | /api/v1/support/tickets |
GET /api/conversations?customer[email]={email}&mailbox=support |
Required | 60/min |
| GET | /api/v1/support/tickets/{id} |
GET /api/conversations/{id}/threads |
Required | 60/min |
| POST | /api/v1/support/tickets |
POST /api/conversations |
Required | 10/min (tighter) |
| POST | /api/v1/support/tickets/{id}/replies |
POST /api/conversations/{id}/threads |
Required | 20/min (tighter) |
| PUT | /api/v1/support/tickets/{id}/resolve |
PUT /api/conversations/{id} {status: "resolved"} |
Required | 10/min |
| GET | /api/v1/support/tickets/{id}/attachments/{aid} |
GET /api/conversations/{id}/threads/{tid}/attachments |
Required | 30/min |
5b. GET /api/v1/support/tickets
Request: No body. Auth: Bearer JWT.
FreeScout call: GET /api/conversations?customer[email]={jwt.customer_email}&mailbox={SUPPORT_MAILBOX_ID}&sortField=updatedAt&sortOrder=desc
Privacy enforcement: Raptor uses jwt.customer_email — not a query param from the client — as the filter. Clients cannot enumerate other customers' tickets by passing a different email.
Response (200):
{
"tickets": [
{
"id": "4821", // opaque string (FreeScout conversation ID cast to string)
"subject": "Issue with my backtest",
"status": "open", // "open" | "waiting_for_you" | "resolved"
"created_at": "2026-05-01T14:22:00Z",
"updated_at": "2026-05-03T09:15:00Z",
"unread": true
}
],
"total": 1
}
Status mapping: FreeScout active → "open", pending → "waiting_for_you", closed → "resolved". No other FreeScout status values are surfaced.
Stripped fields: assignee, tags, customFields, createdBy (operator ID), firstReplyAt (internal SLA field), all _internal_ custom fields.
5c. GET /api/v1/support/tickets/{id}
Privacy check: Before fetching the thread, Raptor calls GET /api/conversations/{id} and verifies conversation.customer.email == jwt.customer_email. If they do not match: 403 with body {"error": "not_found"} (deliberately vague — do not confirm that the ticket exists). This check is performed on every request, not cached.
FreeScout call: GET /api/conversations/{id}/threads
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", // opaque thread ID
"from": "customer", // "customer" | "support"
"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. We looked at...",
"sent_at": "2026-05-02T10:30:00Z",
"attachments": []
}
]
}
Stripped from threads: type: note entries (internal notes — dropped entirely), createdBy operator ID, source (email/API/web), FreeScout user attribution beyond "support".
404 handling: If FreeScout returns 404 (ticket deleted operator-side), Raptor returns {"error": "not_found", "message": "This ticket is no longer available."} — no FreeScout-specific error. The portal shows a Bandz-in-scenario empty state.
5d. POST /api/v1/support/tickets
Request body:
{
"subject": "My backtest won't run",
"body": "Steps to reproduce: ...",
"category": "bug_report" // optional: "account" | "billing" | "bug_report" | "feature_request"
}
FreeScout call: POST /api/conversations
{
"mailboxId": "{SUPPORT_MAILBOX_ID}",
"subject": "{subject}",
"customer": { "email": "{jwt.customer_email}" },
"threads": [{ "type": "customer", "body": "{body}", "from": { "email": "{jwt.customer_email}" } }],
"status": "active",
"tags": ["{category}"] // if category provided; maps to FreeScout tags
}
Fallback: If FreeScout is unreachable, insert into support_pending_submissions, return 202 Accepted with {"status": "queued", "message": "Your message was received and will be delivered shortly."}.
Response (201):
{
"id": "4825",
"subject": "My backtest won't run",
"status": "open",
"created_at": "2026-05-03T11:00:00Z"
}
5e. POST /api/v1/support/tickets/{id}/replies
Privacy check: Same as §5c — verify ticket ownership before writing.
Request body:
{
"body": "I also noticed that..."
}
FreeScout call: POST /api/conversations/{id}/threads
{
"type": "message",
"body": "{body}",
"from": { "email": "{jwt.customer_email}" },
"fromCustomer": true,
"status": "active"
}
Response (201):
{
"thread_id": "t_9903",
"sent_at": "2026-05-03T11:05:00Z"
}
5f. PUT /api/v1/support/tickets/{id}/resolve
Privacy check: Verify ticket ownership.
FreeScout call: PUT /api/conversations/{id} with {"status": "closed"}.
Response (200):
{ "id": "4821", "status": "resolved" }
5g. GET /api/v1/support/tickets/{id}/attachments/{aid}
Privacy check: Verify ticket ownership before serving any attachment.
Raptor fetches the attachment binary from FreeScout and streams it to the client. Content-Disposition, Content-Type, and Content-Length are forwarded. The FreeScout attachment URL is never exposed to the client; all fetches go through Raptor.
Response: Binary stream with appropriate Content-Type.
6. Authentication Flow
6a. Cross-domain session strategy
support.raxx.app is a different origin from raxx.app and api.raxx.app. The session cookie issued by Raptor uses Domain=raxx.app with SameSite=None; Secure so it is sent with cross-origin requests from support.raxx.app to api.raxx.app. The cookie is still HttpOnly.
Alternatively, the SPA can use the Authorization: Bearer <jwt> header path (per session-engine.md §4) — this is the recommended path for the support portal since it avoids cross-origin cookie complexity. The JWT is stored in sessionStorage (not localStorage) — lost on tab close, short-lived (15-min TTL with refresh).
The portal uses JWT Bearer token in sessionStorage, not the session cookie. This is the cleanest cross-domain approach.
6b. Passkey authentication sequence
sequenceDiagram
actor Customer
participant SPA as support.raxx.app<br/>(CF Pages SPA)
participant Raptor as api.raxx.app<br/>(Raptor)
participant WebAuthn as Platform Authenticator<br/>(Touch ID / YubiKey)
Customer->>SPA: Visits support.raxx.app
SPA->>SPA: No JWT in sessionStorage → show sign-in screen
Customer->>SPA: Clicks "Sign in with Raxx"
SPA->>Raptor: GET /api/sessions/webauthn/challenge
Raptor-->>SPA: {challenge, rpId: "raxx.app", timeout: 60000}
SPA->>WebAuthn: navigator.credentials.get({publicKey: ...})
WebAuthn-->>SPA: WebAuthn assertion
SPA->>Raptor: POST /api/sessions {assertion}
Raptor->>Raptor: Verify assertion against stored public key
Raptor->>Raptor: Mint JWT (15-min TTL) + session record
Raptor-->>SPA: {jwt, expires_at, user_id} [no cookie set]
SPA->>SPA: Store JWT in sessionStorage
SPA->>SPA: Render ticket list
Note on rpId: The WebAuthn rpId must be raxx.app (the registrable domain). The support portal at support.raxx.app is a sub-domain and therefore eligible to authenticate with credentials registered against raxx.app. No re-enrollment is required. This must be validated in the Raptor WebAuthn verification logic — rpId is confirmed against the stored credential's origin on registration.
6c. Session lifetime
| Parameter | Value |
|---|---|
| JWT TTL | 15 minutes |
| Session sliding window | Refresh on any authenticated request if < 5 min remaining |
| Absolute max session | 12 hours (matches session-engine.md) |
| Step-up requirement | Not required for support actions (no money, no credential changes) |
6d. Logout
DELETE /api/sessions/current revokes the server-side session. The SPA clears sessionStorage. On session expiry (JWT TTL + no refresh), the portal shows the sign-in screen without a full page reload — graceful session recovery UX.
7. Email-Side Flow (Out-of-Portal Replies)
When a customer replies via email rather than the portal, FreeScout appends a new thread entry to the conversation. The portal must show this reply.
Decision: Portal reads from FreeScout on demand — no local cache of thread content.
Rationale: At 1–50 customers, thread volume is trivial. Caching thread content in Raptor adds complexity and a sync problem (when does the cache invalidate?). The portal re-fetches the full thread on every GET /api/v1/support/tickets/{id} open. The webhook path (FreeScout → Raptor → push to portal) is deferred to a post-launch iteration.
Email-reply flow:
sequenceDiagram
actor Customer
participant EmailClient as Customer Email Client
participant FreeScout as tickets.raxx.app<br/>(FreeScout)
participant Raptor as api.raxx.app
participant SPA as support.raxx.app
Customer->>EmailClient: Replies to "Raxx Support" email
EmailClient->>FreeScout: Email delivered via MX (Postmark Inbound)
FreeScout->>FreeScout: Appends thread entry to conversation
Note over FreeScout: No push to portal; portal reads on demand
Customer->>SPA: Opens portal, views ticket
SPA->>Raptor: GET /api/v1/support/tickets/{id}
Raptor->>FreeScout: GET /api/conversations/{id}/threads
FreeScout-->>Raptor: Thread including new email-sent message
Raptor-->>SPA: Thread with "from: customer" entry for the email reply
Notification loop: When an operator replies, FreeScout sends an outbound email to customer@example.com from support@raxx.app. The email includes a deep-link to https://support.raxx.app/tickets/{id}. The customer clicks through to the portal, authenticates, and sees the thread including the reply.
8. Notification Strategy
8a. When operator replies (happy path)
FreeScout sends the outbound reply email by default. Raptor does not send a separate notification — this would be double-notification. FreeScout's outbound email is customized (§11 / S8 sub-card) to:
- Show "Raxx Support" as the sender name, no-reply@raxx.app as the From address.
- Include a deep-link button: "View your ticket → https://support.raxx.app/tickets/{id}"
- Carry Confidence Engine visual styling (dark brand, Antler accent colors).
- Never mention FreeScout, Tagras, or any vendor name.
8b. In-portal notification badge
A ticket list entry gets a visual unread indicator when FreeScout's lastReplyFrom field is "user" (i.e., the last reply was from the support team, not the customer). Raptor maps this to "unread": true on the ticket list response.
No push notifications. No SMS. Email is the sole out-of-portal notification channel per the platform invariant.
8c. FreeScout webhook for real-time portal refresh
At initial launch, the portal polls on page focus (re-fetches the ticket list when the tab gains focus). A FreeScout webhook → Raptor → SSE/WebSocket push is a post-launch enhancement — it is out of scope for the portal launch window.
9. FreeScout Outbound Webhook (Raptor Ingestion)
FreeScout fires webhooks on conversation events. Raptor needs to receive these for two purposes:
1. Trigger the support_pending_submissions retry when FreeScout comes back online.
2. Future: real-time portal notification push.
Webhook endpoint: POST /api/webhooks/freescout/support
This is a separate endpoint from the existing POST /api/webhooks/freescout used by the status page (§5d of status-raxx-app.md). The routing is by the event type and presence of the type: customer (vs. type: note) thread field.
Signature verification: FreeScout HMAC-SHA256 via X-FreeScout-Signature. Same pattern as the status webhook. Secret stored in Infisical at /raxx/prod/freescout-support-webhook-secret.
Events handled at launch:
| Event | Action |
|---|---|
conversation.status_changed to active |
Retry any pending submissions for this customer |
conversation.created |
Log to support_audit_log (source: freescout_webhook) |
| All others | No-op; return HTTP 200 |
10. Sequence Diagrams
10a. Happy path: customer opens and views a ticket
sequenceDiagram
actor Customer
participant SPA as support.raxx.app
participant Raptor as api.raxx.app/api/v1/support
participant FS as tickets.raxx.app (FreeScout)
Customer->>SPA: Sign in (WebAuthn assertion)
SPA->>Raptor: POST /api/sessions {assertion}
Raptor-->>SPA: {jwt}
SPA->>Raptor: GET /api/v1/support/tickets [Bearer jwt]
Raptor->>FS: GET /api/conversations?customer[email]=cust@example.com
FS-->>Raptor: [{id: 4821, subject: "...", status: "active", ...}]
Raptor->>Raptor: Strip operator fields; map status
Raptor-->>SPA: [{id: "4821", subject: "...", status: "open", unread: true}]
SPA->>Customer: Render ticket list
Customer->>SPA: Clicks ticket #4821
SPA->>Raptor: GET /api/v1/support/tickets/4821
Raptor->>FS: GET /api/conversations/4821 [verify email ownership]
FS-->>Raptor: {customer: {email: "cust@example.com"}, ...}
Raptor->>Raptor: email == jwt.customer_email ✓
Raptor->>FS: GET /api/conversations/4821/threads
FS-->>Raptor: [{type: "customer", body: "..."}, {type: "note", body: "..."}, {type: "message", body: "..."}]
Raptor->>Raptor: Strip type:note threads
Raptor-->>SPA: {threads: [{from: "customer", ...}, {from: "support", ...}]}
SPA->>Customer: Render ticket thread
10b. Privacy boundary enforcement — cross-customer attempt
sequenceDiagram
actor Attacker as Attacker (logged in as Customer A)
participant SPA as support.raxx.app
participant Raptor as api.raxx.app/api/v1/support
participant FS as tickets.raxx.app (FreeScout)
Attacker->>SPA: GET /api/v1/support/tickets/9999 [Bearer jwt for customer_a@example.com]
SPA->>Raptor: GET /api/v1/support/tickets/9999
Raptor->>FS: GET /api/conversations/9999
FS-->>Raptor: {customer: {email: "customer_b@example.com"}, ...}
Raptor->>Raptor: customer_b@example.com ≠ jwt.customer_email (customer_a@example.com)
Raptor->>Raptor: Write audit log: {action: ticket.read, error_code: privacy_violation}
Raptor-->>SPA: HTTP 403 {"error": "not_found"}
Note over Raptor: "not_found" — deliberately vague; does not confirm ticket exists
10c. FreeScout outage — new ticket queued
sequenceDiagram
actor Customer
participant SPA as support.raxx.app
participant Raptor as api.raxx.app/api/v1/support
participant FS as tickets.raxx.app (FreeScout — DOWN)
participant Queue as support_pending_submissions
Customer->>SPA: Submit new ticket form
SPA->>Raptor: POST /api/v1/support/tickets {subject, body}
Raptor->>FS: POST /api/conversations
FS-->>Raptor: Connection error / timeout
Raptor->>Queue: INSERT pending_submission {customer_id, subject, body}
Raptor-->>SPA: HTTP 202 {"status": "queued", "message": "..."}
SPA->>Customer: "Your message was received and will be delivered shortly."
Note over Queue: Retry job runs every 5 min
Queue->>Raptor: Retry delivery (up to 24h)
Raptor->>FS: POST /api/conversations [FreeScout back online]
FS-->>Raptor: HTTP 201 {id: 4826, ...}
Raptor->>Queue: UPDATE delivered_at = now()
11. Visual Identity
Per project_design_direction.md, Antlers direction is Confidence Engine (Direction C).
11a. Color palette
Tokens from frontend/trademaster_ui/src/styles/brand.css apply directly:
| Usage | Token | Value |
|---|---|---|
| Page background | --raxx-bg |
#0B0F14 |
| Card / nav chrome | --raxx-bg-2 |
#141A22 |
| Hover / borders | --raxx-bg-3 |
#1F2732 |
| Primary text | --raxx-fg |
#F7F8FA |
| Secondary text | --raxx-muted |
#B8BEC7 |
| Accent (CTAs, links) | --raxx-moss-bright |
#7FB77E |
| Open ticket badge | --raxx-warn |
#E5A23D |
| Resolved ticket badge | --raxx-gain |
#3FB57F |
| Destructive / error | --raxx-loss |
#E5484D |
11b. Bandz-in-scenario states
Per the Confidence Engine direction, error and empty states use Bandz-in-scenario copy (honest, plain-spoken, no apology inflation). Examples:
| Scenario | Copy |
|---|---|
| No tickets yet | "Nothing here yet — open a ticket and we'll get back to you." |
| Ticket not found (deleted) | "This ticket is no longer available." |
| FreeScout down (queued) | "Your message was received and will be delivered shortly." |
| Session expired | "Your session timed out. Sign in again to continue." |
| Network error | "Something went wrong loading your tickets. Try refreshing." |
11c. Typography and layout
- Font:
--raxx-font-sansstack - Spacing: 4px base grid (
--raxx-space-*tokens) - Border radius:
--raxx-radius-mdfor cards,--raxx-radius-smfor badges - The portal is a single-column layout (no sidebar). Ticket list on the left route
/tickets, thread view at/tickets/{id}.
12. Migrations
12a. New tables (forward migration)
Add three new tables to backend_v2/db/migrations/:
1. NNNN_support_customer_map.sql — support_customer_map table (§4a)
2. NNNN_support_audit_log.sql — support_audit_log table (§4b)
3. NNNN_support_pending_submissions.sql — support_pending_submissions table (§4c)
The tables are new, non-breaking additions. No existing table is altered.
12b. Rollback
Drop all three tables. No data loss on rollback (all tables are new and contain no data that exists elsewhere). The FreeScout-side data (conversations, customers) is unaffected.
12c. FreeScout side
No schema changes on FreeScout. The support portal uses FreeScout's existing conversation and thread APIs. No custom fields are required (unlike the status page, which requires component_tag and incident_severity).
13. Rollout Plan
| Phase | Description | Gate |
|---|---|---|
| Dark | Raptor endpoints deployed + tables migrated. No CF Pages project yet. Manually verified via curl by operator. |
Dev review |
| Flag | FLAG_SUPPORT_PORTAL_ENABLED gates all /api/v1/support/* routes. CF Pages project deployed to raxx-support-staging.pages.dev. |
Internal dogfood: Kristerpher authenticates, opens a test ticket |
| Beta | support.raxx.app DNS live but not linked from app or marketing. Share URL with first 3–5 customers who have already submitted tickets via email. Gather feedback. |
No P1 bugs for 3 days; email notifications verified end-to-end |
| GA | Link from raxx.app/support, app.raxx.app footer, and email footers. Announce in changelog. |
All #651 ACs pass; #707 email pipeline live |
Dependency: The portal is only useful after #707 establishes the email pipeline (MX routing, first-ticket auto-reply, outbound branding). GA is gated on #707 completion.
14. Security Considerations
GDPR / Privacy checklist
- PII collected: Customer email (from JWT; used to scope all FreeScout queries), ticket subject and body text (customer-authored, stored in FreeScout only — not duplicated in Raptor except for the
support_pending_submissionsqueue), IP prefix in audit log. - Retention:
support_audit_loglist-action rows: 30 days; write-action rows: 90 days.support_pending_submissionsdelivered rows: purged after 7 days post-delivery; undeliverable rows (expired after 24h): purged after 7 days. FreeScout conversation data: governed by FreeScout's own retention config (operator-set; default unlimited — recommend 2-year limit, flagged as open question). - DSR erasure:
support_customer_mapandsupport_pending_submissionsuseON DELETE CASCADEfromusers. On GDPR erasure of a user record: both tables are cleared automatically. Thesupport_audit_logis purged of rows forcustomer_id = {deleted_user_id}by the GDPR DSR job. FreeScout conversations are not automatically deleted — operator must handle FreeScout-side deletion as part of the DSR workflow. This is documented in the DSR runbook. Open question OQ-3 below. - Audit log for DSR: The
support_audit_logprovides the evidence trail of what data was accessed, by whom, and when — required for GDPR accountability. - No credential replay: The FreeScout API key is read-only for conversation queries and write-only for creating conversations and threads. It is stored in Infisical (see ADR-0046) and loaded as a Heroku config var. It is never written to a response, logged, or included in any observable output.
- Breach notification: If the
support_customer_maporsupport_audit_logis compromised: (a) no financial data is exposed, (b) ticket content lives in FreeScout (separate system), (c) the email addresses insupport_customer_mapare the breach surface. Notification within 72 hours to affected customers via the verified email on file per GDPR Art. 33/34. Raptor's automated breach-notification pipeline (perauth.md §GDPR) covers this. - Secrets location: FreeScout API token at
FREESCOUT_API_TOKENHeroku config var (sourced from Infisical/raxx/prod/freescout-api-token). FreeScout webhook secret atFREESCOUT_SUPPORT_WEBHOOK_SECRET(Infisical/raxx/prod/freescout-support-webhook-secret). Both rotatable without redeploy (Heroku config var update + dyno restart). - Kill-switch:
FLAG_SUPPORT_PORTAL_ENABLED=falsetakes down all/api/v1/support/*routes, returning 503 with a clear message. The portal SPA shows a maintenance state. FreeScout email intake continues independently.
15. Open Questions
These must be resolved before the indicated sub-cards can be claimed:
OQ-1 — Decision-1 from epic #651: Passkey-only vs. passkey + email backup at portal sign-in. The A+B recovery policy (confirmed 2026-04-30, #670) says customers enroll ≥2 devices at signup. Passkey-only is therefore the correct posture: every customer has ≥2 enrolled authenticators. The portal MUST follow the same passkey-only auth invariant as the rest of the platform. Recommendation: passkey-only. Close Decision-1 as resolved per the A+B policy. Blocking: S1, S5.
OQ-2 — Decision-2 from epic #651: Email intake (FreeScout native) AND portal, or portal-only? The design assumes both. Email intake is the v1.0-day-1 experience (#707). Disabling FreeScout email intake would break #707. Recommendation: both. This is not a blocker — the design supports both modes. But Kristerpher should confirm so S1 (customer mapping) handles the case where a FreeScout customer record exists before the customer ever uses the portal.
OQ-3 — GDPR DSR and FreeScout-side ticket deletion. When a customer requests erasure, Raptor deletes its tables via CASCADE. FreeScout conversations (which may contain PII in the ticket body) are not automatically deleted. The DSR runbook must document the manual FreeScout deletion step, and someone must perform it within the GDPR window (30 days). Options: (a) operator manual deletion, (b) Raptor-automated via FreeScout API on DSR trigger. Defer to attorney for guidance. Blocking: S1 (must note the gap in the migration spec) but not blocking S2–S9.
OQ-4 — FreeScout retention config. FreeScout defaults to unlimited retention. Recommend setting a 2-year conversation retention limit. Decision needed before #707 GA. Not blocking portal sub-cards but should be filed as a follow-up on #707.
OQ-5 — Category dropdown (Decision-4 from epic #651): optional category selector on new ticket form?
The design spec (§5d) sends the category as a FreeScout tag. The question is whether the portal UI shows a dropdown at all. Recommendation: yes, optional dropdown with four values. If Kristerpher prefers free-form only, the category field is simply omitted from the POST body. Not blocking S1–S4 (backend doesn't care either way); blocking S7 (new ticket form UI).
OQ-6 — SUPPORT_MAILBOX_ID configuration.
Raptor needs to know the FreeScout mailbox ID for the support mailbox to filter conversations correctly. This is a numeric ID assigned by FreeScout at mailbox creation time. It must be set as FREESCOUT_SUPPORT_MAILBOX_ID in the Heroku config before any endpoint works. Operator must retrieve this from FreeScout's admin UI after #707 lands. Not a design question — just a pre-launch ops task.