Raxx · internal docs

internal · gated ↑ index

support.raxx.app — Customer Support Portal Scope

Status: PM scope document, 2026-04-30 UTC. Initiated by Kristerpher 2026-04-30. This document captures the product intent, architecture constraints, open decisions, and implementation card breakdown for the public customer support portal.


1. Intent Summary

support.raxx.app is the customer-facing support portal for Raxx. Customers sign in with their existing Raxx passkey, see and interact with their own tickets only, and experience a clean interface that carries Raxx visual identity. FreeScout runs as a private backend; Raptor sits between FreeScout and the customer browser, enforcing a strict per-customer privacy boundary. This is distinct from tickets.raxx.app (the FreeScout admin UI, operator-only).

Kristerpher's direct framing (2026-04-30):

"support should be a front end workflow that allows someone to interface with their tickets. FreeScout becomes the API backend at that point. I think it's fine to be public."


2. Surface Architecture

customer browser
   └── support.raxx.app  (CF Pages, static React SPA — "raxx-support" project)
          │  (HTTPS, Cloudflare-proxied)
          └── api.raxx.app/api/support/*  (Raptor — customer-scoped FreeScout proxy)
                 │  (server-to-server, FreeScout service token)
                 └── tickets.raxx.app  (FreeScout REST API — private)

3. Auth Model

  1. Customer arrives at support.raxx.app unauthenticated → full-page sign-in gate
  2. Customer completes WebAuthn assertion via Raxx passkey (#110 / epic #146)
  3. Raptor issues a short-lived customer-scoped JWT (4h, sliding on activity)
  4. JWT payload: { customer_id, customer_email, exp }
  5. Every /api/support/* call requires this JWT as a Bearer token
  6. Raptor cross-checks ticket requester email against jwt.customer_email on every single-ticket endpoint

4. Privacy Boundary (Non-Negotiable)

Rule Implementation
Customer sees only their own tickets Raptor filters GET /api/support/tickets by requester.email == jwt.customer_email
Wrong-customer ticket access returns 404 Never 403 — do not confirm ticket existence to another customer
Internal notes stripped Thread responses with type: note removed server-side before response leaves Raptor
Operator fields never exposed assignee, tags, component_tag, incident_severity, FreeScout internal IDs, _internal_* custom fields stripped from all responses
FreeScout API key stays server-side Loaded from Infisical at Raptor startup; zero client exposure
No "FreeScout" branding Zero occurrences of "FreeScout" in any customer-facing surface (HTML, JS bundle, error messages)

5. Customer-Facing API Contract (to be finalized in card 1 ADR)

Route FreeScout mapping Notes
GET /api/support/tickets GET /api/conversations?customer[email]=X Filter server-side; paginated
GET /api/support/tickets/:id GET /api/conversations/:id/threads Strip type: note; 404 if wrong customer
POST /api/support/tickets/:id/reply POST /api/conversations/:id/threads type: message, fromCustomer: true
POST /api/support/tickets POST /api/conversations Routes to support@raxx.app mailbox
PUT /api/support/tickets/:id/resolve PUT /api/conversations/:id { status: resolved } Requester ownership check required

6. User Stories

Story Phase
Customer signs in with Raxx passkey 1
Customer views list of their tickets 1
Customer reads full ticket thread 1
Customer posts a reply on a ticket 1
Customer creates a new ticket 1
Customer receives email notification when operator replies 1
Customer marks a ticket resolved 1
Customer opts out of email notifications 2+
Customer views inline attachments in thread 2+
Real-time in-portal reply notifications 2+

7. Out of Scope


8. Design Direction

Confidence Engine palette (Direction C, locked 2026-04-25): - Ink: #0f172a - Moss: #4a7c59 - Snow/Cream: #f8f5f0 - Amber (WAITING status badge): #d97706

Copy voice: "Raxx Support" everywhere — never "Help Desk", "FreeScout", "Tickets system", or any broker name. Error messages in Raxx voice. Loading states in Raxx voice.

Status vocabulary (customer-visible only): - OPEN — moss badge - WAITING FOR YOUR REPLY — amber badge - RESOLVED — muted/ink badge

UX designer is being dispatched in parallel for mockups. Card 4 (frontend) should implement functional-first; card 7 (mobile + polish) reconciles mockup divergences.


9. Open Decisions (Kristerpher's input required)

DECISION-1 — Customer auth bootstrap

Question: Passkey-only at launch, or passkey + email-OTP fallback for first-time / legacy users?

Context: A customer who receives an operator reply email and clicks "View your ticket" must be able to authenticate to read the ticket. If they are on a device where they have not enrolled a passkey (e.g., a new phone, an email-only device), passkey-only fails them.

Options: - A. Passkey-only — cleanest; requires every customer to have enrolled a passkey first - B. Passkey + email-OTP fallback — covers the "I got an email, I want to read my ticket" case

Recommendation: Option B. The email link flow is a key activation moment; blocking it with "you need a passkey" frustrates customers at their most engaged moment.


DECISION-2 — Email-to-ticket intake

Question: Can a customer email support@raxx.app to open a ticket (FreeScout native email intake), OR must ticket creation go through the portal UI only?

Options: - A. Both — email intake stays on + portal UI option. Recommended. - B. Portal-only — disables FreeScout email intake, enforces categorization. More friction for customers.

Recommendation: Option A. Email intake is FreeScout default behavior; disabling it is extra work and hurts customers who prefer email. The customer_raxx_id custom field (card 5) links email-originated tickets back to Raxx accounts retroactively when the customer logs into the portal.


DECISION-3 — Ticket status vocabulary

Question: Do we expose the operator's incident_severity field, or normalize to simple labels?

Options: - A. Normalize to: OPEN / WAITING FOR YOUR REPLY / RESOLVED - B. Pass through FreeScout native statuses (active, pending, closed, spam)

Recommendation: Option A. FreeScout's native statuses ("pending", "spam") are operator concepts. Customers should see clean language.


DECISION-4 — New ticket category selector

Question: When a customer opens a new ticket, do we ask for a category?

Options: - A. Optional category dropdown: Account / Billing / Bug Report / Feature Request / Other - B. Free-form (subject + body only)

Recommendation: Option A, with the dropdown optional (not required). Operators benefit from routing; customers are not blocked by it.


DECISION-5 — Email notification delivery mechanism

Question: Do we use FreeScout's native email reply (SMTP via Postmark, already configured in terraform/freescout/) or build a Raptor webhook receiver for more template control?

Options: - A. FreeScout native SMTP — already wired; customize the FreeScout email template to Raxx branding - B. Raptor webhook receiver — more control over Postmark template; more infra work

Recommendation: Option A (scoped in card 6). Faster to ship; FreeScout template customization is well-documented. Migrate to Option B in Phase 2 if template limitations arise.


10. Implementation Card Breakdown

# Title Size Blocks
Card 1 Architect: data model + API contract S Cards 3, 4
Card 2 Infra: DNS + CF Pages + deploy workflow S Card 4
Card 3 Backend: customer-scoped FreeScout proxy API M Card 4 (can mock)
Card 4 Frontend: React support portal SPA L Cards 7, 8
Card 5 FreeScout: customer mailbox + custom field setup S Card 3, Card 6
Card 6 Email notifications: operator reply → customer email S Card 5
Card 7 Mobile + iOS Safari polish S Card 4
Card 8 Launch checklist: meta tags, lint, sitemap, robots S All cards

11. Suggested Sequencing

Sprint 1 (in parallel): - Card 1 (architect ADR) + Card 2 (infra shell) + Card 5 (FreeScout mailbox config)

Sprint 2: - Card 3 (backend) + Card 4 (frontend, develops against mocks while Card 3 is in-flight)

Sprint 3: - Card 6 (email notifications) + Card 7 (mobile polish, requires ux-designer mockups)

Sprint 4: - Card 8 (launch checklist) — gate on Kristerpher's DECISION-1 through DECISION-5 answers


12. Refs