The staff dashboard
The dashboard is a Next.js app a merchant's staff sign in to, to see what happened to that merchant's payments. Its founding decision is that it observes and does not administer: it reads records, it never changes configuration, and it never holds a merchant API key. In v0.4.1 it is read-only — a staff member can sign in and read payments, refunds, webhook deliveries, customers and checkout sessions, and cannot do anything at all to any of them. It has never run in a deployment.
Agents working on this should load vpay-dashboard.
The boundary
A
/dash/v1request reads exactly one tenant's rows: the onedashboard_client.merchant_idnames, fixed in YAML and checked at boot.
The tenant does not come from the caller's credential, the way /v1 resolves one. No claim in any token can change it. A deployment whose staff must see two merchants registers two dashboard clients.
Four checks stand between a request and a row, each in a different place so no single edit removes the boundary:
- The token validates — signature, expiry, issuer, and an audience equal to the registered
dashboard_client.client_id— against vpay's own JWKS. - It carries a
vpay_merchant_idclaim equal to the bound merchant. Only the staff sign-in grant stamps that claim, so noclient_credentialstoken can ever read/dash/v1. The claim is compared, never used: a forged one buys a403, never another merchant's rows. - It carries the registration's single scope.
- Every query filters by the bound
merchant_id.
Any method other than GET/HEAD is refused by the boundary before the router matches, with a 403. That is what makes "read-only" structural rather than a promise: a write mounted later cannot arrive unlogged. Two boot checks back this up — a dashboard_client.merchant_id no merchant registers is fatal, and so is a merchant registration listing the dashboard's client id in its allowed_audiences. A deployment with no dashboard_client mounts no /dash/v1 at all.
How a staff member gets that token is Dashboard authentication.
From browser to /dash/v1
The browser never holds a vpay token. It holds an httpOnly session cookie on the dashboard's own origin; the dashboard's server looks up the /dash/v1 access token in the staff_sessions row on every render and makes the call.
flowchart LR
B["Staff browser<br/>httpOnly session cookie"] --> P["Dashboard pages<br/>Server Components, requireStaff()"]
B -.->|"GET only, same-origin"| F["BFF route handlers<br/>/api/dash/*"]
P --> T["/dash/v1 access token<br/>read from staff_sessions row"]
F --> T
T --> R["GET /dash/v1/payment_intents<br/>GET /dash/v1/payment_intents/{id}"]
T --> Q["POST /dash/v1/$procs/search...<br/>refunds, deliveries, customers, checkouts"]
R --> DB[("Postgres<br/>bound merchant only")]
Q --> DB
X["Merchant API key"] -.->|"never held here"| P
W["Writes and audit_log"] -.->|"not built, refused with 403"| R
- Pages read on the server. Each page is a Server Component that calls
requireStaff()and reads/dash/v1itself, then hands the rows to Refine's hooks as initial data. A browser-side read was tried and withdrawn after it broke the end-to-end suite. - The BFF (
/api/dash/*) is a same-origin,GET-only proxy that authenticates on the same cookie. A request with no cookie never reaches vpay; a request the dashboard did not issue is refused by anOrigin/Sec-Fetch-Sitecheck; the bearer token appears in no response header or body; no caller-supplied merchant id, audience or scope is forwarded. Every other method gets a405. It was attacked in a security review and driven from a real browser; the payments pages still issue no read through it. - The payments list is served by two REST routes and is cursor-paged, like the merchant API. The other four lists are CrateStack procedures under
/dash/v1/$procs/, which the dashboard's server calls withPOSTon the browser's behalf; they are offset-paged and carry no filter yet.
The dashboard is not an SDK surface: the merchant SDKs speak /v1, and a /dash/v1 method in one would be a merchant credential reaching for a staff surface. The detail response is vpay's own shape (object: "dashboard.payment_detail"), not a pretend Stripe object.
The screens
| Page | What it shows |
|---|---|
/login, /login/totp, /login/password | The sign-in — see authentication |
/payments | The bound merchant's intents, newest first; status and created-range filters, cursor paging |
/payments/{id} | The intent, its charge, its refunds, the last error with its failure code, the event timeline |
/refunds, /deliveries, /customers, /checkouts | Read-only lists, one page at a time, no filter controls |
The navigation is generated from one array and a test fails if it ever links to a page that does not exist — a menu entry for an unwritten page is the same lie as an empty table.
What a screen may show
A dashboard that looks complete is the failure mode this project fears most, so the screens are held to rules about absence:
- No "Rail" column on the list. The list response carries no charge, only
payment_method_types— the rails an intent may use. So the column is headed Methods. The detail page has a real rail, from the charge. - The payer's phone is always a dash.
charges.payer_ref_maskedis never written, so the detail page renders a realnullas—and must never fall back to the unmasked value. There is no payer column on the list and no search by phone, because both would be sourced from a column that is always empty. - No page count on the payments list. Its route returns cursors and
has_more, never a total, so "page 3 of 12" would be invented. "Next" appears only whenhas_moresays so. - An unknown status renders as text, not as a coloured pill. A green badge on an unfamiliar status is a claim.
- The timeline names what it cannot show. Under it, the page lists the five documented event types that nothing writes (
payment_intent.created,payment_intent.processing,payment_intent.canceled,charge.refunded,charge.refund.updated), so a one-line timeline is not read as the whole history. - The merchant is on every page, beside the staff member's address, so an empty list is distinguishable from looking at the wrong merchant.
- Payer credentials never appear. The checkout list's source type does not even carry
client_secret_suffixorreturn_token. - Configuration fails closed. The API URL, client id, redirect URI and scope are read at container start; if one is missing,
/loginnames the variables and offers no form.
What it cannot do
- Anything to a payment. ADR-0008 describes per-record writes — re-poll a charge, replay a webhook, issue a refund, annotate a charge — each with an
audit_logrow. None is built, so there is noaudit_logeither. - Balances and the ledger, settings, rail health (slices 4–6) are not started. Webhook deliveries are a list only; retries and signature replay are not started.
- It has never been deployed. The Helm chart templates a Deployment for the app (
dashboard.enabled, off by default) and a separate/dash/v1-only backend tier (management.enabled). The image was run by hand with a read-only root filesystem and answered its health path — but no Kubernetes pod has ever run, of this app or anything else in the chart.
Two deployment traps, already documented
The management tier does not serve POST /v1/oauth/token (that mints the merchant credential); the staff grant is /dash/v1/oauth/token. And publishing /dash/v1 through the chart's HTTPRoute with networkPolicy.enabled requires the Gateway's namespace in networkPolicy.managementIngress.namespaceSelector — a chart guard refuses the combination. Whether this tier should face a public gateway at all is an open maintainer decision. See Deployment.
Status in v0.4.1
| Part | Status | Evidence |
|---|---|---|
/dash/v1 boundary, the two payment reads, boot refusals | Built and tested | backends/tests/integration/tests/dashboard_read_surface.rs over a booted server and real Postgres |
| Sign-in and the pages, in a real browser | Built and tested | dashboard.cy.ts signs in through the real OP against the compose stack and reads payments |
| Refunds, deliveries, customers, checkouts lists | Partly built | Read-only lists over CrateStack procedures; no filters |
| The BFF read surface | Partly built | Reviewed and browser-tested; no page reads through it |
Writes and audit_log | Not built | Refused at the boundary; ADR-0008's writes are designed, unbuilt |
| Slices 4–6 | Not built | Not started |
| Real data on screen | Partly built | Every payment it has shown settled against a WireMock rail |
| Running in Kubernetes | Not built | Chart templates exist; no pod has ever run |
The full record is dashboard.md § Status and its three status pages. Overall standing is on Status.
Go deeper
- The dashboard flow — the invariant, the slices, the boundary
- What slice 1 did not build — including the rules about absence
- The read seam, the BFF, and its security review
- What is built and proven
- ADR-0008: the dashboard observes; it does not administer
- ADR-0022: surface isolation and independent scaling
- The app's README
- Skills: vpay-dashboard, vpay-frontend