Customer self-service portal — passwordless auth, universal panel access with invitations, billing, API keys, usage charts, webhooks, support, and admin impersonation.
The Trinity Beast Account Dashboard is a customer-facing self-service portal served at https://api.cpmp-site.org/dashboard. It gives every account holder a single place to view their subscription status, API usage, giving history, webhook configuration, and billing — all authenticated via passwordless magic link.
The dashboard is a Single Page Application (SPA) served by the LPO server directly. There are no separate frontend assets, no CDN dependency, and no build step — the entire SPA is embedded in the Go binary as string-concatenated HTML/JS. All data is fetched from /dashboard/api/* JSON endpoints after the initial page load.
graph LR
subgraph Browser["Browser (SPA)"]
direction TB
SHELL["/dashboard — HTML Shell"] --> STORE["localStorage\ncpmp_user: token · lang · roles\ncpmp_site: lang"]
STORE --> LANG["_detectLang()\ncpmp_user.lang → cpmp_site.lang\n→ navigator.languages → en"]
LANG --> RTL["Set dir=ltr / rtl\nrtl for ar · ur"]
STORE --> CALLS["JSON API Calls"]
end
subgraph LPO["LPO Server (ECS)"]
direction TB
SPAH["SPAHandler\nserves shell"] --> SESS["RequireSession\nmiddleware"]
SESS --> PANEL["Panel API\nhandlers"]
PUBL["PublicLangHandler\nno auth required"]
end
subgraph Data["Data Layer"]
AUR["Aurora\napi_keys · users\nusage_logs"]
VAL["Valkey\nsessions · magic links\nlang:{code} · audit log"]
STR["Stripe\nsubscriptions\ncharges"]
end
CALLS -->|"Authorization: Bearer"| SESS
LANG -->|"GET /public/lang/{code}"| PUBL
PUBL --> VAL
PANEL --> AUR
PANEL --> VAL
PANEL --> STR
Every account sees every panel. The dashboard does not hide features a customer has not bought. Navigation is identical for a first-time donor, a Partner, and an admin — the only exception is the two admin-only panels. What differs is panel content: a customer who owns a product sees their live data, and a customer who does not sees a short invitation to it.
This replaced an earlier design in which the sidebar was assembled from whatever relationships the account held, which meant customers could not see what they did not already have.
Every dashboard user is resolved into an Account — a unified identity keyed by email address. One email can hold any combination of relationships simultaneously (e.g. a person who both gives to CPMP and subscribes to the API).
The server resolves each account into a set of typed relationships. These determine what a panel displays — never whether the panel is reachable. They also drive the roles slice used for endpoint authorization, which is still enforced server-side.
| Relationship | Role Granted | Source | Description |
|---|---|---|---|
DonorFacet | donor | Stripe | Active recurring giving subscription via CPMP give page |
APIKeyFacet | api-subscriber | Aurora api_keys | Active REST API subscription (free, pro, enterprise, unlimited, lifetime) |
TranslationKeyFacet | translation-customer | Aurora api_keys | Translation Service key (service_type = 'translation' or is_translation = true) |
WebhookFacet | webhook-associate | Aurora api_keys + webhook_subscriptions | Active webhook push subscription (starter, standard, professional, enterprise) |
PartnerFacet | partner | Aurora api_keys | AWS PrivateLink partner account |
| Admin | admin | Aurora application_parameters | Email matches admin_email parameter — grants the two admin panels + impersonation |
Naming note for maintainers. The Go type names still carry the *Facet suffix from the original design (internal/handlers/dashboard/models.go, resolved in resolver.go), and are serialized to the browser as the top-level keys donor, api_key, webhook, partner, and is_admin. The names are historical. They no longer imply that the UI is faceted, and no code in the SPA layer references the term at all.
The dashboard uses an email-based account model. One email address = one account. Every API key, webhook subscription, translation key, and donation associated with that email appears in a single unified dashboard view.
This gives customers full control over how they organize their products:
The API response includes a products array alongside the legacy single-key convenience fields. Each product entry carries a type field (lpo, translation, webhook, partner) and the relevant data for that product. This allows the SPA to render organized product cards regardless of how many products the account holds.
flowchart TD
A["resolveAccount(email)"] --> B["Aurora: JOIN api_keys + users\nWHERE email = ? AND revoked = false"]
B --> TX{"service_type = 'translation'\nOR is_translation = true?"}
TX -->|Yes| TXF["TranslationKeyFacet\n→ TranslationKeys[]"]
TX -->|No| C{tier?}
C -->|"free/pro/enterprise\nunlimited/lifetime"| D["APIKeyFacet\n→ LPOKeys[]"]
C -->|"partner"| E["PartnerFacet\n→ PartnerKeys[]"]
C -->|"webhook_*"| F["WebhookFacet\n→ WebhookKeys[]\n+ webhook_subscriptions query"]
A --> G["Stripe: customer search by email\n→ active subscriptions"]
G --> H["DonorFacet\n→ Subscriptions[]"]
A --> I["application_parameters\nWHERE key = 'admin_email'"]
I -->|"email matches"| J["IsAdmin = true"]
D & TXF & E & F & H & J --> K["deriveRoles()\n→ roles slice"]
K --> L["Account + products[]\nreturned to SPA"]
The sidebar is built by buildSidebar(acc) and is the same for every account. Seven sections are emitted unconditionally; an eighth appears only for admins. There are no tier checks, no ownership checks, and no Giving / Services tab split.
| Section | Items (route keys) | Shown To |
|---|---|---|
| — (unlabeled) | Overview (overview) | Everyone |
| Account | Profile (account), Billing (billing), Change Plan (change-plan), Support (support) | Everyone |
| Giving | Overview (giving-overview), Donation History (giving-history), Your Impact (giving-impact) | Everyone |
| API Access | API Key (api-key), Usage (usage), Rate Limits (rate-limits), Reports (reports) | Everyone |
| Translation | Translate (translate), History (translate-history) | Everyone |
| Webhook | Config (webhook), Delivery Log (webhook-log), Delivery Reports (webhook-reports) | Everyone |
| Partner | Connection (connection) | Everyone |
| Admin | Analytics (analytics), Person View (person-view) | is_admin only |
Two further route keys exist in the panel router but are not sidebar items, because they are reached programmatically from the Support panel: support-detail and support-detail-customer.
Because navigation no longer gates anything, each panel is responsible for its own empty state. When the account lacks the relationship a panel depends on, the panel renders the page title as normal and then a single centered invitation card in place of the data.
Every invitation card follows one shape: a large icon, one sentence describing the value of the product in plain language, and one button linking to the public marketing page for it. No pricing appears in the card — pricing lives on the destination page, which reads from tier_catalog.
| Panel | Shown When | Invitation Copy | Button → Destination |
|---|---|---|---|
giving-overview | No donation history | Your giving journey starts here. Every dollar funds freedom from brick kiln slavery. | Join Us → /give.html |
api-key | No LPO products and no API key | Get live cryptocurrency prices from 6 exchanges with one API call. | Subscribe → /subscribe-listener.html |
usage | No API key and no Partner key | Your API usage charts appear here once you start making requests. | Subscribe → /subscribe-listener.html |
rate-limits | No API key | Rate limit details appear when you have an active API subscription. | View Plans → /subscribe-listener.html |
webhook | No webhook subscription | Get prices pushed directly to your endpoint — UDP or HTTPS, your choice. | Webhook Plans → /webhook.html |
webhook-log | No webhook subscription | Webhook delivery history appears once your push service is active. | Webhook Plans → /webhook.html |
connection | No Partner key | AWS PrivateLink access — sub-2ms latency, zero rate limits, direct container access. | Apply → /partner-apply.html |
Three panels behave differently and are worth knowing about explicitly:
reports and translate have no ownership check and no invitation card. Every account gets the complete UI — the full report filter and export controls, and the full translation submission form. Eligibility to actually run a report or submit a job is enforced server-side by the endpoint, not by the panel.giving-history uses an inline empty state rather than a card: "No donations found yet." followed by a Make your first gift link.analytics is the one panel that refuses to render its content, returning an adminRequired error notice for non-admins. It is also absent from their sidebar, so this is a defense-in-depth check rather than a path users reach.The dashboard uses passwordless magic link authentication. No passwords are stored or transmitted. A time-limited, single-use token is emailed to the user via SES and consumed atomically on first use.
sequenceDiagram
participant U as User (Browser)
participant S as LPO Server
participant V as Valkey
participant A as Aurora
participant E as SES (Email)
U->>S: POST /dashboard/api/request-link {email}
S->>V: Rate limit check (30/hr per email, 60/hr per IP)
S->>A: emailHasAccount(email)?
alt account exists
S->>S: generateToken() → 32-byte random
S->>V: SET magic:{sha256(token)} payload TTL=15min
S->>E: Send magic link email (async goroutine)
end
S-->>U: 200 "If an account exists, a link was sent" (always)
U->>S: GET /dashboard/authenticate?token=...
S->>V: GETDEL magic:{sha256(token)} (atomic, single-use)
alt token valid
S->>A: resolveAccount(email) → relationships + roles
S->>S: generateToken() → session token
S->>V: SET session:{sha256(token)} Session TTL=24h
S-->>U: HTML page → localStorage.setItem('cpmp_user', JSON) → redirect /dashboard
else token expired or used
S-->>U: HTML error page "Link expired or already used"
end
| Property | Value |
|---|---|
| Token entropy | 32 bytes (256 bits) cryptographically random via crypto/rand |
| Storage | SHA-256 hash stored in Valkey — raw token never persisted server-side |
| Magic link TTL | 15 minutes |
| Single-use enforcement | Atomic GETDEL — consumed on first use, cannot be replayed |
| Session TTL | 24 hours, sliding (extended on every authenticated request) |
| Rate limiting | 30 requests/hour per email, 60 requests/hour per IP |
| Email enumeration | Always returns 200 regardless of whether account exists |
| Transport | Authorization: Bearer <token> header — never in cookies, no CSRF needed |
Sent from The Trinity Beast <No-Reply@CPMP-Site.org> via SES (us-east-2). Gmail-compatible HTML: bgcolor attributes on every <td>, solid hex colors only, no rgba(). Subject: Your Dashboard Login Link — CPMP.
Sessions are stored in Valkey under session:{sha256(token)}. The SPA stores the raw token in cpmp_user.token (localStorage JSON object) and sends it as a Bearer token on every API call. The RequireSession middleware validates and slides the TTL on every request.
| Valkey Key | TTL | Contents |
|---|---|---|
session:{hash} | 24h sliding | Email, roles, created_at, last_seen, IP, user_agent, impersonated_by (if admin session) |
magic:{hash} | 15 min | Email, requested_at, IP, user_agent |
ratelimit:magic:email:{email} | 1 hour | Request counter (max 30) |
ratelimit:magic:ip:{ip} | 1 hour | Request counter (max 60) |
audit:dashboard:{email} | 90 days | Sorted set of audit events (last 500), scored by timestamp ms |
The dashboard and website use two localStorage JSON objects to persist state across page loads. Both use underscore-delimited names to distinguish them from the legacy flat keys they replaced.
| Key | Lifetime | Structure | Written By |
|---|---|---|---|
cpmp_user | Session (cleared on logout) | | Magic link authenticate endpoint (/dashboard/authenticate) |
cpmp_site | Permanent | | i18n.js v5 (language selector flag dropdown) |
cpmp_user — Dashboard session state. Written once on magic link authentication. The SPA reads cpmp_user.token for the Authorization: Bearer header on every API call. The email, name, and roles fields enable immediate UI rendering (sidebar, welcome message, role-gated panels) without waiting for the /session API response. Cleared entirely on logout.cpmp_site — Website-wide preferences. Persists across sessions and survives logout. Currently stores only lang (the user's chosen language code). The dashboard reads cpmp_site.lang as a fallback if cpmp_user.lang is not set (e.g., before login).The entire dashboard interface is multi-lingual, and the language is resolved client-side before any panel renders. _detectLang() walks four sources in order and returns the first hit:
| Order | Source | Why It Ranks Here |
|---|---|---|
| 1 | cpmp_user.lang | The authenticated user's own stored preference. Most specific — it is tied to the account, not the device. |
| 2 | cpmp_site.lang | The site-wide flag-dropdown selection, shared with the main website. Survives logout, so a returning visitor keeps their choice before authenticating. |
| 3 | navigator.languages | Browser preference order, filtered to the 12 supported codes. Gives a sensible first-visit default with no stored state. |
| 4 | en | Final fallback. |
The resolved code drives two things. It sets text direction — ar and ur set dir="rtl" on both documentElement and body, and every other language sets ltr. And it fetches the string catalog from GET /public/lang/{code}, which reads the lang:{code} key from Valkey. That endpoint requires no authentication and carries no session middleware — it is the one server call the SPA makes without a Bearer token, because the login screen itself must be localized before a session exists. If the fetch fails, the SPA retries against /public/lang/en so the interface degrades to English rather than to empty labels.
This is why editing a language JSON file is not enough on its own — the endpoint serves from Valkey, so bash scripts/kcc.sh push-langs must run for a change to reach the dashboard.
Prior to i18n.js v5 (May 2026), the language preference was stored as a flat string in localStorage('cpmp-lang'). On first page load after the upgrade, i18n.js automatically reads the old flat key, migrates the value into cpmp_site.lang, and removes the old key. This migration is transparent to users — no language preference is lost.
On every page load, GET /dashboard/api/session is called to confirm the token in cpmp_user.token is still valid before rendering the dashboard. If the token is expired or revoked (e.g., after email change), the SPA clears cpmp_user and redirects to the login screen.
All panels are rendered client-side by the SPA JavaScript. The server serves a single HTML shell; panel content is built from the account data returned by GET /dashboard/api/account plus async data fetched per-panel as needed.
graph TD
A["Account Loaded"] --> B["buildSidebar(acc)\n7 sections, unconditional"]
B --> ADM{"is_admin?"}
ADM -->|yes| ADM2["+ Admin section\nanalytics · person-view"]
ADM -->|no| NAV["Same nav as everyone"]
ADM2 --> NAV
NAV --> C["User selects a panel"]
C --> D["buildPanelContent(acc)\nswitch on state.panel"]
D --> E{"Does the account hold\nthe relationship\nthis panel needs?"}
E -->|"Yes"| F["Render live data\n(async fetch per panel)"]
E -->|"No"| G["Render invitation card\nicon + one-line value prop\n+ link to marketing page"]
D --> H["reports · translate\nno ownership check —\nfull UI for everyone"]
The default landing panel and the router's fallback for any unrecognized route. Shows a welcome message, an organized product summary, and quick action buttons. Content adapts to which products the account holds.
If the account has donation history, a giving bar shows the lifetime total with a link to the Giving panels.
When the account holds one or more products (from the products array), the overview renders a "Your Products" card with one row per product. Each product row displays:
This design scales naturally: a customer with one product sees one card, a customer with four products sees four cards — all in one clean view without needing tabs or navigation to discover what they have.
If the account has donation history, giving status and lifetime total tiles appear below the product cards — or as the primary content for accounts that hold no other product.
Context-aware shortcut buttons: View API Key, Usage Stats, Translate, Webhook Config, See Your Impact, Manage Billing. Only buttons relevant to the account's products are shown. This is the one place in the dashboard where content is still filtered by ownership rather than accompanied by an invitation — the full set of features remains one click away in the sidebar.
When the account holds no products at all, the overview falls back to a set of legacy stat tiles instead of the product card list.
Data source: GET /dashboard/api/account (no additional API call needed — the products array and all resolved relationship data arrive in a single response).
Three panels, in every account's sidebar. An account with no donation history sees an invitation to give on the Overview panel and an inline Make your first gift link on Donation History:
| Panel | Route Key | Contents | Data Source |
|---|---|---|---|
| Giving Overview | giving-overview | Status, monthly gift amount, total given, next renewal date, Stripe portal button. Invitation card when there is no donation history. | /account (donor data) |
| Donation History | giving-history | Table of last 12 months of Stripe charges — date, amount, receipt link | GET /dashboard/api/giving/history |
| Impact | giving-impact | Photo gallery of CPMP mission work — medical camps, freedom moments, wheelchairs, Bible distribution, provisions, training | Static (embedded in SPA) |
The Donation History panel fetches the last 12 months of successful Stripe charges. If Stripe has older records, a note is shown directing the user to contact support for a full history.
| Panel | Route Key | Contents | Data Source |
|---|---|---|---|
| Profile | account | Email (with change option), display name (editable), preferred language, API response language, status indicator format, member since date, roles | /account |
| Billing | billing | Current Subscription card (live from Stripe): plan name, amount/interval, cancel status with date, next payment estimate, payment method (card brand + last 4). Cancel warning banner with "Don't cancel" reactivation button. Stripe Customer Portal button, payment method on file indicator, billing information (synced from portal). | GET /dashboard/api/subscription-detailsPOST /dashboard/api/billing/portalPOST /dashboard/api/subscription/reactivate |
| Change Plan | change-plan | Current Subscription card (same as Billing — shows cancel state before switching). Available plans list (from tier_catalog) with Switch buttons. Proration info. Go Lifetime card with credit calculation. Free-tier upgrade redirects to Stripe Checkout. | GET /dashboard/api/subscription-detailsGET /dashboard/api/available-plansPOST /dashboard/api/change-planGET /dashboard/api/lifetime-quote |
The Billing panel shows a Current Subscription card at the top — live from Stripe — displaying the plan name, price, cancel status (with red badge and service end date), next estimated payment, and payment method (card brand + last 4 digits). If the subscription is scheduled for cancellation, a prominent "Don't cancel subscription" button lets the customer reactivate directly without visiting the Stripe portal. The same card appears on the Change Plan panel so customers see their current state before making changes.
The billing portal resolves the Stripe customer ID in order: Aurora api_keys.stripe_customer_id → Stripe customer search by email. The portal session URL is returned and opened in a new tab. Return URL is https://cpmp-site.org/dashboard.
The Profile panel includes two API preference controls that affect how the Unified Messaging Envelope (UME) delivers responses to the customer's API key:
| Control | Field | Options | Default | Effect |
|---|---|---|---|---|
| API Response Language | api_lang | 12 supported languages (en, es, pt, fr, de, ru, hi, ur, it, ar, ja, zh) | en | Sets the language of translatable message content in API responses (error descriptions, informational messages). Envelope metadata (status brackets, endpoint, region, cluster node) remains English always. |
| Status Indicators | response_format | tbc (emoji) or plain (text) | tbc | Controls whether status indicators use emoji symbols (✅ 🛑 ⚠️) or plain text equivalents (OK, ERROR, WARN). Applies to both the status field and the error field prefix. |
Both preferences are saved via POST /dashboard/api/profile with fields api_lang and response_format. The handler updates ALL non-revoked api_keys rows for the user — a single toggle applies across all keys the account holds.
Note: api_lang is separate from preferred_lang. The preferred_lang field controls the language of emails, newsletters, and support communications. The api_lang field controls only API response messages.
In every account's sidebar. Accounts with no API key see invitation cards on API Key, Usage, and Rate Limits; the Reports panel renders its full UI regardless.
| Panel | Route Key | Contents | Data Source |
|---|---|---|---|
| API Key | api-key | Masked key display, Reveal button (fetches full key on demand), Copy button (appears after reveal), key details (tier, status, LRS, renewal) | GET /dashboard/api/api-key/reveal (on Reveal click) |
| Usage | usage | Current month request count (from active key), quota progress bar (color-coded: green/amber/red), 30-day daily bar chart (consolidated across all user's API keys via user_id) | GET /dashboard/api/usage |
| Rate Limits | rate-limits | QPS limit, burst limit, monthly quota. Shown for all tiers — on unlimited and lifetime the quota row reads Unlimited rather than a number. | /account (API key data) |
| LRS Reports | reports | Interactive reports panel with Usage and Summary tabs. Date range picker, asset filter, pagination (30/60/90 per page), and export (JSON/CSV/TSV/Text). Proxies to local LRS using the user's user_id — consolidates history across all API keys the user has ever held (tier changes, key rotations). Available to all API key holders regardless of tier — same monthly report limits apply. | GET /dashboard/api/reports/usageGET /dashboard/api/reports/summary |
The API key is masked by default (lpo-****-**** style). The Reveal action calls /api-key/reveal, which requires api-subscriber, translation-customer, webhook-associate, partner, or admin role. For accounts with multiple keys, the reveal endpoint accepts an optional ?key_id= parameter to reveal a specific key. Every reveal is audit-logged.
The 30-day usage chart is rendered as proportional bars from the usage_logs table, grouped by day in EST timezone.
In every account's sidebar — three panels. Accounts with no webhook subscription see an invitation card on all three.
| Panel | Route Key | Contents | Status |
|---|---|---|---|
| Webhook Configuration | webhook | Current plan, asset count, push interval, HTTPS endpoint, UDP endpoint, last delivery timestamp. Categorized asset picker with a real 24h-volume sort toggle and name tooltips, plus an endpoint configuration wizard. A pending subscription shows an "Activate Delivery" step before it can go live (see 5.5.2). | Live — full management |
| Delivery Log | webhook-log | Recent delivery history, grouped by real push event with a 3-minute activity rollup (see 5.5.3) | Live |
| Delivery Reports | webhook-reports | Daily/summary reporting on delivery rate, asset coverage, and latency, with date-range filtering and 4-format export (see 5.5.4) | Live |
The asset picker draws from the shared exchange_asset_map catalog (150+ prewarmed assets across 6 exchanges — see Trinity-Beast-Kiro-Command-Center.html). A market-volatility disclaimer appears above the picker on both the Webhook and Partner panels, noting that thinly-traded picks can go stale and are swappable at any time. Plan-tier asset limits are enforced server-side at subscription creation: Starter 9 assets/60s, Standard 30/15s, Professional 60/6s (resized from 75 on 2026-08-10 — see 5.5.1 note below), Enterprise 60/3s (resized from 150 on 2026-08-18). Professional and Enterprise deliberately share the same asset ceiling and differ only in push interval.
The webhook engine's own price cache resolves a roughly liquidity-bound number of fresh assets per push rather than a fixed fraction of the configured tier size. After engineering fixes on 2026-08-08 (a Valkey fallback tier and a widened webhook-specific freshness window), typical delivery is around 47–70% of the configured asset count at the higher tiers, and improves further as the prewarm pool matures. Professional's advertised count was lowered from 75 to 60 assets on 2026-08-10 to bring the promise closer to what the exchange pool reliably supports. Enterprise was resolved on 2026-08-18: rather than advertise an asset count the market cannot supply, the tier was moved to the same 60-asset ceiling as Professional at $420/mo, differentiated solely by its 3-second push interval — exactly twice Professional's rate for exactly twice the price. The reasoning is a liquidity fact: of our 150+ prewarmed assets, only about 65 trade above $1M per 24 hours, so any ceiling far above that necessarily reaches into markets too thin to supply a fresh price at a 3-second cadence. Selling the interval instead of the count makes the promise keepable, and a fast interval is only meaningful on liquid assets in the first place.
The asset picker offers two sort modes, switchable without a re-fetch: A–Z (alphabetical) and Most Traded (ranked by real 24-hour USD trading volume, pulled from each exchange's own ticker feed via bash scripts/kcc.sh refresh-volume and stored on exchange_asset_map.volume_24h_usd). Hovering an asset chip shows its full display name (e.g. hovering BTC shows "Bitcoin") from the display_name column, backfilled from CoinGecko.
By default the category browser caps each of the 7 groups at 9 assets (ranked by 30-day request volume, then alphabetically) to keep the picker compact. Passing ?full=1 to the categories endpoint returns every enabled asset in the category, uncapped — this is what the webhook and partner asset pickers use so a Professional or Enterprise subscriber can see the complete pool, not just the top 9 per category.
A new webhook subscription is created in pending_verification status. The Configuration panel shows a "2. Activate Delivery" card with a delivery-method dropdown (UDP + HTTPS, UDP only, or HTTPS only) and the matching endpoint fields — a UDP host/port pair and/or an HTTPS URL. Submitting calls POST /dashboard/api/webhook/activate, which validates the IP and port range, requires the assets step to already be complete, and flips the row to active with verified_at set. This intentionally skips the public API's token echo-back verification step — a logged-in dashboard session already proves control of the account, so the extra round-trip that a machine-to-machine signup needs is not required here.
When a subscriber downgrades their tier (e.g. Professional → Standard) and their configured asset count exceeds the new tier's limit, the Webhook Configuration panel shows a sticky amber banner at the top of the asset wizard area:
Behavior:
position:sticky; top:0) — it stays visible while scrolling through the chip grid, so the subscriber cannot miss it.PUT /webhook/assets to bring the selection within the new tier's cap. The banner disappears once the count is at or below the limit.This is a graceful grandfather: delivery is never interrupted by a tier change. The banner and email are informational prompts, not enforcement gates.
The Delivery Log panel reads directly from webhook_delivery_log, capped at the most recent 50 push events — it is a recent-activity view, not a second LRS Reports. For history beyond that window, or for billing-grade totals, use Delivery Reports (5.5.4) or LRS Reports.
Rows are grouped server-side by (sequence_number, delivery_method) — one row per real push, not per asset — showing time (America/New_York, to the second, since Enterprise's 3-second interval can otherwise collapse dozens of distinct pushes into one visible minute), delivery method, status (with attempt count if a push needed a retry), latency, and asset count. Clicking a row expands it to show every asset and price included in that specific push. Every column header is clickable to sort, and clicking again reverses the sort — this all happens client-side against the already-fetched 50-event array, with no re-fetch.
Below the push table, an Activity Rollup card buckets the same 50-event snapshot into fixed 3-minute windows — start/end time, push count, delivered/failed counts, and average latency per window. The bucket size is fixed and stated once above the table (rather than per row), and the push count is always shown next to the average latency specifically so a 2-push window and a 60-push window never read the same.
Built 2026-08-11 after a customer question about whether webhook reporting reads from the same place as LRS Reports (it doesn't — LRS reads Valkey-cached usage logs; webhook reporting reads Aurora directly). Delivery Reports gives a subscriber date-ranged, aggregate answers to "how well is my subscription actually performing" without scanning the raw per-asset log live.
Backed by a permanent Aurora rollup table, webhook_delivery_daily, refreshed automatically every 15 minutes by a pg_cron job that rescans only the last 2 days of raw log (not a full-history rescan). Each row aggregates one subscription's one day on one delivery method: push counts, success/failure counts, asset-row counts, distinct assets delivered, the day's configured asset-list size (snapshotted, not joined live — so historical coverage percentages stay meaningful after a later tier or asset-list change), and average/p95 latency.
The panel offers a date range picker, summary cards (delivery rate, asset coverage, average latency — deliberately phrased in plain terms rather than audit language, while keeping the exact numbers), a daily breakdown table, and all 4 export formats (JSON/CSV/TSV/Text) — the same convention as LRS Reports. A "Refresh Now" button forces an immediate rollup update outside the 15-minute cron cadence, rate-limited to one call per subscription per 60 seconds; the manual refresh scans a 3-day window rather than the full retention period, avoiding the ALB idle-timeout 502 that a full 93-day rescan produced during testing.
Ownership of the webhook subscription is the only gate on this panel — it is deliberately not tied to LRS entitlement, since Webhook Push and LRS Reports are separate products.
| Method | Path | Description |
|---|---|---|
GET | /dashboard/api/webhook/reports/daily | Daily breakdown rows for the caller's subscription, date-range filterable, 4-format export |
GET | /dashboard/api/webhook/reports/summary | Aggregate summary cards over the selected date range |
POST | /dashboard/api/webhook/reports/refresh | Starts an on-demand rollup refresh asynchronously — returns 202 immediately (3-day scan window, 60s per-subscription cooldown) |
GET | /dashboard/api/webhook/reports/refresh/status | Poll for the outcome of a refresh started above |
A public-API mirror exists at GET /webhook/reports/daily, /webhook/reports/summary, POST /webhook/reports/refresh, and GET /webhook/reports/refresh/status — same behavior, authenticated by API key rather than dashboard session.
In every account's sidebar. Accounts without a Partner key see an invitation card linking to the Partner application page (cpmp-site.org/partner-apply.html). Partners also see a Reports link in the sidebar under the Partner section — this routes directly to the LRS Reports panel (§5.4), which partners access free and unlimited by tier policy.
| Field | Description |
|---|---|
| Connection Status | active for any provisioned, non-revoked partner key. Color-coded: green (connected/active), amber (degraded), red (disconnected). Future: live TCP probe or CloudWatch health data once partners create VPC endpoints. |
| PrivateLink Endpoint | AWS PrivateLink endpoint identifier (or "—" if no consumer has connected yet) |
| SLA Tier | Partner SLA level (or "—" if not yet assigned) |
| Hourly Volume | Requests per hour (or "0 requests" for freshly onboarded partners) |
The same categorized chip-picker pattern as the Webhook asset wizard (§5.5.1), but with no asset cap — Partner tier has unlimited access to all assets by policy. The watchlist is purely organizational (analogous to the internal EAM tool curating which assets are actively tracked) and does not restrict what the Partner-tier key can query.
Features:
GET /asset-names)?full=1) — shows all 360 prewarmed assets, not just top 9 per categoryData sources:
GET /dashboard/api/partner/config — current watchlist (api_key_id, tier, asset_count, assets array)POST /dashboard/api/partner/assets — update the selection (body: {"assets":["BTC","ETH","SOL",...]}). Deduplicates and normalizes to uppercase. Empty array is valid ("no watchlist curated yet" — access unaffected).GET /dashboard/api/partner/available-assets — the full pickable catalog (same source as the webhook asset picker — GET /asset-categories?full=1)When an admin approves a partner application, the system automatically:
/dashboard/authenticate?token=... — so the partner logs in as themselves immediately (not into a stale session)The longer-lived token (72h vs the normal 15-minute magic link) is deliberate — partners may not open the welcome email immediately.
Available to all authenticated accounts. Provides a full inline support experience — customers can view their tickets, read the reply thread, post replies, and mark tickets as resolved without leaving the dashboard.
Displays all support tickets associated with the authenticated user's email, ordered by most recent first. Each row shows ticket number, subject, category, status, and last updated date.
Data source: GET /dashboard/api/support/tickets
Clicking a ticket opens the full conversation thread. Shows the original message, all non-internal replies (admin internal notes are never visible to customers), and the current status. Replies from admin are displayed with translated content when the customer's preferred_lang is not English.
Data source: GET /dashboard/api/support/tickets/{ticket_number}
Customers can post replies to open tickets directly from the dashboard. Replies are limited to 10,000 characters. If the customer's language is not English, the reply is auto-translated to English for admin readability (stored in message_translated). Posting a reply notifies the admin via email.
Status flow: A customer reply to a ticket in awaiting_customer or resolved status automatically re-opens it to open.
Data source: POST /dashboard/api/support/tickets/{ticket_number}/reply
Customers can mark their own ticket as resolved from the dashboard. This sets the status to resolved and notifies the admin. Tickets already in resolved or closed status return a success message without changes.
Alternatively, customers can resolve tickets via a single-use email link (included in admin reply notifications). The link contains a 32-byte token stored in Valkey with a 30-day TTL, consumed atomically on use.
Data source: POST /dashboard/api/support/tickets/{ticket_number}/resolve
| Status | Meaning |
|---|---|
new | Just submitted, not yet reviewed by admin |
open | Admin has replied or customer re-opened |
in_progress | Admin is actively working on it |
awaiting_customer | Admin is waiting for customer response |
resolved | Marked resolved by customer or admin |
closed | Permanently closed — no further replies allowed from dashboard |
| Category | Description |
|---|---|
general | General inquiry |
api-technical | API integration or technical issue |
billing | Billing, subscription, or payment question |
bug-report | Bug or unexpected behavior |
feature-request | Feature suggestion |
mission-donations | CPMP mission or donation inquiry |
Visible only when is_admin: true. Provides a full ticket management interface — view all tickets across all customers, filter by status/category, read threads (including internal notes), post replies, change status, and manage the support queue.
Lists all support tickets system-wide, ordered by most recently updated. Supports filtering by status and category via query parameters. Limited to 200 results per request.
Data source: GET /dashboard/api/admin/support/tickets?status=open&category=technical
Shows the full ticket including customer name, email, IP address, original message, and the complete reply thread — including internal admin notes that are never visible to customers. Useful for context when multiple admins collaborate on a ticket.
Data source: GET /dashboard/api/admin/support/tickets/{id}
Post a reply to any ticket. Replies can be marked as is_internal: true for admin-only notes that the customer never sees. Customer-visible replies are auto-translated to the customer's preferred_lang and trigger an email notification with a one-click "Mark as Resolved" link.
Status flow: First admin reply to a new ticket automatically advances status to open.
Data source: POST /dashboard/api/admin/support/tickets/{id}/reply
Change a ticket's status to any valid value. Audit-logged with the admin's email.
Data source: POST /dashboard/api/admin/support/tickets/{id}/status
The support system is fully multi-lingual:
message_en column) for admin readabilitypreferred_lang for email notification and dashboard displayWhen a ticket is submitted, the tbi-rhema-support Lambda is invoked asynchronously. It auto-categorizes the ticket, drafts a response, and notifies the admin with category, priority, draft, and internal notes. The analysis is stored in Valkey at support:ticket:{id} and included in the admin ticket detail response.
Available to accounts with a Translation API key (service_type = 'translation'). Provides a complete self-service interface for submitting translation jobs, choosing AI agents, monitoring progress, and reviewing history.
The translation submission form allows customers to submit documents for translation directly from their dashboard — no API calls required.
| Field | Type | Description |
|---|---|---|
| Document URL | URL input | Public URL of the HTML document to translate. Must be accessible via HTTPS. Max 500 KB. |
| AI Agent | Dropdown | Choose the translation agent: Best Value (Qwen3 235B — default), Efficient (Mistral Large 3), Capable (DeepSeek V3), Fast (Claude Haiku 3.5), Premium (Claude Sonnet 4.6), or Maximum (Claude Opus 4). Six agents available through Amazon Bedrock. |
| Target Languages | Text input | Comma-separated ISO 639-1 codes (e.g., es, fr, de, ja). Supports 300+ languages. |
On submission, the form calls POST /translate/quote to get an instant price quote. The customer reviews the quote (document analysis, estimated chunks, difficulty, total price) and clicks Accept & Pay to start the job.
The agent dropdown includes a dynamic description panel that updates when the selection changes:
| Tier | Agent | Speed | Best For | ~Cost/Pair |
|---|---|---|---|---|
| Best Value | Qwen3 235B DEFAULT | Fast | Best price-to-quality. CJK, South Asian scripts, high-volume batches. | ~$0.05 |
| Efficient | Mistral Large 3 | Fast | European language specialist. Native-level French, German, Spanish, Italian. | ~$0.06 |
| Capable | DeepSeek V3 | Fast | Technical docs, API references, complex reasoning at budget prices. | ~$0.07 |
| Fast | Claude Haiku 3.5 | Fast | Latin-script languages, code/structure preservation. | ~$0.08 |
| Premium | Claude Sonnet 4.6 | Moderate | Complex scripts (Arabic, Hindi, Japanese), demanding technical docs. | ~$0.31 |
| Maximum | Claude Opus 4 | Thorough | Critical documents, legal/medical, maximum fidelity. | ~$1.56 |
All six agents are available through Amazon Bedrock and share the same sentinel protection pipeline — code blocks, brand terms, version numbers, and technical identifiers are extracted before the agent sees the document. The difference is the depth of linguistic understanding, not the safety of the content.
After submitting a job, the panel shows real-time status. Customers can check the status of their currently running job at any time by returning to the Translation panel.
| State | Description |
|---|---|
queued | Job accepted, waiting for processing capacity |
running | Translation in progress — per-language progress visible |
completed | All language pairs finished successfully |
partial | Some pairs succeeded, some failed — retry available |
failed | Job failed entirely — error details available |
cancelled | Job was cancelled by the customer or admin |
The history table shows all past translation jobs for the customer's API key, with date filtering and export options (JSON, CSV, TSV, Text).
| Column | Description |
|---|---|
| Document | Full document filename(s). Multi-doc jobs show all filenames in a scrollable cell. |
| Langs | Number of target languages |
| Agent | AI model used (e.g., qwen3-235b) |
| Price | Total cost displayed with 2-decimal precision (e.g., $33.00) |
| Status | Color-coded: succeeded, running, queued, failed |
| Date | Submission date in compact format (M/D h:mm AM) |
| Actions | Details link (per-pair progress view) + Download link (presigned S3 URLs, 7-day expiry) |
History is queried directly from Aurora via the translation_quotes + translation_jobs tables, filtered by the customer's api_key_id. This ensures jobs appear immediately after completion — no nightly sync lag. The dashboard proxies this through GET /dashboard/api/translate/history.
Clicking "Details" on a history entry opens the real-time job detail view with:
LANG — filename for accessibilityCustomers can also interact with the Translation Service directly via API. All endpoints require a Translation API key (service_type = 'translation').
POST /translate/quote
Content-Type: application/json
X-API-Key: your-translation-api-key
{
"doc_url": "https://example.com/docs/my-document.html",
"langs": ["es", "fr", "de", "ja", "zh"],
"model": "qwen3-235b"
}
Response includes document analysis (size, chunks, difficulty, code blocks, diagrams), pricing breakdown (cost per chunk, per pair, markup, total), and a quote ID valid for 24 hours.
POST /translate/accept/{quote_id}
X-API-Key: your-translation-api-key
Charges the customer's payment method on file and immediately submits the translation job. Returns the job ID for status tracking.
GET /translate/quote/{quote_id}
X-API-Key: your-translation-api-key
Returns the quote details including the associated job status if the quote has been accepted.
GET /translate/quotes
X-API-Key: your-translation-api-key
Returns all quotes for the authenticated API key — pending, accepted, expired, and completed.
GET /translate/models
Public endpoint (no auth required). Returns the curated list of available AI agents with pricing, speed, quality ratings, and descriptions.
Translation API keys and Prices API keys are distinct. Each key has a service_type field:
| Key Type | service_type | Access |
|---|---|---|
| Prices API Key | prices | /price, /reports, LRS endpoints |
| Translation API Key | translation | /translate/* endpoints |
Using the wrong key type returns a clear 403 error explaining which key type is needed — not a generic "invalid key" message. This prevents confusion between the two services.
Customers can configure up to 150 brand terms that are automatically protected during every translation job. These terms are never translated or transliterated — they appear exactly as written in every target language.
The Brand Terms section appears in the Translation Service panel under Account Settings. Customers can add, edit, and remove terms at any time. Changes take effect on the next translation job — no need to re-submit existing quotes.
api_keys.protected_terms JSONB column)translate="no" annotations before sending text to the AI agent| Constraint | Value |
|---|---|
| Maximum terms per account | 150 |
| Maximum characters per term | 100 |
| Duplicates | Automatically removed on save |
| Scope | Account-level — applies to all jobs, no per-request overrides |
| Method | Path | Description |
|---|---|---|
GET | /dashboard/api/protected-terms | Returns current terms list with count and limit |
PUT | /dashboard/api/protected-terms | Replaces entire terms list. Body: {"terms": ["Term1", "Term2", ...]} |
Three additional features enhance the Translation Service panel for customers and admins:
A 🔔 icon in the dashboard header shows a red dot with the count of unread translation job completions. Clicking the badge displays a summary of recently completed jobs. Notifications are polled every 60 seconds via App.pollNotifications().
dashboard:notifications:seen:{api_key_id} (30-day TTL)A dedicated spend section shows translation costs aggregated by month and model for the last 6 months. Customers see only their own spend; admins see all accounts. The widget auto-refreshes when a translation job completes — no manual page reload needed.
translation_jobs + translation_quotes tables (the ledger)succeeded/partial/failedWhen a refund is processed (translation or subscription), the customer receives a localized confirmation email in their preferred_lang. The email includes refund amount, original charge, refund ID, and for subscription refunds, confirms the API key has been revoked. Supported languages: English, Spanish, Portuguese, French, German, Russian, Hindi, Urdu, Arabic, Japanese, Chinese, and Italian.
Admin accounts see a refund history card in the Translation Service panel showing all refunded or partially refunded translation purchases.
| Column | Description |
|---|---|
| Date | When the refund was processed |
| Document | Original document name from the quote |
| Customer | Customer email (admin view only) |
| Original | Original charge amount (USD) |
| Refunded | Refund amount (USD) |
| State | refunded or partially_refunded |
| Method | Path | Description |
|---|---|---|
GET | /dashboard/api/translate/spend | Returns 6-month spend breakdown by month and model. Admin sees all; customers see own. |
GET | /dashboard/api/translate/refunds | Returns refund history. Admin-only. |
GET | /dashboard/api/translate/notifications | Returns unread notification count and recent completed job summaries. |
POST | /dashboard/api/translate/notifications/seen | Marks all notifications as seen. Resets badge count to 0. |
All dashboard endpoints are served under https://api.cpmp-site.org/dashboard. Authenticated endpoints require Authorization: Bearer <token>.
| Method | Path | Description |
|---|---|---|
GET | /dashboard | Serves the SPA HTML shell. No auth required — the SPA handles auth state client-side. |
GET | /dashboard/authenticate | Validates magic link token, creates session, returns HTML page that writes cpmp_user JSON to localStorage and redirects to /dashboard. |
POST | /dashboard/api/request-link | Sends magic link email. Body: {"email":"..."}. Always returns 200. |
| Method | Path | Role Required | Description |
|---|---|---|---|
GET | /dashboard/api/session | Any | Validates token, returns email, roles, created_at, last_seen. Called on SPA load. |
POST | /dashboard/api/logout | Any | Deletes session from Valkey. SPA clears cpmp_user from localStorage on 200. |
GET | /dashboard/api/account | Any | Returns full resolved account — all relationships, roles, display name. The primary data source for all panels. |
POST | /dashboard/api/billing/portal | Any | Creates Stripe billing portal session. Returns {"url":"..."}. Audit-logged. |
GET | /dashboard/api/subscription-details | api-subscriber | Live Stripe subscription data: plan name, amount, interval, status, cancel state, current period, next payment estimate, payment method (card brand + last4), plus tier change history from Aurora. |
POST | /dashboard/api/subscription/reactivate | api-subscriber | Reverses a scheduled cancellation. Sets cancel_at_period_end=false on Stripe, clears cancel_at in Aurora. Audit-logged. |
GET | /dashboard/api/available-plans | api-subscriber | Returns plans from tier_catalog for the customer's product family (REST or Webhook). Marks current tier. Indicates if checkout flow is needed (free tier upgrade). |
POST | /dashboard/api/change-plan | api-subscriber | Switches subscription tier via Stripe proration. Body: {"target_tier":"..."}. Free-tier returns checkout_url. Paid-tier updates in place. Audit-logged. |
GET | /dashboard/api/lifetime-quote | api-subscriber | Returns lifetime upgrade eligibility and credit calculation (remaining subscription value deducted from $3,000). |
GET | /dashboard/api/api-key/reveal | api-subscriber, translation-customer, webhook-associate, partner, or admin | Returns full unmasked API key. Accepts optional ?key_id= to reveal a specific key (for multi-product accounts). Without key_id, returns the first key. Audit-logged on every call. |
GET | /dashboard/api/usage | api-subscriber | Returns current month usage (from active key's counter), quota, and 30-day daily breakdown array (consolidated across all user's API keys via user_id). |
GET | /dashboard/api/giving/history | donor | Returns last 12 months of Stripe charges — date, amount_usd, status, receipt_url. |
GET | /dashboard/api/reports/usage | api-subscriber | Proxies to LRS /reports/usage. Resolves user's user_id from session and passes it to LRS for consolidated cross-key history. Accepts same query params as direct LRS (asset, start_date, end_date, page, page_size, format, cached). |
GET | /dashboard/api/reports/summary | api-subscriber | Proxies to LRS /reports/summary. Resolves user's user_id from session for consolidated history. Accepts same query params as direct LRS (start_date, end_date, format). |
POST | /dashboard/api/webhook/activate | webhook-associate | Activates a pending_verification webhook subscription. Body: {"delivery_method":"...", "udp_host":"...", "udp_port":..., "https_url":"..."}. Requires the asset list already be configured. Skips the public API's token-echo verification since a dashboard session already proves control. |
GET | /dashboard/api/webhook/log | webhook-associate | Returns up to 50 recent push events, grouped by (sequence_number, delivery_method), each with a nested per-asset price array. |
GET | /dashboard/api/webhook/reports/daily | webhook-associate | Daily delivery-rollup rows for the caller's subscription, date-range filterable, 4-format export (JSON/CSV/TSV/Text). |
GET | /dashboard/api/webhook/reports/summary | webhook-associate | Aggregate delivery summary (delivery rate, asset coverage, latency) over the selected date range. |
POST | /dashboard/api/webhook/reports/refresh | webhook-associate | Forces an immediate rollup refresh. 3-day scan window, 60-second per-subscription cooldown (Redis SetNX). |
GET | /asset-names | None (public) | Returns {"names": {"BTC":"Bitcoin", ...}} from exchange_asset_map.display_name, used for asset-picker hover tooltips. |
POST | /dashboard/api/profile | Any | Updates display name. Body: {"display_name":"..."}. |
POST | /dashboard/api/email/change | Any | Initiates email change. Sends verification to new address. Body: {"new_email":"..."}. |
GET | /dashboard/api/support/tickets | Any | Returns all support tickets for the authenticated user's email. Ordered by most recent. |
GET | /dashboard/api/support/tickets/{ticket_number} | Any | Returns full ticket detail with reply thread (excludes internal admin notes). Scoped to user's email. |
POST | /dashboard/api/support/tickets/{ticket_number}/reply | Any | Post a customer reply. Body: {"message":"..."}. Max 10,000 chars. Auto-translates to English for admin. Notifies admin via email. |
POST | /dashboard/api/support/tickets/{ticket_number}/resolve | Any | Mark own ticket as resolved. Notifies admin. Closed tickets cannot be resolved (open a new one). |
GET | /dashboard/api/translate/history | translation-subscriber | Returns translation job history for the authenticated user's API key. Includes job ID, state, docs, langs, model, cost, pair counts. |
GET | /dashboard/api/translate/status/{job_id} | translation-subscriber | Returns real-time status of a specific translation job including per-language progress. |
GET | /dashboard/api/protected-terms | translation-subscriber | Returns the customer's brand terms list (terms, count, limit of 150). |
PUT | /dashboard/api/protected-terms | translation-subscriber | Replaces brand terms list. Body: {"terms": [...]}. Max 150 terms, 100 chars each. |
GET | /dashboard/api/translate/spend | translation-subscriber | Returns 6-month spend breakdown by month and model. Admin sees all accounts; customers see own spend only. |
GET | /dashboard/api/translate/refunds | admin | Returns refund history for all translation purchases. Admin-only. |
GET | /dashboard/api/translate/notifications | translation-subscriber | Returns unread notification count and recent completed job summaries for the authenticated user. |
POST | /dashboard/api/translate/notifications/seen | translation-subscriber | Marks all notifications as seen. Resets badge count to 0. |
GET | /dashboard/api/translate/download/{job_id} | translation-subscriber | Returns presigned S3 download URLs for each translated file. Links expire in 7 days. |
| Method | Path | Description |
|---|---|---|
POST | /dashboard/api/admin/impersonate | Start impersonation session for a target email. Body: {"email":"..."}. Returns new session token with target's roles + impersonated_by field. |
POST | /dashboard/api/impersonate/end | End impersonation, restore admin session. |
GET | /dashboard/api/admin/analytics | Admin-only analytics panel. Queries usage_logs directly from Aurora (no 93-day TTL). Filters: date range, asset, api_key_id, source, node. Returns detail + summary views with breakdowns by asset, source, node, api_key, and day. |
GET | /dashboard/api/admin/support/tickets | List all support tickets. Filters: ?status=, ?category=. Returns up to 200 tickets ordered by most recently updated. |
GET | /dashboard/api/admin/support/tickets/{id} | Full ticket detail by UUID — includes all replies (internal notes visible), customer IP, email, name. |
POST | /dashboard/api/admin/support/tickets/{id}/reply | Post admin reply. Body: {"message":"...", "is_internal": false}. Internal notes hidden from customer. Customer-visible replies auto-translated and emailed. |
POST | /dashboard/api/admin/support/tickets/{id}/status | Update ticket status. Body: {"status":"open"}. Valid: new, open, in_progress, awaiting_customer, resolved, closed. Audit-logged. |
POST | /admin/translate/refund/{quote_id} | Issue full or partial refund for a translation purchase. Body (optional): {"amount": 5.00, "reason": "..."}. Processes via Stripe, sends confirmation email to customer. |
All /dashboard/api/* JSON endpoints return the standard 12-field Unified Messaging Envelope — the same structure used by the LPO and LRS APIs. There are no exceptions. Every success, every error, every auth failure uses the same shape.
On success, data contains the endpoint-specific payload and error is an empty string:
{
"status": "✅ [LPO] [us-east-2] [BeastMain] [/dashboard/api/account] [200]",
"status_code": 200,
"endpoint": "/dashboard/api/account",
"cluster_node": "BeastMain",
"region": "us-east-2",
"language": "en",
"api_key_id": "ak_demo123",
"ip_address": "203.0.113.42",
"agent_profile_arn": "arn:tbi:us-east-2:211998422884:agent-profile/tbi-dashboard/v1",
"timestamp": "2026-05-17T18:30:00Z",
"data": {
"email": "user@example.com",
"display_name": "Cory Dean",
"preferred_lang": "en",
"created_at": "2026-01-15T00:00:00Z",
"roles": ["api-subscriber", "donor"],
"is_admin": false,
"api_key": {
"api_key_id": "uuid",
"api_key_masked": "tbcc-****-****-****",
"tier": "pro",
"subscription_status": "active",
"usage_this_month": 12847,
"quota": 50000,
"rate_limit_qps": 10,
"burst_limit": 50,
"lrs_enabled": false,
"next_renewal": "2026-06-15"
},
"donor": {
"stripe_customer_id": "cus_...",
"amount_cents": 2500,
"currency": "usd",
"interval": "month",
"status": "active",
"next_renewal": "2026-06-01",
"lifetime_total_usd": "125.00"
},
"webhook": null,
"partner": null
},
"error": ""
}
On error, data is null and error contains the bracket-prefixed message:
{
"status": "🛑 [LPO] [us-east-2] [BeastMain] [/dashboard/api/account] [401]",
"status_code": 401,
"endpoint": "/dashboard/api/account",
"cluster_node": "BeastMain",
"region": "us-east-2",
"language": "en",
"api_key_id": "ak_demo123",
"ip_address": "203.0.113.42",
"agent_profile_arn": "arn:tbi:us-east-2:211998422884:agent-profile/tbi-dashboard/v1",
"timestamp": "2026-05-17T18:30:00Z",
"data": null,
"error": "🛑 [LPO] [us-east-2] [BeastMain] [/dashboard/api/account] [401] Session expired or invalid"
}
The SPA's apiFetch() function handles envelope unwrapping transparently:
json.data — panel code never sees the enveloperes.status before JSON parsing — clears session, shows loginerror field (strips the bracket prefix) and throws it as an Error for the panel to display in a flash messageThis means all existing panel code that consumes apiFetch results continues to work unchanged — the envelope is invisible to panel rendering logic.
Two response types bypass the UME envelope by design:
format=csv, format=tsv, format=text on admin analytics) — these return raw file content with appropriate Content-Type and Content-Disposition headers/dashboard/api/reports/*) — these pass through the LRS response verbatim, which is already UME-wrapped by the LRS serverAdmin accounts (identified by the admin_email application parameter) can impersonate any account for support and debugging purposes. Impersonation creates a new session with the target account's roles plus an impersonated_by field recording the admin's email.
| Property | Behavior |
|---|---|
| Visual indicator | Red banner always visible: "You are viewing [email]'s account as admin" |
| Audit logging | Both the admin and the target account are audit-logged on impersonation start and end |
| Session isolation | Impersonation creates a new session token — the admin's original session is preserved |
| End impersonation | POST /dashboard/api/impersonate/end — restores the admin's original session |
| Access | Admin sees the target's full dashboard exactly as the target would see it |
The impersonation panel appears at the bottom of the Overview panel when logged in as admin. It is only visible when is_admin: true is returned by the account endpoint.
https://api.cpmp-site.org/dashboard using your admin email (corydeankalani@cpmp-site.org).contact@cpmp-site.org for Homer Simpson).Use impersonation to see exactly what each test account sees without logging out and back in. The four test accounts and their roles:
| Name | Role | What to look for | |
|---|---|---|---|
corydeankalani@cpmp-site.org | Cory Dean Kalani | Admin + Lifetime | Impersonation card visible, LRS Reports in sidebar, unlimited usage, no rate limits panel |
contact@cpmp-site.org | Homer Simpson | Pro API Subscriber | Usage panel with 30-day chart (3,330 logs), amber quota bar at 62%, Rate Limits panel (10 QPS / 50 burst) |
support@cpmp-site.org | Bugs Bunny | Webhook Associate | Webhook Configuration panel with 9 assets, UDP + HTTPS endpoints, 4,320 pushes this month |
admin@cpmp-site.org | Tony Stark | Partner | Connection panel with PrivateLink status (Active), Asset Watchlist wizard with chip-picker, Reports link in sidebar, no rate limits |
If you want to test an account as that user would experience it (without the admin red banner), use the magic link flow directly:
https://api.cpmp-site.org/dashboard.The Trinity Beast <No-Reply@CPMP-Site.org>.Note: Magic links expire in 15 minutes and are single-use. Rate limit is 30 requests per hour per email address.
| Data | Source | Table / Key | Notes |
|---|---|---|---|
| API key details, usage, tier | Aurora | api_keys JOIN users | Identity anchored on users.email |
| 30-day daily usage breakdown | Aurora | usage_logs | Grouped by day in EST timezone |
| Webhook configuration | Aurora | webhook_subscriptions | Joined via api_key_id |
| Donor subscription status | Stripe | Customer search + subscriptions API | Non-fatal if Stripe is unavailable |
| Donation history | Stripe | Charges API | Last 12 months, succeeded + captured only |
| Billing portal URL | Stripe | Billing Portal Sessions API | Created on demand, not cached |
| Admin email | Aurora | application_parameters WHERE key = 'admin_email' | Single row lookup |
| Sessions, magic links, rate limits | Valkey | session:*, magic:*, ratelimit:* | See Section 4 |
| Audit log | Valkey | audit:dashboard:{email} | Sorted set, last 500 events, 90-day TTL |
| Dashboard session (client) | Browser | localStorage cpmp_user | JSON: token, email, name, roles, lang — cleared on logout |
| Language preference (client) | Browser | localStorage cpmp_site | JSON: lang — persists across sessions, survives logout |
| Concern | Mitigation |
|---|---|
| Password exposure | No passwords — magic link only |
| Token theft | Tokens are 256-bit random, stored hashed in Valkey, transmitted only in Authorization header (not cookies) |
CSRF | Not applicable — Bearer tokens in Authorization header are not auto-sent by browsers |
| Email enumeration | Request-link always returns 200 regardless of account existence |
| Magic link replay | Atomic GETDEL — token consumed on first use, cannot be replayed |
| Brute force | Rate limiting: 30 magic link requests/hour per email, 60/hour per IP |
| Session fixation | New session token generated on every login — magic link token and session token are separate |
| API key exposure | Key masked by default, full key only returned on explicit Reveal action, audit-logged |
| Impersonation abuse | Admin-only, both parties audit-logged, red banner always visible, original session preserved |
XSS | All user-supplied values escaped via esc() helper before DOM insertion |
| Key Pattern | Type | TTL | Contents |
|---|---|---|---|
session:{sha256(token)} | STRING | 24h sliding | JSON Session object — email, roles, timestamps, IP, user_agent, impersonated_by |
magic:{sha256(token)} | STRING | 15 min | JSON MagicLinkPayload — email, requested_at, IP, user_agent |
ratelimit:magic:email:{email} | STRING | 1 hour | Integer counter — magic link requests from this email |
ratelimit:magic:ip:{ip} | STRING | 1 hour | Integer counter — magic link requests from this IP |
audit:dashboard:{email} | ZSET | 90 days | Sorted set of audit events, scored by Unix ms timestamp. Max 500 entries. Events: login, logout, magic-link-requested, api-key-revealed, billing-portal-opened, impersonation-start, impersonation-end. |
support:resolve:{token} | STRING | 30 days | Maps a single-use resolve token to a ticket UUID. Consumed atomically via GETDEL when the customer clicks the email resolve link. |
| Component | Status | Notes |
|---|---|---|
| Magic link auth flow | ✅ Live | SES email, 15min TTL, single-use atomic GetDel |
| Session management | ✅ Live | 24h sliding TTL, Bearer token, Valkey-backed |
| Account resolver | ✅ Live | Aurora + Stripe → typed relationships + roles |
| SPA shell + routing | ✅ Live | All panels rendered client-side, role-aware sidebar |
| Overview panel | ✅ Live | Adapts to all account types |
| Giving Overview panel | ✅ Live | Donor data, Stripe portal button, invitation card when no history |
| Donation History panel | ✅ Live | 12 months of Stripe charges |
| Impact panel | ✅ Live | Photo gallery, static content |
| Profile panel | ✅ Live | Editable display name, email change with verification, language preference |
| Billing panel | ✅ Live | Stripe portal session on demand |
| API Key panel | ✅ Live | Masked display, reveal + copy |
| Usage panel | ✅ Live | Monthly total + 30-day bar chart |
| Rate Limits panel | ✅ Live | QPS, burst, quota from the resolved API key |
| Webhook Configuration panel | ✅ Live | Full config display + dashboard-native activation flow. Asset wizard with categorized chip-picker, volume sort toggle, tier-aware limits. |
| Partner Connection panel | ✅ Live | PrivateLink status card, asset watchlist wizard (uncapped), Reports sidebar link. Connection status reports "active" for provisioned keys. Live CloudWatch/TCP health probe planned for post-onboarding. |
| Support panel | ✅ Live | Full inline ticket history, submit new tickets, reply to existing tickets. Customer and Admin views with filtering. |
| Admin impersonation | ✅ Live | Full impersonation with audit trail |
| Admin Analytics panel | ✅ Live | Aurora-based usage analytics (no 93-day TTL). Filters: date range, asset, api_key_id, source, node. Detail + Summary views with bar charts. Export in JSON/CSV/TSV/Text. Admin-only (sidebar "📈 Analytics" under Admin section). |
| LRS Reports panel | ✅ Live | Interactive panel with Usage/Summary tabs, date range picker, asset filter, pagination (30/60/90), export (JSON/CSV/TSV/Text). Proxies to local LRS via /dashboard/api/reports/*. Available to all tiers — same monthly report limits apply. |
| Webhook Delivery Log panel | ✅ Live | Grouped push-event table (sortable, expandable to per-asset detail) + 3-minute Activity Rollup card. Capped at the 50 most recent pushes. |
| Webhook Delivery Reports panel | ✅ Live | Aurora-backed daily/summary rollup, date-range filtering, 4-format export, manual refresh with cooldown. Not LRS-gated — ownership of the subscription is the only requirement. |
| Webhook "Activate Delivery" flow | ✅ Live | Dashboard-native activation of a pending_verification subscription — no separate token-verification round trip needed for an already-authenticated session. |
| Webhook Wizard | ✅ Live | Categorized asset picker with volume-sort toggle, name tooltips, and uncapped (?full=1) full-catalog mode; endpoint configuration; plan-aware limits (9 / 30 / 60 / 60 assets by tier — Professional and Enterprise share a ceiling) |
| Dashboard icon system | ✅ Live | 58-icon SVG sprite (Lucide-derived) replacing emoji throughout the navigation and panels. Language picker's flag emoji intentionally out of scope. |
| Backend error-message localization | ✅ Live | All dashboard/api error responses render in the session's language via a Valkey-backed loader (dashboard:errmsg), synced nightly from S3 by BeastReconciler with an on-demand admin reload endpoint for same-day changes. |
| Partner connection live health | ⏳ Post-onboarding | CloudWatch or TCP probe — relevant once a real partner creates a VPC endpoint. Current "active" status is truthful for all provisioned keys. |
| Dashboard i18n | ✅ Live | All panel labels, button text, and status strings delivered via dashboard.* i18n namespace in all 12 language JSON files. SPA reads cpmp_site.lang from localStorage and applies translations dynamically. Fully multi-lingual. |
| Mobile polish | ⏳ Planned | Responsive layout improvements |
| Announcement email | ⏳ Pre-launch | Email all active subscribers when testing is complete |
The following panels show exactly what each test account sees when logged in to the dashboard. Data reflects the current seed state in Aurora. Each account uses a cartoon character as the display name — a convention that keeps test data obviously fictional.
Note the sidebars are identical. Every account below navigates the same seven sections, whatever they have purchased — the only difference is the Admin section on 12.1. What varies between these accounts is the content of each panel: Homer opening the Webhook panel sees an invitation to the push product, while Bugs Bunny sees his live configuration. Reading these walkthroughs side by side is the clearest way to see the invitation model at work.
All panel labels, button text, status strings, and UI copy are delivered in the user's preferred_lang. The dashboard.* i18n namespace is present in all 12 language JSON files and loaded dynamically by the SPA. Language detection is a four-step chain: cpmp_user.lang (the authenticated user's own preference) → cpmp_site.lang (the site-wide picker, shared with the main website) → navigator.languages filtered to the 12 supported codes → English fallback. The dashboard is safe to announce to all subscribers regardless of language.
The admin account. The only account that sees the Admin section (Analytics, Person View) and holds the impersonation capability. LRS Reports enabled, unlimited usage. The red impersonation banner appears when viewing another account.
When impersonating, a red banner appears at the top of every panel:
A rate-limited pro subscriber. 31,204 of 50,000 requests used this month (62% — amber progress bar). 30-day usage chart shows realistic ramp-up with heavier recent activity.
Rate Limits panel for Homer's pro tier:
A webhook standard subscriber. 9 assets configured, 15-second push interval, both UDP and HTTPS delivery endpoints active. 4,320 pushes delivered this month.
An AWS PrivateLink partner account. High-volume, low-latency access via internal network. No rate limits, no monthly quota. LRS Reports included free by tier policy. Asset Watchlist wizard for dashboard-level organization (does not restrict access). Connection panel shows PrivateLink status and SLA tier.