Status: Accepted Date: 2026-05-03 UTC Owner: software-architect Parent epic: #651 ADRs: 0046 · 0045 Downstream sub-cards: TBD — filed with this design
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.
All platform invariants apply. Support-portal-specific constraints:
/api/v1/support/tickets/{id} response — not just the list endpoint — that the ticket's customer email matches the JWT customer_email claim. Customer A must never see Customer B's ticket. This check happens server-side, not in the React app.type: note are 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.actor=customer:{customer_id}, timestamp, and action. Audit rows are retained per the GDPR retention policy (90 days for interaction audit; see §8).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.
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.
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.
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.
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.
| 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 |
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.
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.
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"
}
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"
}
Privacy check: Verify ticket ownership.
FreeScout call: PUT /api/conversations/{id} with {"status": "closed"}.
Response (200):
{ "id": "4821", "status": "resolved" }
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.
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.
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.
| 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) |
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.
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.
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.
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.
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.
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 |
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
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
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()
Per project_design_direction.md, Antlers direction is Confidence Engine (Direction C).
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 |
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." |
--raxx-font-sans stack--raxx-space-* tokens)--raxx-radius-md for cards, --raxx-radius-sm for badges/tickets, thread view at /tickets/{id}.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.
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.
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).
| 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.
support_pending_submissions queue), IP prefix in audit log.support_audit_log list-action rows: 30 days; write-action rows: 90 days. support_pending_submissions delivered 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).support_customer_map and support_pending_submissions use ON DELETE CASCADE from users. On GDPR erasure of a user record: both tables are cleared automatically. The support_audit_log is purged of rows for customer_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.support_audit_log provides the evidence trail of what data was accessed, by whom, and when — required for GDPR accountability.support_customer_map or support_audit_log is compromised: (a) no financial data is exposed, (b) ticket content lives in FreeScout (separate system), (c) the email addresses in support_customer_map are 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 (per auth.md §GDPR) covers this.FREESCOUT_API_TOKEN Heroku config var (sourced from Infisical /raxx/prod/freescout-api-token). FreeScout webhook secret at FREESCOUT_SUPPORT_WEBHOOK_SECRET (Infisical /raxx/prod/freescout-support-webhook-secret). Both rotatable without redeploy (Heroku config var update + dyno restart).FLAG_SUPPORT_PORTAL_ENABLED=false takes down all /api/v1/support/* routes, returning 503 with a clear message. The portal SPA shows a maintenance state. FreeScout email intake continues independently.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.