diff --git a/.gitignore b/.gitignore index 680d2fe7..6854765b 100644 --- a/.gitignore +++ b/.gitignore @@ -101,3 +101,8 @@ docker-compose.override.yaml # 0120 conformance run reports (regenerable) conformance-0120-report-*.json + +# Per-developer MCP configuration — dev tooling, not shipped code. Keep +# your own copy locally; a committed one auto-configures a third-party +# server for everyone who opens the workspace (PR #249 review). +.mcp.json diff --git a/docs/runbooks/0072-current-prices-mv-rollout.md b/docs/runbooks/0072-current-prices-mv-rollout.md index 39d186cc..2f30ee45 100644 --- a/docs/runbooks/0072-current-prices-mv-rollout.md +++ b/docs/runbooks/0072-current-prices-mv-rollout.md @@ -95,13 +95,13 @@ grep -c "arrayReduce('median'" /tmp/0072-rollback-mv_current_prices.sql That last check is the sanity gate, and **its expected value depends on which upgrade you are running.** The artifact must capture the definition prod is on -*right now* — the one you would roll back to — so assert the predecessor you +_right now_ — the one you would roll back to — so assert the predecessor you actually expect: -| rolling out | prod's predecessor | `arrayReduce('median'` | -|---|---|---| -| 0072 onto v1 | v1, no median filter | **0** | -| 0135 onto 0072 | 0072, median filter present | **>= 1** | +| rolling out | prod's predecessor | `arrayReduce('median'` | +| -------------- | --------------------------- | ---------------------- | +| 0072 onto v1 | v1, no median filter | **0** | +| 0135 onto 0072 | 0072, median filter present | **>= 1** | Getting either wrong means the artifact does not restore what you think it does — stop and re-plan before anything mutates. diff --git a/infra/src/lib/stacks/portal-hosting-stack.ts b/infra/src/lib/stacks/portal-hosting-stack.ts index 7c1a670a..f5c3f90e 100644 --- a/infra/src/lib/stacks/portal-hosting-stack.ts +++ b/infra/src/lib/stacks/portal-hosting-stack.ts @@ -238,6 +238,36 @@ var REDIRECTS = { '/api-tokens/api': '/api-tokens/api/' }; +// The portal's client-side routes, served by the portal's own index.html. +// +// Without this, a hard refresh or a pasted link to one of them resolves +// against S3, which grants s3:GetObject and NOT s3:ListBucket — so the missing +// key comes back as 403 AccessDenied XML rather than a 404, and the visitor +// gets a bare AWS error page instead of the app. The router cannot help, +// because the bundle never loads. +// +// An ALLOW-LIST of literals, not a catch-all rewrite of every extension-less +// path. A catch-all would answer 200-with-index.html for genuinely missing +// objects too, which turns a broken deploy — a hashed chunk that did not +// upload — into an app that silently renders the wrong thing. It also keeps +// the open-redirect property the stack note above insists on: nothing from the +// request is interpolated into a URI. +// +// Both slash forms, because the trailing-slash branch below would otherwise +// rewrite '/api-tokens/login/' to '/api-tokens/login/index.html' and 403. +// +// WARNING: add a route to web/portal/src/landing/links.ts and you must add +// it here too. Task 0195 is where this stops being a hand-maintained list and +// becomes the per-prefix SPA fallback. +var APP_ROUTES = { + '/api-tokens/login': '/api-tokens/index.html', + '/api-tokens/login/': '/api-tokens/index.html', + '/api-tokens/dashboard': '/api-tokens/index.html', + '/api-tokens/dashboard/': '/api-tokens/index.html', + '/api-tokens/quick-start': '/api-tokens/index.html', + '/api-tokens/quick-start/': '/api-tokens/index.html' +}; + function handler(event) { var request = event.request; var uri = request.uri; @@ -246,6 +276,12 @@ function handler(event) { if (typeof REDIRECTS[uri] === 'string') { return redirect(REDIRECTS[uri]); } + // Before the trailing-slash branch, which would otherwise append index.html + // to the directory form and miss. + if (typeof APP_ROUTES[uri] === 'string') { + request.uri = APP_ROUTES[uri]; + return request; + } if (uri.slice(-1) === '/') { request.uri = uri + 'index.html'; return request; diff --git a/lore/1-tasks/active/0193_FEATURE_portal-presentable-ui-pass.md b/lore/1-tasks/active/0193_FEATURE_portal-presentable-ui-pass.md index 59dc4cb1..88d72b48 100644 --- a/lore/1-tasks/active/0193_FEATURE_portal-presentable-ui-pass.md +++ b/lore/1-tasks/active/0193_FEATURE_portal-presentable-ui-pass.md @@ -27,6 +27,21 @@ history: path prefix on an existing domain — `https://sorobanscan.rumblefish.dev/` with the landing page at `/api-key` — so this slice's screens are the whole visible surface of the self-service onboarding epic. + - date: 2026-08-27 + status: active + who: akot + note: > + Review round on PR #249 (karczuRF, stkrolikiewicz): 25 findings, 22 + confirmed against the code, 2 needing a browser, 1 with a caveat. All + addressed in six commits except the one that is a measurement — + `pending_absent` at sign-in, which needs the Discord Developer Portal + scope and a local run against the real guild (runbook §1 step 3, §5) + and is Adam's to do before merge. Decisions #3-#9 below emerged from + it; 0227 spawned for the design-vs-OpenAPI reconciliation; 0191 + amended a second time (the landing restated the superseded model). + Two of Adam's 2026-08-25 calls reversed on review, both recorded at + the render site: "Last rotated" → "Last updated", and 0188's lag line + back under the meter. Portal 156 tests (+4), Rust lib 162 (+1). --- # Make the portal presentable @@ -44,7 +59,8 @@ this point; this slice is about it being legible. ## Context The rule that keeps this task honest: **it re-decides no copy.** The wording of -the two eligibility refusals is [[0189]]'s, the `delete-key` modal is [[0191]]'s, +the two eligibility refusals is [[0189]]'s, the `regenerate-key` modal is [[0191]]'s +(phrase amended there on 2026-08-27, decision 41, after this slice changed it), the revoke confirmation and its "no replacement is issued" line are [[0191]]'s (0192 merged into it), the `GetUsage` lag line is [[0188]]'s. If this slice finds one of them wrong, fix it in the owning task rather than quietly here — otherwise the reason behind the @@ -97,6 +113,58 @@ wording is lost and the next person edits it back. - [ ] No copy owned by another slice was changed here without changing it there - [ ] Epic AC 2 and AC 4 satisfied from the user's side +## Design Decisions + +### From Plan + +1. **Re-decide no copy.** Every sentence another slice owns is rendered + verbatim or amended in the owning task (0191 twice, 0188 honoured). +2. **Two screens, MUI 7 + Emotion, no third-party scripts.** As the epic and + the 2026-08-07 stack decision say. + +### Emerged + +3. **The 429 on the quick start is the measured one, not the design's.** + Measured 2026-08-27 against the production free plan: `429`, + `x-amzn-errortype: TooManyRequestsException`, + `{"message":"Too Many Requests"}`, no `Retry-After`. The frame's + `RATE_LIMIT_EXCEEDED` + `Retry-After: 1` existed nowhere in the repo. The + quota-exhausted body is not shown because it was not measured. +4. **No legal footer until the documents exist.** "By continuing you agree to + our Terms of Service and Privacy Policy" is not rendered — two underlined + ``s asked the visitor to agree to documents they could not open. + Returns as links when the URLs land in `links.ts`. +5. **Prerequisites are stated before the button again.** On `/login` — the + only page with the Discord button since the landing lost its card — as one + line above the control, 0189's words, tertiary type; on the landing page, + the FAQ row that carries them is open by default, so it states them + without a click. Restores what 2026-08-26 moved into a collapsed FAQ. The + acceptance criterion stands as written; the frame draws neither. +6. **"Last updated", not the frame's "Last rotated"** — reverses 2026-08-25. + The value is `lastUpdatedDate`, nothing rotates (0191), both ends of the + contract said so. +7. **0188's lag line is back**, restyled small under the meter — reverses + 2026-08-25's removal. 0188 owns the decision and this slice restyles it. +8. **The quick start's HOST is ours** (the execute-api base + `docs/scf/api-endpoints.md` documents); the design's paths stay and are + 0227's. A page that renders a credential does not aim it at another domain. +9. **`SWAGGER_UI` → `API_REFERENCE`.** The constant names what it opens; 0195 + re-points it. +10. **`.mcp.json` is not committed.** Dev tooling that auto-configures a + third-party server for everyone; per-developer, gitignored. +11. **A failed session request says which request failed.** + `SessionState.failed` carries `while: 'checking' | 'signing-out'`; the + dashboard renders it with a retry instead of redirecting to the landing + page, and a failed sign-out is never rendered as a successful one. + +## Future Work + +- 0227 — reconcile the landing page and quick start with the real OpenAPI + (paths, example fields, `source`, placeholder key, Figma). +- The popup's 1500 ms grace (`POPUP_MESSAGE_GRACE_MS`) may lose a + `postMessage` on a cold cache (review, plausible, not reproduced) — see the + note at `afterGrace` before changing it. + ## Notes - Structural precedent from the explorer: it splits `web/` from `libs/ui` and diff --git a/lore/1-tasks/archive/0189_FEATURE_eligibility-gate-discord-membership-and-account-age.md b/lore/1-tasks/archive/0189_FEATURE_eligibility-gate-discord-membership-and-account-age.md index e93d6d38..ddcadc78 100644 --- a/lore/1-tasks/archive/0189_FEATURE_eligibility-gate-discord-membership-and-account-age.md +++ b/lore/1-tasks/archive/0189_FEATURE_eligibility-gate-discord-membership-and-account-age.md @@ -111,8 +111,43 @@ Detail and reasoning: archived `0180_RESEARCH_.../notes/R-discord-member-endpoint-response-shape.md` and `notes/G-measurement-runbook.md`. Do not re-derive them. +### Item 2 — measured 2026-08-27: **`pending` IS present** + +| # | Result | How | Date | +| --- | --- | --- | --- | +| 2 | **Present.** The REST member response carried `pending: false`. | Local `serve` (`scripts/measure-pending-absent.sh`), guild `1536303837785362432`, account `kotryba`, one full sign-in round-trip | 2026-08-27 | + +**The evidence is an absence, so the chain is written out.** The log +(`/tmp/portal-pending-absent-20260827T164107.log`) carries +`portal issued an API key key_id=smdesqkg5j created=false` at 14:42:28 and +**zero WARN or ERROR lines of any kind** — no `pending_absent`, no +"membership could not be verified", no `outcome = "unknown"`. Issuance on the +sign-in path runs only from `issue::after_sign_in`, which `auth/mod.rs` reaches +only after `match membership` falls through on `Membership::Member`, and +`eligibility::membership` returns `Member` only for `pending == Some(false)`. +So the field was present and false. + +**What this closes:** risk R1's worst case — "if the field turns out never to be +sent, EVERY member is refused, indefinitely, and it looks exactly like a Discord +outage". Discord does send it on this route. That was the fear behind +[[0193]]'s review blocker (PR #249, karczuRF) and it is disproved. + +**What it does not close, and must not be read as closing:** + +- **This is one guild, and not the production one.** Production gates on the + real Stellar Developers guild (`897514728459468821`, [[0179]] step 4). Re-run + the script with `GUILD=897514728459468821` and an account that is a member. +- **Item 4 is still open and is now the interesting one.** If the guild measured + here has Membership Screening **off**, then `pending: false` arrives without + screening at all — which is more than item 2 asked and would settle item 4 in + the same breath. Adam owns that server; confirming the setting (Server + Settings → Members) turns one measurement into two. Recorded as unconfirmed + rather than assumed. +- Items 1, 3 and 5 remain unmeasured. + > **Status 2026-08-20 — items 1–5 deferred to the operator (Adam), tables -> deliberately left empty.** The archived result tables were checked before +> deliberately left empty.** *(Item 2 measured 2026-08-27 — see the table +> above. The rest of this note stands.)* The archived result tables were checked before > implementation and are **empty placeholders** (`status: seed`, "nothing > measured yet"); no dated results exist to carry in, and none are invented > here. Every prerequisite is operator-owned and unmet: the Discord app diff --git a/lore/1-tasks/archive/0191_FEATURE_rework-key-once-per-quota-period.md b/lore/1-tasks/archive/0191_FEATURE_rework-key-once-per-quota-period.md index bfc6111a..0e4afe04 100644 --- a/lore/1-tasks/archive/0191_FEATURE_rework-key-once-per-quota-period.md +++ b/lore/1-tasks/archive/0191_FEATURE_rework-key-once-per-quota-period.md @@ -4,7 +4,7 @@ title: "Replace my key — revoke now, re-issue next quota period (merged with 0 type: FEATURE status: completed related_adr: ["0010"] -related_tasks: ["0183", "0157", "0160", "0180", "0187", "0189", "0190", "0192", "0193", "0221"] +related_tasks: ["0183", "0157", "0160", "0180", "0187", "0189", "0190", "0192", "0193", "0221", "0164"] tags: [layer-backend, priority-medium, effort-medium, milestone-M3, epic-self-service-onboarding, api-gateway, usage-plan, security, slice-8, slice-9] milestone: 3 links: @@ -91,6 +91,14 @@ history: added (`apigateway:PATCH` on `/apikeys/*`, tag-scoped in its own sid). One acceptance criterion deferred, not dropped: the `MONTH` rollover confirmation needs 1 September 2026, spawned as [[0221]]. + - date: "2026-08-27" + status: completed + who: akot + note: > + Amendment, written from [[0193]]: the phrase that arms the modal is + `regenerate-key`, not `delete-key` — decision 41 below. Status + unchanged; nothing else in this task is reopened. ADR 0010 §8 and + [[0164]]'s checklist re-pointed in the same change. --- # Rework — a new key, once a period @@ -808,3 +816,47 @@ prices-api` 0 failed, `clippy --all-targets -D warnings` clean, portal 95/95, `into_service_error` was re-verified against the resolved `aws-smithy-runtime-api` 1.12.3 source (the non-`ServiceError` arm builds an unhandled error, it does not panic). + +## Amendment — 2026-08-27, via [[0193]]: the arming phrase is `regenerate-key` + +Decision 5 and the spec above say the confirm stays disabled **until the +user types `delete-key`**. Since 2026-08-25 the dashboard control has said +**Regenerate**, the dialog's heading is "Regenerate API key?" and its button +"Regenerate" (the 0193 frame), and on 2026-08-26 Adam changed the phrase to +follow the button. [[0193]] found the change during its review round and, +under its own rule ("fix it in the owning task rather than quietly here"), +records it here rather than in the styling task. + +41. **The arming phrase is `regenerate-key`.** A dialog headed "Regenerate" + that demands the word `delete` asks the visitor to agree to a different + sentence from the one they just read; the phrase follows the button so the + two say the same thing. **What does not change:** the REASON for a typed + phrase — this is destructive, it must not be reachable by one stray click + — and everything else decision 5 pins: the old key dies immediately, no + replacement is issued now, confirm disabled on submit, the refusal + renders a calendar date. The FAQ on the landing page is a second place + that copy lives (`web/portal/src/landing/Faq.tsx`); a future change to the + wording has two targets. + + Re-pointed in the same change: ADR 0010 §8 ("the `regenerate-key` modal"), + [[0164]]'s quiet-failure check and acceptance criterion (a tester following + the old checklist would type `delete-key`, see confirm stay disabled, and + file the dialog as broken), and [[0193]]'s own context line. Code: + `REWORK_CONFIRM_PHRASE` in `web/portal/src/app/app.tsx`, with the spec + `keeps confirm disabled until the visitor types regenerate-key`. + + **Second amendment, same day, same route.** [[0193]]'s review (PR #249) + found two landing-page sentences transcribed from the Figma file that + stated the model this task superseded: the FAQ's "The replacement is + issued straight away and the old key stops working" and the claims + card's "Rotate once per month if needed". Both now restate decision 5 — + deactivated at once, nothing issued until the next quota period, and + "regenerate" rather than "rotate" — in `landing/Faq.tsx` and + `landing/DeveloperDashboard.tsx`. A third, found the same day while + driving the dashboard in a browser: the key card's yellow strip ("Key + rotation is limited to once per calendar month. Next rotation + available: …"), now "Regenerating is limited to once per quota period + and issues nothing now. A new key can be issued from: …" in `app.tsx`. + Recorded here because the words are this task's, and the Figma frames + still carry the old ones: a future re-transcription must not bring them + back. diff --git a/lore/1-tasks/backlog/0164_TEST_self-service-flow-end-to-end-verification.md b/lore/1-tasks/backlog/0164_TEST_self-service-flow-end-to-end-verification.md index 51a6ee6a..e862ad11 100644 --- a/lore/1-tasks/backlog/0164_TEST_self-service-flow-end-to-end-verification.md +++ b/lore/1-tasks/backlog/0164_TEST_self-service-flow-end-to-end-verification.md @@ -56,6 +56,13 @@ history: run order is now [[0194]] → [[0179]] → here, because evidence gathered against `stellar_test` is not evidence of a flow an outside developer can complete. + - date: 2026-08-27 + status: backlog + who: akot + note: > + Checklist re-pointed: the rework modal arms on `regenerate-key`, not + `delete-key` ([[0191]] amendment, decision 41). A tester on the old + wording would have reported the dialog as broken. --- # Self-service flow — end-to-end verification @@ -206,7 +213,8 @@ fails quietly: - Rework refused a second time within the period, returning `409` with the next eligible date. Verify the meeting's worked example: reworked on 3 August → refused until 1 September. -- The rework modal will not confirm until `delete-key` is typed, and the old key +- The rework modal will not confirm until `regenerate-key` is typed ([[0191]] + decision 41 — it was `delete-key` until 2026-08-26), and the old key returns `403` immediately after. **Evidence to keep:** the curl transcripts, the dashboard screenshots, the @@ -224,7 +232,7 @@ omitted is a finding waiting for the reviewer. - [ ] If the SSM age threshold was temporarily raised to observe check 10, the restore is recorded and verified - [ ] The quiet-failure checks executed and recorded, including the reconciler - convergence and the `delete-key` rework path + convergence and the `regenerate-key` rework path - [ ] Run performed with two non-maintainer Discord accounts per the table above — one eligible, one exercising each refusal in isolation - [ ] The two refusals (not a member, account too young) each produce a diff --git a/lore/1-tasks/backlog/0226_FEATURE_daily-usage-series-and-dashboard-chart.md b/lore/1-tasks/backlog/0226_FEATURE_daily-usage-series-and-dashboard-chart.md new file mode 100644 index 00000000..c5d32588 --- /dev/null +++ b/lore/1-tasks/backlog/0226_FEATURE_daily-usage-series-and-dashboard-chart.md @@ -0,0 +1,86 @@ +--- +id: "0226" +title: "Daily requests chart — expose the per-day series /usage already reads, and draw it" +type: FEATURE +status: backlog +related_adr: ["0010"] +related_tasks: ["0188", "0193", "0194"] +tags: [layer-backend, layer-frontend, priority-medium, effort-medium, milestone-M3, epic-self-service-onboarding, api-gateway, dashboard] +milestone: 3 +links: + - "../active/0193_FEATURE_portal-presentable-ui-pass.md" +history: + - date: "2026-08-25" + status: backlog + who: claude + note: > + Spawned from [[0193]]. The dashboard frame (`778:2499`) has a + "Daily requests — April 2026" bar chart with a per-bar tooltip; the data + it needs is read by the backend already and thrown away before it leaves + the gateway. Adam chose to ship the rest of the frame first and do this + as its own slice. + - date: "2026-08-27" + status: backlog + who: akot + note: > + Renumbered 0222 → 0226. The id had been taken on `develop` by the + no-invocations alarm bug (PR #250) before this branch merged it in; + found by [[0193]]'s review round. The two code comments that cited it + (`app.tsx`, `app.spec.tsx`) re-pointed in the same change. +--- + +# Daily requests chart + +## Summary + +The dashboard's Monthly Usage card should show a bar per day of the current +period, with the day's request count on hover — the frame's +`Daily requests — April 2026`. + +## Context + +**The data is already fetched.** `Gateway::usage_of` +(`packages/prices-api/src/portal/keys/gateway.rs`) pages through `GetUsage` and +builds `days: Vec<(i64, i64)>` — one `[used, remaining]` pair per day — and then +`summarize_days` collapses the series to two numbers before returning it. No +extra control-plane call is needed to serve the chart; the series is discarded +a few lines after it is read. + +What is missing is the contract: `KeyUsage` carries `used`/`remaining`/`limit`, +`UsageResponse` (`portal/usage/mod.rs`) serialises those, and `PortalUsage` in +`web/portal/src/api/portal.ts` mirrors them. None of the three has a per-day +field. + +## Implementation + +- **Gateway:** keep the daily series on `KeyUsage` beside the summary. Each + entry needs the DATE as well as the count — `GetUsage` returns the pairs + positionally against the queried range, so the date has to be reconstructed + from `start_date` plus the index. Mind the case `summarize_days` already + handles: AWS's own period can roll partway through the range. +- **Route:** add `days: [{ date: "YYYY-MM-DD", used: u64 }]` to + `UsageResponse`. Only `used` — `remaining` is a running balance, not a + per-day allowance (task 0157's close), and publishing it per day invites + exactly the misreading 0188 avoided. +- **Cache:** the series rides in `CachedAnswer` with the rest of the answer; + no second TTL. +- **Frontend:** a bar per day inside the Monthly Usage card, the day's count on + hover and on focus (keyboard reach is not optional — the tooltip is the only + place the number exists), and the empty state kept honest: a key AWS has no + row for yet renders no chart, exactly as it renders no bar today. +- Drawn with SVG or CSS rather than a charting dependency, unless somebody + argues otherwise: one series of ~31 bars against a fixed axis is less code + than the wrapper around a library would be, and the bundle is served to every + visitor of the landing page too. + +## Acceptance Criteria + +- [ ] `/usage` carries a dated per-day series, and no additional `GetUsage` + call is made to produce it +- [ ] The days a key existed for but was unused are present as zeroes; the days + before it existed are absent, not zero +- [ ] The chart renders the current period, labelled with its month +- [ ] Each bar's count is reachable by keyboard as well as by pointer +- [ ] A key with nothing recorded yet renders the card exactly as it does + today — no chart, no invented zeroes ([[0188]]'s rule) +- [ ] Rust and portal test suites cover the new field and the empty case diff --git a/lore/1-tasks/backlog/0227_CHORE_reconcile-the-portal-documented-api-surface-with-the-real-openapi.md b/lore/1-tasks/backlog/0227_CHORE_reconcile-the-portal-documented-api-surface-with-the-real-openapi.md new file mode 100644 index 00000000..fd220640 --- /dev/null +++ b/lore/1-tasks/backlog/0227_CHORE_reconcile-the-portal-documented-api-surface-with-the-real-openapi.md @@ -0,0 +1,72 @@ +--- +id: "0227" +title: "Reconcile the portal's documented API surface with the real OpenAPI — paths, example fields, the source name, the placeholder key" +type: CHORE +status: backlog +related_adr: [] +related_tasks: ["0193", "0163", "0195", "0124"] +tags: [layer-frontend, priority-medium, effort-small, milestone-M3, epic-self-service-onboarding, docs, figma] +milestone: 3 +links: + - "../active/0193_FEATURE_portal-presentable-ui-pass.md" + - "../../../docs/scf/api-endpoints.md" +history: + - date: "2026-08-27" + status: backlog + who: akot + note: > + Spawned from [[0193]]'s review round (PR #249, stkrolikiewicz: "worth + a backlog item so it is tracked rather than remembered"). The deferral + existed only as a comment in `quickstart/QuickStart.tsx`. The one part + that could not wait — a real key pasted into a `curl` aimed at + `api.soroswap.finance` — was fixed in 0193 the same day by pointing + the HOST at our execute-api base; everything else below is design + content and changes when the Figma file does. +--- + +# Reconcile the portal's documented API surface with the real OpenAPI + +## Summary + +The landing page and the quick start were transcribed from the Figma frames, +and the frames describe an API that is not quite this one. The paths, the +example-response fields, the `"source": "soroswap"` value and the +`sf_live_…` key placeholder are the design's; the OpenAPI document +(`/api-docs-json`, task [[0124]]) is the truth. Bring the page to the document — +or, where the design is the better answer, change the document and the API — +but stop rendering a third thing that is neither. + +## Context + +[[0193]] rendered what the frames say so the two would not diverge into a +third answer, and kept every design-only value in one constant so this +reconciliation is a small diff. Its review found the gap live on the deployed +page and asked for it to be tracked. [[0163]] (the quick start's content) and +[[0195]] (Swagger UI, custom domain) are the neighbours: the base URL changes +again when 0195 lands, and the quick start's example queries must be +"accurate against the live API" (epic AC 3). + +## Implementation + +- `web/portal/src/quickstart/QuickStart.tsx` — `BASE_URL` (host is already + ours; the `/v1` and the `/prices/XLM-USDC` path are the design's), + `PLACEHOLDER_KEY`, the "Understanding the response" field table, the + endpoint list, the SDK snippets' paths +- `web/portal/src/landing/Endpoints.tsx` and `Terminal.tsx` — the hero and + endpoint-section snippets (same fields, same `source`) +- `web/portal/src/landing/Documentation.tsx` — card copy that promises + "Full Swagger UI included" and "what headers to watch" (0195, and the + measured 429 in `QuickStart.tsx`'s `RATE_LIMIT_BODY`) +- Decide each divergence one way: page → document, or document → page. Record + the ones that go the second way as 0124 amendments +- Update the Figma frames to match, or record that the frames are stale + +## Acceptance Criteria + +- [ ] Every URL, path and field name rendered by the landing page and the + quick start exists in `/api-docs-json`, or has a dated decision here + saying why the document changes instead +- [ ] Every copy-button snippet on the quick start runs unchanged against the + live API with a real free-plan key and returns what the page shows +- [ ] No hostname other than ours appears in the production bundle +- [ ] The Figma file agrees with the page, or a note here says it does not diff --git a/lore/2-adrs/0010_discord-account-model-and-abuse-barrier.md b/lore/2-adrs/0010_discord-account-model-and-abuse-barrier.md index 67559c69..a84f8512 100644 --- a/lore/2-adrs/0010_discord-account-model-and-abuse-barrier.md +++ b/lore/2-adrs/0010_discord-account-model-and-abuse-barrier.md @@ -3,7 +3,7 @@ id: "0010" title: "Discord identity is the account: one active key, gated on guild membership and account age" status: accepted deciders: [akot] -related_tasks: ["0156", "0157", "0158", "0159", "0160", "0179", "0180", "0186", "0187", "0188", "0189", "0191", "0192"] +related_tasks: ["0156", "0157", "0158", "0159", "0160", "0179", "0180", "0186", "0187", "0188", "0189", "0191", "0192", "0193"] related_adrs: ["0007", "0008"] tags: [discord, oauth, auth, abuse-prevention, account-model, api-keys, usage-plan, epic-self-service-onboarding] links: @@ -54,6 +54,14 @@ history: previously state, settled at the re-slice: **revocation does not earn a replacement key** — the once-per-period cap governs re-issuance regardless of how the previous key ended ([[0192]]). + - date: 2026-08-27 + status: accepted + who: akot + note: > + One word in §8 re-pointed: the rework confirmation's arming phrase is + `regenerate-key`, not `delete-key` — [[0191]] amendment (decision 41), + recorded from [[0193]]'s review. **No decision in this ADR changes**; + the confirm-then-re-auth order and the reasons for a typed phrase stand. --- # ADR 0010: Discord identity is the account @@ -261,7 +269,8 @@ signed. The callback completes that action and nothing else. | Usage / dashboard | no | session only — works forever | | **Rework** | **yes** | membership, then the quota-period cap, then the swap | -For rework the user confirms first (the `delete-key` modal in [[0162]]), and the +For rework the user confirms first (the `regenerate-key` modal — [[0162]], phrase +amended in [[0191]] decision 41), and the re-auth is the gate between confirming and executing. **Account age is only checked on issuance**, not on rework: an account old enough diff --git a/package-lock.json b/package-lock.json index c0c71464..96539714 100644 --- a/package-lock.json +++ b/package-lock.json @@ -14,6 +14,10 @@ "web/*" ], "dependencies": { + "@emotion/react": "^11.14.0", + "@emotion/styled": "^11.14.1", + "@mui/icons-material": "^7.3.11", + "@mui/material": "^7.3.11", "react": "^19.0.0", "react-dom": "^19.0.0", "react-router-dom": "^7.18.2" @@ -125,7 +129,6 @@ "version": "7.29.7", "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.7.tgz", "integrity": "sha512-Aup7aUOfpbAUg2ROOJN6Iw5f9DMBlzu0mIkm/malLQFN/YQgO48wCj0Kxa3sEHJvPVFg7siR+qRInwXd2qhQKw==", - "dev": true, "license": "MIT", "dependencies": { "@babel/helper-validator-identifier": "^7.29.7", @@ -191,7 +194,6 @@ "version": "7.29.7", "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.29.7.tgz", "integrity": "sha512-DkXD5OJQaAQIdZ1bt3UZdEnHAn9Imd3IVBdX03UFe+ony9Ojw5pzr9YVKGDY1jt+Gcn/FnGkNf8r+Vj5NOJWtQ==", - "dev": true, "license": "MIT", "dependencies": { "@babel/parser": "^7.29.7", @@ -325,7 +327,6 @@ "version": "7.29.7", "resolved": "https://registry.npmjs.org/@babel/helper-globals/-/helper-globals-7.29.7.tgz", "integrity": "sha512-3nQVUAtvkKH9zahfWgw96Jc/uFOmjACE1kQz82E2lqWmHBgjzbNlsC22nuQTfahmWeQtTq5nQ/4Nnd2A1wj4zA==", - "dev": true, "license": "MIT", "engines": { "node": ">=6.9.0" @@ -349,7 +350,6 @@ "version": "7.29.7", "resolved": "https://registry.npmjs.org/@babel/helper-module-imports/-/helper-module-imports-7.29.7.tgz", "integrity": "sha512-ejHwrQQYcm9xnTivShn2IDOlIzInN34AXskvq9QicvCtEzq1Vzclu/tKF8Jq1Cg8JG2GL6/EmjgsCT7lXepE3g==", - "dev": true, "license": "MIT", "dependencies": { "@babel/traverse": "^7.29.7", @@ -454,7 +454,6 @@ "version": "7.29.7", "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.29.7.tgz", "integrity": "sha512-Pb5ijPrZ89GDH8223L4UP8i6QApWxs04RbPQJTeWDV0/keR2E36MeKnyr6LYmUUvqRRI+Iv87SuF1W6ErINzYw==", - "dev": true, "license": "MIT", "engines": { "node": ">=6.9.0" @@ -464,7 +463,6 @@ "version": "7.29.7", "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.29.7.tgz", "integrity": "sha512-qehxGkRj55h/ff8EMaJ+cYhyaKlHIxqYDn682wQD7RNp9UujOQsHog2uS0r2vzr4pW+sXf90NeeayjcNaX3fFg==", - "dev": true, "license": "MIT", "engines": { "node": ">=6.9.0" @@ -513,7 +511,6 @@ "version": "7.29.7", "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.7.tgz", "integrity": "sha512-hnORnjP/1P/zFEndoeX+n+t1RwWRJiJpM/jO7FW32Kn9r5+sJB2JWOdYo4L6k78j15eCwY3Gm/7364B1EMwtNg==", - "dev": true, "license": "MIT", "dependencies": { "@babel/types": "^7.29.7" @@ -1916,7 +1913,6 @@ "version": "7.29.7", "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.29.7.tgz", "integrity": "sha512-Nq8OhGWiZIZGV6hLHoyAKLLcJihP/xFeBMGJoUrxTX2psI8dCifzLhZISFb+VWS3wFMRDmCGw5R+dOySCqPLhw==", - "dev": true, "license": "MIT", "engines": { "node": ">=6.9.0" @@ -1926,7 +1922,6 @@ "version": "7.29.7", "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.29.7.tgz", "integrity": "sha512-puq+Gf35oI24FeN11LkoUQFqv9uwNeWpxXZi/Ji3rRIoKAzKnxRaZ+Gkj0vKS9ZCiTESfng1N9LyOyXvo+m+Gg==", - "dev": true, "license": "MIT", "dependencies": { "@babel/code-frame": "^7.29.7", @@ -1941,7 +1936,6 @@ "version": "7.29.7", "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.29.7.tgz", "integrity": "sha512-EhlfNQtZ+NK22w5BM61ciuiq1m58ed33Wr1Xan//ZRTy6hgjnwyCffRYwzsGXdASJSUJ1guZILsErh1eQcl+zw==", - "dev": true, "license": "MIT", "dependencies": { "@babel/code-frame": "^7.29.7", @@ -1960,7 +1954,6 @@ "version": "7.29.7", "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.29.7.tgz", "integrity": "sha512-4zBIxpPzowiZpusoFkyGVwakdRJUyuH5PxQ/PrqghfdFWWasvnCdPfQXHrenDai+gyLARulZjZowCOj6fjT4pA==", - "dev": true, "license": "MIT", "dependencies": { "@babel/helper-string-parser": "^7.29.7", @@ -2022,6 +2015,167 @@ "tslib": "^2.4.0" } }, + "node_modules/@emotion/babel-plugin": { + "version": "11.13.5", + "resolved": "https://registry.npmjs.org/@emotion/babel-plugin/-/babel-plugin-11.13.5.tgz", + "integrity": "sha512-pxHCpT2ex+0q+HH91/zsdHkw/lXd468DIN2zvfvLtPKLLMo6gQj7oLObq8PhkrxOZb/gGCq03S3Z7PDhS8pduQ==", + "license": "MIT", + "dependencies": { + "@babel/helper-module-imports": "^7.16.7", + "@babel/runtime": "^7.18.3", + "@emotion/hash": "^0.9.2", + "@emotion/memoize": "^0.9.0", + "@emotion/serialize": "^1.3.3", + "babel-plugin-macros": "^3.1.0", + "convert-source-map": "^1.5.0", + "escape-string-regexp": "^4.0.0", + "find-root": "^1.1.0", + "source-map": "^0.5.7", + "stylis": "4.2.0" + } + }, + "node_modules/@emotion/babel-plugin/node_modules/convert-source-map": { + "version": "1.9.0", + "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-1.9.0.tgz", + "integrity": "sha512-ASFBup0Mz1uyiIjANan1jzLQami9z1PoYSZCiiYW2FczPbenXc45FZdBZLzOT+r6+iciuEModtmCti+hjaAk0A==", + "license": "MIT" + }, + "node_modules/@emotion/babel-plugin/node_modules/source-map": { + "version": "0.5.7", + "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.5.7.tgz", + "integrity": "sha512-LbrmJOMUSdEVxIKvdcJzQC+nQhe8FUZQTXQy6+I75skNgn3OoQ0DZA8YnFa7gp8tqtL3KPf1kmo0R5DoApeSGQ==", + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/@emotion/cache": { + "version": "11.14.0", + "resolved": "https://registry.npmjs.org/@emotion/cache/-/cache-11.14.0.tgz", + "integrity": "sha512-L/B1lc/TViYk4DcpGxtAVbx0ZyiKM5ktoIyafGkH6zg/tj+mA+NE//aPYKG0k8kCHSHVJrpLpcAlOBEXQ3SavA==", + "license": "MIT", + "dependencies": { + "@emotion/memoize": "^0.9.0", + "@emotion/sheet": "^1.4.0", + "@emotion/utils": "^1.4.2", + "@emotion/weak-memoize": "^0.4.0", + "stylis": "4.2.0" + } + }, + "node_modules/@emotion/hash": { + "version": "0.9.2", + "resolved": "https://registry.npmjs.org/@emotion/hash/-/hash-0.9.2.tgz", + "integrity": "sha512-MyqliTZGuOm3+5ZRSaaBGP3USLw6+EGykkwZns2EPC5g8jJ4z9OrdZY9apkl3+UP9+sdz76YYkwCKP5gh8iY3g==", + "license": "MIT" + }, + "node_modules/@emotion/is-prop-valid": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/@emotion/is-prop-valid/-/is-prop-valid-1.4.0.tgz", + "integrity": "sha512-QgD4fyscGcbbKwJmqNvUMSE02OsHUa+lAWKdEUIJKgqe5IwRSKd7+KhibEWdaKwgjLj0DRSHA9biAIqGBk05lw==", + "license": "MIT", + "dependencies": { + "@emotion/memoize": "^0.9.0" + } + }, + "node_modules/@emotion/memoize": { + "version": "0.9.0", + "resolved": "https://registry.npmjs.org/@emotion/memoize/-/memoize-0.9.0.tgz", + "integrity": "sha512-30FAj7/EoJ5mwVPOWhAyCX+FPfMDrVecJAM+Iw9NRoSl4BBAQeqj4cApHHUXOVvIPgLVDsCFoz/hGD+5QQD1GQ==", + "license": "MIT" + }, + "node_modules/@emotion/react": { + "version": "11.14.0", + "resolved": "https://registry.npmjs.org/@emotion/react/-/react-11.14.0.tgz", + "integrity": "sha512-O000MLDBDdk/EohJPFUqvnp4qnHeYkVP5B0xEG0D/L7cOKP9kefu2DXn8dj74cQfsEzUqh+sr1RzFqiL1o+PpA==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.18.3", + "@emotion/babel-plugin": "^11.13.5", + "@emotion/cache": "^11.14.0", + "@emotion/serialize": "^1.3.3", + "@emotion/use-insertion-effect-with-fallbacks": "^1.2.0", + "@emotion/utils": "^1.4.2", + "@emotion/weak-memoize": "^0.4.0", + "hoist-non-react-statics": "^3.3.1" + }, + "peerDependencies": { + "react": ">=16.8.0" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@emotion/serialize": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/@emotion/serialize/-/serialize-1.3.3.tgz", + "integrity": "sha512-EISGqt7sSNWHGI76hC7x1CksiXPahbxEOrC5RjmFRJTqLyEK9/9hZvBbiYn70dw4wuwMKiEMCUlR6ZXTSWQqxA==", + "license": "MIT", + "dependencies": { + "@emotion/hash": "^0.9.2", + "@emotion/memoize": "^0.9.0", + "@emotion/unitless": "^0.10.0", + "@emotion/utils": "^1.4.2", + "csstype": "^3.0.2" + } + }, + "node_modules/@emotion/sheet": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/@emotion/sheet/-/sheet-1.4.0.tgz", + "integrity": "sha512-fTBW9/8r2w3dXWYM4HCB1Rdp8NLibOw2+XELH5m5+AkWiL/KqYX6dc0kKYlaYyKjrQ6ds33MCdMPEwgs2z1rqg==", + "license": "MIT" + }, + "node_modules/@emotion/styled": { + "version": "11.14.1", + "resolved": "https://registry.npmjs.org/@emotion/styled/-/styled-11.14.1.tgz", + "integrity": "sha512-qEEJt42DuToa3gurlH4Qqc1kVpNq8wO8cJtDzU46TjlzWjDlsVyevtYCRijVq3SrHsROS+gVQ8Fnea108GnKzw==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.18.3", + "@emotion/babel-plugin": "^11.13.5", + "@emotion/is-prop-valid": "^1.3.0", + "@emotion/serialize": "^1.3.3", + "@emotion/use-insertion-effect-with-fallbacks": "^1.2.0", + "@emotion/utils": "^1.4.2" + }, + "peerDependencies": { + "@emotion/react": "^11.0.0-rc.0", + "react": ">=16.8.0" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@emotion/unitless": { + "version": "0.10.0", + "resolved": "https://registry.npmjs.org/@emotion/unitless/-/unitless-0.10.0.tgz", + "integrity": "sha512-dFoMUuQA20zvtVTuxZww6OHoJYgrzfKM1t52mVySDJnMSEa08ruEvdYQbhvyu6soU+NeLVd3yKfTfT0NeV6qGg==", + "license": "MIT" + }, + "node_modules/@emotion/use-insertion-effect-with-fallbacks": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/@emotion/use-insertion-effect-with-fallbacks/-/use-insertion-effect-with-fallbacks-1.2.0.tgz", + "integrity": "sha512-yJMtVdH59sxi/aVJBpk9FQq+OR8ll5GT8oWd57UpeaKEVGab41JWaCFA7FRLoMLloOZF/c/wsPoe+bfGmRKgDg==", + "license": "MIT", + "peerDependencies": { + "react": ">=16.8.0" + } + }, + "node_modules/@emotion/utils": { + "version": "1.4.2", + "resolved": "https://registry.npmjs.org/@emotion/utils/-/utils-1.4.2.tgz", + "integrity": "sha512-3vLclRofFziIa3J2wDh9jjbkUz9qk5Vi3IZ/FSTKViB0k+ef0fPV7dYrUIugbgupYDx7v9ud/SjrtEP8Y4xLoA==", + "license": "MIT" + }, + "node_modules/@emotion/weak-memoize": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/@emotion/weak-memoize/-/weak-memoize-0.4.0.tgz", + "integrity": "sha512-snKqtPW01tN0ui7yu9rGv69aJXr/a/Ywvl11sUjNtEcRc+ng/mQriFL0wLXMef74iHa/EkftbDzU9F8iFbH+zg==", + "license": "MIT" + }, "node_modules/@eslint-community/eslint-utils": { "version": "4.9.1", "resolved": "https://registry.npmjs.org/@eslint-community/eslint-utils/-/eslint-utils-4.9.1.tgz", @@ -2332,7 +2486,6 @@ "version": "0.3.13", "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz", "integrity": "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==", - "dev": true, "license": "MIT", "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.0", @@ -2354,7 +2507,6 @@ "version": "3.1.2", "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==", - "dev": true, "license": "MIT", "engines": { "node": ">=6.0.0" @@ -2375,14 +2527,12 @@ "version": "1.5.5", "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==", - "dev": true, "license": "MIT" }, "node_modules/@jridgewell/trace-mapping": { "version": "0.3.31", "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz", "integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==", - "dev": true, "license": "MIT", "dependencies": { "@jridgewell/resolve-uri": "^3.1.0", @@ -2653,6 +2803,251 @@ "@module-federation/sdk": "2.8.2" } }, + "node_modules/@mui/core-downloads-tracker": { + "version": "7.3.11", + "resolved": "https://registry.npmjs.org/@mui/core-downloads-tracker/-/core-downloads-tracker-7.3.11.tgz", + "integrity": "sha512-a7I/b/nBTdXYz2cOSlEmkQ9WWE1x8FHpqMhFPp+Y1VPFxcOw91G5ELOHARQAGSPy5V+UCgJua6K/1x70bAtQPw==", + "license": "MIT", + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/mui-org" + } + }, + "node_modules/@mui/icons-material": { + "version": "7.3.11", + "resolved": "https://registry.npmjs.org/@mui/icons-material/-/icons-material-7.3.11.tgz", + "integrity": "sha512-+hz5ilwHZ3djd5es3sCErLioqe/NhZcYTsV/TNXZAMdJdb23F4xzJjqnnZdnurc3S1+ietcssRNqieOhPQLZ7Q==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.28.6" + }, + "engines": { + "node": ">=14.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/mui-org" + }, + "peerDependencies": { + "@mui/material": "^7.3.11", + "@types/react": "^17.0.0 || ^18.0.0 || ^19.0.0", + "react": "^17.0.0 || ^18.0.0 || ^19.0.0" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@mui/material": { + "version": "7.3.11", + "resolved": "https://registry.npmjs.org/@mui/material/-/material-7.3.11.tgz", + "integrity": "sha512-yq8bPc3LxOwKRWpcjRgDkYFmpM6aKlARfESTmOQcvLYFeJwtHte2tw6hJDrb8sk8wcvpDprHEHVaoUU0MslIkw==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.28.6", + "@mui/core-downloads-tracker": "^7.3.11", + "@mui/system": "^7.3.11", + "@mui/types": "^7.4.12", + "@mui/utils": "^7.3.11", + "@popperjs/core": "^2.11.8", + "@types/react-transition-group": "^4.4.12", + "clsx": "^2.1.1", + "csstype": "^3.2.3", + "prop-types": "^15.8.1", + "react-is": "^19.2.3", + "react-transition-group": "^4.4.5" + }, + "engines": { + "node": ">=14.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/mui-org" + }, + "peerDependencies": { + "@emotion/react": "^11.5.0", + "@emotion/styled": "^11.3.0", + "@mui/material-pigment-css": "^7.3.11", + "@types/react": "^17.0.0 || ^18.0.0 || ^19.0.0", + "react": "^17.0.0 || ^18.0.0 || ^19.0.0", + "react-dom": "^17.0.0 || ^18.0.0 || ^19.0.0" + }, + "peerDependenciesMeta": { + "@emotion/react": { + "optional": true + }, + "@emotion/styled": { + "optional": true + }, + "@mui/material-pigment-css": { + "optional": true + }, + "@types/react": { + "optional": true + } + } + }, + "node_modules/@mui/material/node_modules/react-is": { + "version": "19.2.8", + "resolved": "https://registry.npmjs.org/react-is/-/react-is-19.2.8.tgz", + "integrity": "sha512-s5un28nYxKJw5gvUHyW5PCC28CvBqLu9r3cWgzHT4Vo/5fqqkFcdRYsGcKf50WMPpjjFZS5d76fn3YCo2njKwQ==", + "license": "MIT" + }, + "node_modules/@mui/private-theming": { + "version": "7.3.11", + "resolved": "https://registry.npmjs.org/@mui/private-theming/-/private-theming-7.3.11.tgz", + "integrity": "sha512-9B+YKms0fRHbNrqp9tOT/DNbNnU5gyvJ1o3qAGXfq8GmZcbJnE3At9x07Zr/o0pkhzg4aDdwXVqe4+AcgtOCPA==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.28.6", + "@mui/utils": "^7.3.11", + "prop-types": "^15.8.1" + }, + "engines": { + "node": ">=14.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/mui-org" + }, + "peerDependencies": { + "@types/react": "^17.0.0 || ^18.0.0 || ^19.0.0", + "react": "^17.0.0 || ^18.0.0 || ^19.0.0" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@mui/styled-engine": { + "version": "7.3.10", + "resolved": "https://registry.npmjs.org/@mui/styled-engine/-/styled-engine-7.3.10.tgz", + "integrity": "sha512-WxE9SiF8xskAQqGjsp0poXCkCqsoXFEsSr0HBXfApmGHR+DBnXRp+z46Vsltg4gpPM4Z96DeAQRpeAOnhNg7Ng==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.28.6", + "@emotion/cache": "^11.14.0", + "@emotion/serialize": "^1.3.3", + "@emotion/sheet": "^1.4.0", + "csstype": "^3.2.3", + "prop-types": "^15.8.1" + }, + "engines": { + "node": ">=14.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/mui-org" + }, + "peerDependencies": { + "@emotion/react": "^11.4.1", + "@emotion/styled": "^11.3.0", + "react": "^17.0.0 || ^18.0.0 || ^19.0.0" + }, + "peerDependenciesMeta": { + "@emotion/react": { + "optional": true + }, + "@emotion/styled": { + "optional": true + } + } + }, + "node_modules/@mui/system": { + "version": "7.3.11", + "resolved": "https://registry.npmjs.org/@mui/system/-/system-7.3.11.tgz", + "integrity": "sha512-7izwGWdNawAKpBKcRlx7f2gFnAAjmASBWvMcyX4YYEeLOFsbfGRbUYGInvnAcUeql3rPxI7F9Ft4oY2OLRz44g==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.28.6", + "@mui/private-theming": "^7.3.11", + "@mui/styled-engine": "^7.3.10", + "@mui/types": "^7.4.12", + "@mui/utils": "^7.3.11", + "clsx": "^2.1.1", + "csstype": "^3.2.3", + "prop-types": "^15.8.1" + }, + "engines": { + "node": ">=14.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/mui-org" + }, + "peerDependencies": { + "@emotion/react": "^11.5.0", + "@emotion/styled": "^11.3.0", + "@types/react": "^17.0.0 || ^18.0.0 || ^19.0.0", + "react": "^17.0.0 || ^18.0.0 || ^19.0.0" + }, + "peerDependenciesMeta": { + "@emotion/react": { + "optional": true + }, + "@emotion/styled": { + "optional": true + }, + "@types/react": { + "optional": true + } + } + }, + "node_modules/@mui/types": { + "version": "7.4.12", + "resolved": "https://registry.npmjs.org/@mui/types/-/types-7.4.12.tgz", + "integrity": "sha512-iKNAF2u9PzSIj40CjvKJWxFXJo122jXVdrmdh0hMYd+FR+NuJMkr/L88XwWLCRiJ5P1j+uyac25+Kp6YC4hu6w==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.28.6" + }, + "peerDependencies": { + "@types/react": "^17.0.0 || ^18.0.0 || ^19.0.0" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@mui/utils": { + "version": "7.3.11", + "resolved": "https://registry.npmjs.org/@mui/utils/-/utils-7.3.11.tgz", + "integrity": "sha512-XTjGnifwteg71/ij+0e7Y7d+hwyntMYP5wPoA/g2drdGH+Flkvjwy0OfrVpKBbaOvofq4zU/LIyUZyKgmWu18g==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.28.6", + "@mui/types": "^7.4.12", + "@types/prop-types": "^15.7.15", + "clsx": "^2.1.1", + "prop-types": "^15.8.1", + "react-is": "^19.2.3" + }, + "engines": { + "node": ">=14.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/mui-org" + }, + "peerDependencies": { + "@types/react": "^17.0.0 || ^18.0.0 || ^19.0.0", + "react": "^17.0.0 || ^18.0.0 || ^19.0.0" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@mui/utils/node_modules/react-is": { + "version": "19.2.8", + "resolved": "https://registry.npmjs.org/react-is/-/react-is-19.2.8.tgz", + "integrity": "sha512-s5un28nYxKJw5gvUHyW5PCC28CvBqLu9r3cWgzHT4Vo/5fqqkFcdRYsGcKf50WMPpjjFZS5d76fn3YCo2njKwQ==", + "license": "MIT" + }, "node_modules/@napi-rs/lzma-linux-x64-gnu": { "version": "1.5.1", "resolved": "https://registry.npmjs.org/@napi-rs/lzma-linux-x64-gnu/-/lzma-linux-x64-gnu-1.5.1.tgz", @@ -3831,6 +4226,16 @@ "dev": true, "license": "MIT" }, + "node_modules/@popperjs/core": { + "version": "2.11.8", + "resolved": "https://registry.npmjs.org/@popperjs/core/-/core-2.11.8.tgz", + "integrity": "sha512-P1st0aksCrn9sGZhp8GMYwBnQsbvAWsZAX44oXNNvLHGqAOcoVxmjZiohstwQ7SqKnbR47akdNi+uleWD8+g6A==", + "license": "MIT", + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/popperjs" + } + }, "node_modules/@redocly/ajv": { "version": "8.11.2", "resolved": "https://registry.npmjs.org/@redocly/ajv/-/ajv-8.11.2.tgz", @@ -5987,14 +6392,18 @@ "version": "4.0.2", "resolved": "https://registry.npmjs.org/@types/parse-json/-/parse-json-4.0.2.tgz", "integrity": "sha512-dISoDXWWQwUquiKsyZ4Ng+HX2KsPL7LyHKHQwgGFEA3IaKac4Obd+h2a/a6waisAoepJlBcx9paWqjA8/HVjCw==", - "dev": true, + "license": "MIT" + }, + "node_modules/@types/prop-types": { + "version": "15.7.15", + "resolved": "https://registry.npmjs.org/@types/prop-types/-/prop-types-15.7.15.tgz", + "integrity": "sha512-F6bEyamV9jKGAFBEmlQnesRPGOQqS2+Uwi0Em15xenOxHaf2hv6L8YCVn3rPdPJOiJfPiCnLIRyvwVaqMY3MIw==", "license": "MIT" }, "node_modules/@types/react": { "version": "19.2.18", "resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.18.tgz", "integrity": "sha512-AnzbBERsrLKtk2XSfTbYRLjQPdy116Sty4q+T+Bp3IC4l6jNBvreVPAHmpq9qhXQM7CXZPjLVmGMw9sy+hxQ3w==", - "dev": true, "license": "MIT", "dependencies": { "csstype": "^3.2.2" @@ -6010,6 +6419,15 @@ "@types/react": "^19.2.0" } }, + "node_modules/@types/react-transition-group": { + "version": "4.4.12", + "resolved": "https://registry.npmjs.org/@types/react-transition-group/-/react-transition-group-4.4.12.tgz", + "integrity": "sha512-8TV6R3h2j7a91c+1DXdJi3Syo69zzIZbz7Lg5tORM5LEJG7X/E6a1V3drRyBRZq7/utz7A+c4OgYLiLcYGHG6w==", + "license": "MIT", + "peerDependencies": { + "@types/react": "*" + } + }, "node_modules/@types/resolve": { "version": "1.20.2", "resolved": "https://registry.npmjs.org/@types/resolve/-/resolve-1.20.2.tgz", @@ -7760,7 +8178,6 @@ "version": "3.1.0", "resolved": "https://registry.npmjs.org/babel-plugin-macros/-/babel-plugin-macros-3.1.0.tgz", "integrity": "sha512-Cg7TFGpIr01vOQNODXOOaGz2NpCU5gl8x1qJFbb6hbZxR7XrcE2vtbAsTAbJ7/xwJtUuJEw8K8Zr/AE0LHlesg==", - "dev": true, "license": "MIT", "dependencies": { "@babel/runtime": "^7.12.5", @@ -8374,7 +8791,6 @@ "version": "3.1.0", "resolved": "https://registry.npmjs.org/callsites/-/callsites-3.1.0.tgz", "integrity": "sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ==", - "dev": true, "license": "MIT", "engines": { "node": ">=6" @@ -8562,6 +8978,15 @@ "node": ">=0.8" } }, + "node_modules/clsx": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/clsx/-/clsx-2.1.1.tgz", + "integrity": "sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, "node_modules/color-convert": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", @@ -8751,7 +9176,6 @@ "version": "7.1.0", "resolved": "https://registry.npmjs.org/cosmiconfig/-/cosmiconfig-7.1.0.tgz", "integrity": "sha512-AdmX6xUzdNASswsFtmwSt7Vj8po9IuqXm0UXz7QKPuEUmPB4XyjGfaAr2PSuELMwkRMVH1EpIkX5bTZGRB3eCA==", - "dev": true, "license": "MIT", "dependencies": { "@types/parse-json": "^4.0.0", @@ -8768,7 +9192,6 @@ "version": "1.10.3", "resolved": "https://registry.npmjs.org/yaml/-/yaml-1.10.3.tgz", "integrity": "sha512-vIYeF1u3CjlhAFekPPAk2h/Kv4T3mAkMox5OymRiJQB0spDP10LHvt+K7G9Ny6NuuMAb25/6n1qyUjAcGNf/AA==", - "dev": true, "license": "ISC", "engines": { "node": ">= 6" @@ -8899,7 +9322,6 @@ "version": "3.2.3", "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==", - "dev": true, "license": "MIT" }, "node_modules/damerau-levenshtein": { @@ -9019,7 +9441,6 @@ "version": "4.4.3", "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", - "dev": true, "license": "MIT", "dependencies": { "ms": "^2.1.3" @@ -9221,6 +9642,16 @@ "dev": true, "license": "MIT" }, + "node_modules/dom-helpers": { + "version": "5.2.1", + "resolved": "https://registry.npmjs.org/dom-helpers/-/dom-helpers-5.2.1.tgz", + "integrity": "sha512-nRCa7CK3VTrM2NmGkIy4cbK7IZlgBE/PYMn55rrXefr5xXDP0LdtfPnblFDoVdcAfslJ7or6iqAUnx0CCGIWQA==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.8.7", + "csstype": "^3.0.2" + } + }, "node_modules/dom-serializer": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/dom-serializer/-/dom-serializer-2.0.0.tgz", @@ -9494,7 +9925,6 @@ "version": "1.3.4", "resolved": "https://registry.npmjs.org/error-ex/-/error-ex-1.3.4.tgz", "integrity": "sha512-sqQamAnR14VgCr1A618A3sGrygcpK+HEbenA/HiEAkkUwcZIIB/tgWqHFxWgOyDh4nB4JCRimh79dR5Ywc9MDQ==", - "dev": true, "license": "MIT", "dependencies": { "is-arrayish": "^0.2.1" @@ -9615,7 +10045,6 @@ "version": "1.3.0", "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz", "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==", - "dev": true, "license": "MIT", "engines": { "node": ">= 0.4" @@ -9740,7 +10169,6 @@ "version": "4.0.0", "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-4.0.0.tgz", "integrity": "sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==", - "dev": true, "license": "MIT", "engines": { "node": ">=10" @@ -10856,6 +11284,12 @@ "semver": "bin/semver.js" } }, + "node_modules/find-root": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/find-root/-/find-root-1.1.0.tgz", + "integrity": "sha512-NKfW6bec6GfKc0SGx1e07QZY9PE99u0Bft/0rzSD5k3sO/vwkVUpDUKVm5Gpp5Ue3YfShPFTX2070tDs5kB9Ng==", + "license": "MIT" + }, "node_modules/find-up": { "version": "5.0.0", "resolved": "https://registry.npmjs.org/find-up/-/find-up-5.0.0.tgz", @@ -11067,7 +11501,6 @@ "version": "1.1.2", "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==", - "dev": true, "license": "MIT", "funding": { "url": "https://github.com/sponsors/ljharb" @@ -11515,6 +11948,21 @@ "he": "bin/he" } }, + "node_modules/hoist-non-react-statics": { + "version": "3.3.2", + "resolved": "https://registry.npmjs.org/hoist-non-react-statics/-/hoist-non-react-statics-3.3.2.tgz", + "integrity": "sha512-/gGivxi8JPKWNm/W0jSmzcMPpfpPLc3dY/6GxhX2hQ9iGj3aDfklV4ET7NjKpSinLpJ5vafa9iiGIEZg10SfBw==", + "license": "BSD-3-Clause", + "dependencies": { + "react-is": "^16.7.0" + } + }, + "node_modules/hoist-non-react-statics/node_modules/react-is": { + "version": "16.13.1", + "resolved": "https://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz", + "integrity": "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==", + "license": "MIT" + }, "node_modules/html-encoding-sniffer": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/html-encoding-sniffer/-/html-encoding-sniffer-3.0.0.tgz", @@ -11761,7 +12209,6 @@ "version": "3.3.1", "resolved": "https://registry.npmjs.org/import-fresh/-/import-fresh-3.3.1.tgz", "integrity": "sha512-TR3KfrTZTYLPB6jUjfx6MF9WcWrHL9su5TObK4ZkYgBdWKPOFoSoQIdEuTuR82pmtxH2spWG9h6etwfr1pLBqQ==", - "dev": true, "license": "MIT", "dependencies": { "parent-module": "^1.0.0", @@ -11873,7 +12320,6 @@ "version": "0.2.1", "resolved": "https://registry.npmjs.org/is-arrayish/-/is-arrayish-0.2.1.tgz", "integrity": "sha512-zz06S8t0ozoDXMG+ube26zeCTNXcKIPJZJi8hBrF4idCLms4CG9QtK7qBl1boi5ODzFpjswb5JPmHCbMpjaYzg==", - "dev": true, "license": "MIT" }, "node_modules/is-async-function": { @@ -11946,7 +12392,6 @@ "version": "2.16.2", "resolved": "https://registry.npmjs.org/is-core-module/-/is-core-module-2.16.2.tgz", "integrity": "sha512-evOr8xfXKxE6qSR0hSXL2r3sd7ALj8+7jQEUvPYcm5sgZFdJ+AYzT6yNmJenvIYQBgIGwfwz08sL8zoL7yq2BA==", - "dev": true, "license": "MIT", "dependencies": { "hasown": "^2.0.3" @@ -11962,7 +12407,6 @@ "version": "2.0.3", "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.3.tgz", "integrity": "sha512-ej4AhfhfL2Q2zpMmLo7U1Uv9+PyhIZpgQLGT1F9miIGmiCJIoCgSmczFdrc97mWT4kVY72KA+WnnhJ5pghSvSg==", - "dev": true, "license": "MIT", "dependencies": { "function-bind": "^1.1.2" @@ -12537,7 +12981,6 @@ "version": "4.0.0", "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==", - "dev": true, "license": "MIT" }, "node_modules/js-yaml": { @@ -12647,7 +13090,6 @@ "version": "3.1.0", "resolved": "https://registry.npmjs.org/jsesc/-/jsesc-3.1.0.tgz", "integrity": "sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA==", - "dev": true, "license": "MIT", "bin": { "jsesc": "bin/jsesc" @@ -12667,7 +13109,6 @@ "version": "2.3.1", "resolved": "https://registry.npmjs.org/json-parse-even-better-errors/-/json-parse-even-better-errors-2.3.1.tgz", "integrity": "sha512-xyFwyhro/JEof6Ghe2iz2NcXoj2sloNsWr/XsERDK/oiPCfaNhl5ONfp+jQdAZRQQ0IJWNzH9zIZF7li91kh2w==", - "dev": true, "license": "MIT" }, "node_modules/json-schema-traverse": { @@ -13300,7 +13741,6 @@ "version": "1.4.0", "resolved": "https://registry.npmjs.org/loose-envify/-/loose-envify-1.4.0.tgz", "integrity": "sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==", - "dev": true, "license": "MIT", "dependencies": { "js-tokens": "^3.0.0 || ^4.0.0" @@ -13672,7 +14112,6 @@ "version": "2.1.3", "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", - "dev": true, "license": "MIT" }, "node_modules/nanoid": { @@ -14116,7 +14555,6 @@ "version": "4.1.1", "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz", "integrity": "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==", - "dev": true, "license": "MIT", "engines": { "node": ">=0.10.0" @@ -14569,7 +15007,6 @@ "version": "1.0.1", "resolved": "https://registry.npmjs.org/parent-module/-/parent-module-1.0.1.tgz", "integrity": "sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g==", - "dev": true, "license": "MIT", "dependencies": { "callsites": "^3.0.0" @@ -14582,7 +15019,6 @@ "version": "5.2.0", "resolved": "https://registry.npmjs.org/parse-json/-/parse-json-5.2.0.tgz", "integrity": "sha512-ayCKvm/phCGxOkYRSCM82iDwct8/EonSEgCSxWxD7ve6jHggsFl4fZVQBPRNgQoKiuV/odhFrGzQXZwbifC8Rg==", - "dev": true, "license": "MIT", "dependencies": { "@babel/code-frame": "^7.0.0", @@ -14601,7 +15037,6 @@ "version": "1.2.4", "resolved": "https://registry.npmjs.org/lines-and-columns/-/lines-and-columns-1.2.4.tgz", "integrity": "sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg==", - "dev": true, "license": "MIT" }, "node_modules/parse-ms": { @@ -14677,7 +15112,6 @@ "version": "1.0.7", "resolved": "https://registry.npmjs.org/path-parse/-/path-parse-1.0.7.tgz", "integrity": "sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw==", - "dev": true, "license": "MIT" }, "node_modules/path-to-regexp": { @@ -14691,7 +15125,6 @@ "version": "4.0.0", "resolved": "https://registry.npmjs.org/path-type/-/path-type-4.0.0.tgz", "integrity": "sha512-gDKb8aZMDeD/tZWs9P6+q0J9Mwkdl6xMV8TjnGP3qJVJ06bdMgkbBlLU8IdfOsIsFz2BW1rNVT3XuNEl8zPAvw==", - "dev": true, "license": "MIT", "engines": { "node": ">=8" @@ -14715,7 +15148,6 @@ "version": "1.1.1", "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", - "dev": true, "license": "ISC" }, "node_modules/picomatch": { @@ -15061,7 +15493,6 @@ "version": "15.8.1", "resolved": "https://registry.npmjs.org/prop-types/-/prop-types-15.8.1.tgz", "integrity": "sha512-oj87CgZICdulUohogVAR7AjlC0327U4el4L6eAvOqCeudMDVU0NThNaV+b9Df4dXgSP1gXMTnPdhfe/2qDH5cg==", - "dev": true, "license": "MIT", "dependencies": { "loose-envify": "^1.4.0", @@ -15073,7 +15504,6 @@ "version": "16.13.1", "resolved": "https://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz", "integrity": "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==", - "dev": true, "license": "MIT" }, "node_modules/proxy-addr": { @@ -15278,6 +15708,22 @@ "url": "https://opencollective.com/express" } }, + "node_modules/react-transition-group": { + "version": "4.4.5", + "resolved": "https://registry.npmjs.org/react-transition-group/-/react-transition-group-4.4.5.tgz", + "integrity": "sha512-pZcd1MCJoiKiBR2NRxeCRg13uCXbydPnmB4EOeRrY7480qNWO8IIgQG6zlDkm6uRMsURXPuKq0GWtiM59a5Q6g==", + "license": "BSD-3-Clause", + "dependencies": { + "@babel/runtime": "^7.5.5", + "dom-helpers": "^5.0.1", + "loose-envify": "^1.4.0", + "prop-types": "^15.6.2" + }, + "peerDependencies": { + "react": ">=16.6.0", + "react-dom": ">=16.6.0" + } + }, "node_modules/readable-stream": { "version": "3.6.2", "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-3.6.2.tgz", @@ -15426,7 +15872,6 @@ "version": "1.22.12", "resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.12.tgz", "integrity": "sha512-TyeJ1zif53BPfHootBGwPRYT1RUt6oGWsaQr8UyZW/eAm9bKoijtvruSDEmZHm92CwS9nj7/fWttqPCgzep8CA==", - "dev": true, "license": "MIT", "dependencies": { "es-errors": "^1.3.0", @@ -15455,7 +15900,6 @@ "version": "4.0.0", "resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-4.0.0.tgz", "integrity": "sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g==", - "dev": true, "license": "MIT", "engines": { "node": ">=4" @@ -16659,6 +17103,12 @@ "url": "https://github.com/sponsors/Borewit" } }, + "node_modules/stylis": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/stylis/-/stylis-4.2.0.tgz", + "integrity": "sha512-Orov6g6BB1sDfYgzWfTHDOxamtX1bE/zo104Dh9e6fqJ3PooipYyfJ0pUmrZO2wAvO8YbEyeFrkV91XTsGMSrw==", + "license": "MIT" + }, "node_modules/super-regex": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/super-regex/-/super-regex-1.1.0.tgz", @@ -16694,7 +17144,6 @@ "version": "1.0.0", "resolved": "https://registry.npmjs.org/supports-preserve-symlinks-flag/-/supports-preserve-symlinks-flag-1.0.0.tgz", "integrity": "sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w==", - "dev": true, "license": "MIT", "engines": { "node": ">= 0.4" diff --git a/package.json b/package.json index 2fb40a96..18907e89 100644 --- a/package.json +++ b/package.json @@ -85,6 +85,10 @@ "web/*" ], "dependencies": { + "@emotion/react": "^11.14.0", + "@emotion/styled": "^11.14.1", + "@mui/icons-material": "^7.3.11", + "@mui/material": "^7.3.11", "react": "^19.0.0", "react-dom": "^19.0.0", "react-router-dom": "^7.18.2" diff --git a/packages/prices-api/src/portal/auth/discord.rs b/packages/prices-api/src/portal/auth/discord.rs index bcae6b62..27fa4f96 100644 --- a/packages/prices-api/src/portal/auth/discord.rs +++ b/packages/prices-api/src/portal/auth/discord.rs @@ -88,14 +88,31 @@ pub const DEFAULT_AUTHORIZE_URL: &str = "https://discord.com/oauth2/authorize"; /// Base of Discord's REST API. `/oauth2/token` and `/users/@me` hang off it. pub const DEFAULT_API_BASE: &str = "https://discord.com/api"; -/// Timeout for each of the two calls. +/// Timeout for each Discord call. /// /// The api-handler's own Lambda timeout is 15s (`production.json`) and API -/// Gateway's ceiling is 29s, so two untimed calls could burn the whole budget -/// and return nothing. Five seconds each leaves room for both plus the handler's -/// own work, and a Discord that is slower than that is a Discord that is down — -/// which the visitor is better told about than left waiting for. -const REQUEST_TIMEOUT: Duration = Duration::from_secs(5); +/// Gateway's ceiling is 29s, so untimed calls could burn the whole budget and +/// return nothing. A Discord that is slower than this is a Discord that is +/// down — which the visitor is better told about than left waiting for. +/// +/// ⚠️ **Four seconds, not five, since 2026-08-27.** The sign-in callback now +/// makes THREE calls where it made two — the token exchange, the membership +/// read added on 2026-08-26, and the identity read — and they are serial by +/// design (the token is borrowed by the first and consumed by the last). At +/// five seconds each plus the parameter reads, the slow-but-not-failing case +/// summed past the 15s invocation timeout: the Lambda was killed and the +/// browser got a bare API Gateway 502 instead of any of the designed screens. +/// The arithmetic that has to keep holding, worst case: +/// +/// ```text +/// exchange 4s + parameters 2s + membership 4s + identity 4s = 14s < 15s +/// ``` +/// +/// `PARAMETER_TIMEOUT` in `portal/eligibility.rs` is the 2s term. Raising +/// either constant, or adding a fourth call, needs this sum redone and +/// `timeoutSeconds` in `infra/envs/production.json` checked against it — +/// `issue::tests::budget_arithmetic_fits_the_lambda` does the sum. +pub(super) const REQUEST_TIMEOUT: Duration = Duration::from_secs(4); /// Endpoints, separated from the credentials so tests can point them at a /// loopback mock while using the same code path production does. diff --git a/packages/prices-api/src/portal/auth/issue.rs b/packages/prices-api/src/portal/auth/issue.rs index 02383e73..58d4a8f7 100644 --- a/packages/prices-api/src/portal/auth/issue.rs +++ b/packages/prices-api/src/portal/auth/issue.rs @@ -186,10 +186,13 @@ pub(super) fn refuse_issue_discord( /// `keys::RECONCILE_DEADLINE` is 10s and was sized for 0187, where the /// reconciliation was essentially the entire request: "10s leaves the handler /// ~5s of the function's budget". This path puts four more network calls in -/// front of it — the token exchange (5s), two Parameters and Secrets reads -/// (2s each) and two Discord reads (5s each) — so reusing that constant -/// unchanged would let the worst case reach ~29s against -/// `apiHandler.timeoutSeconds` of **15**. Lambda would kill the invocation +/// front of it — the token exchange (4s), two Parameters and Secrets reads +/// (2s, **joined**, so they cost one wait and not two) and two Discord reads +/// (4s each) — so reusing that constant unchanged would let the worst case +/// reach ~24s against `apiHandler.timeoutSeconds` of **15**. The +/// `discord::REQUEST_TIMEOUT` docblock carries the sum that has to keep +/// holding — `4 + 2 + 4 + 4 = 14s` — and `budget_arithmetic_fits_the_lambda` +/// below pins it, so raising a term here fails a test rather than a visitor. Lambda would kill the invocation /// with no response at all: the visitor gets API Gateway's bare `502` instead /// of the designed `?issue=failed` redirect, an `Errors` datapoint is /// recorded, and a key may exist that was never attached — precisely the @@ -312,21 +315,25 @@ pub(super) async fn complete_issue( tracing::error!("an issue callback arrived with no eligibility settings wired"); None } - Some(settings) => match ( - settings.guild_id().await, - settings.min_account_age_minutes().await, - ) { - (Ok(guild_id), Ok(min_age)) => Some((guild_id, min_age)), - (guild, age) => { - for error in [guild.err(), age.err()].into_iter().flatten() { - tracing::error!( - error = %error, - "eligibility parameters could not be read; refusing without accusation" - ); + // Joined, as the sign-in path joins them (`auth/mod.rs`): read one + // after the other they cost two `PARAMETER_TIMEOUT`s in the worst + // case, and that is the 2s that put this callback at 16s against the + // 15s invocation — the Lambda killed, a bare API Gateway 502, and + // possibly a key created and never attached. + Some(settings) => { + match tokio::join!(settings.guild_id(), settings.min_account_age_minutes()) { + (Ok(guild_id), Ok(min_age)) => Some((guild_id, min_age)), + (guild, age) => { + for error in [guild.err(), age.err()].into_iter().flatten() { + tracing::error!( + error = %error, + "eligibility parameters could not be read; refusing without accusation" + ); + } + None } - None } - }, + } }; // The membership call BORROWS the token; the identity read then consumes @@ -406,44 +413,167 @@ pub(super) async fn complete_issue( ); land(ISSUE_UNKNOWN_QUERY) } - Eligibility::Eligible => { - let Some(gateway) = state.issue.gateway.as_deref() else { + Eligibility::Eligible => match issue(state, &user.id, started).await { + // An explicit press lands on the welcome whether the key was + // minted or adopted — the visitor asked for a key and has one. + Issued { .. } => land(ISSUE_OK_QUERY), + Capped { next_eligible_date } => land(&capped_query(&next_eligible_date)), + Failed => land(ISSUE_FAILED_QUERY), + Unwired => { tracing::error!("an eligible issue callback arrived with no control plane wired"); - return land(ISSUE_FAILED_QUERY); - }; - - // What is left of the invocation, not a constant. Everything above - // — the exchange, both parameter reads, both Discord reads — has - // already been paid for out of the same budget. - let remaining = ISSUE_BUDGET.saturating_sub(started.elapsed()); - if remaining < RECONCILE_FLOOR { - tracing::error!( - remaining_ms = remaining.as_millis() as u64, - "eligibility passed but the invocation's budget is spent; \ - refusing to start a reconciliation that cannot finish" - ); - return land(ISSUE_FAILED_QUERY); + land(ISSUE_FAILED_QUERY) } - // `min`, so the configured deadline stays an upper bound and the - // `with_deadline` test seam keeps working. - let deadline = remaining.min(state.issue.deadline); - - match keys::issue_for(gateway, &user.id, deadline).await { - IssueOutcome::Issued => { - // A key now exists, so a cached "no key" on the usage - // route is false — same eviction the reveal performs, - // for the page this redirect is about to land on. - if let Some(cache) = &state.issue.usage_cache { - cache.invalidate_no_key(&user.id); - } - land(ISSUE_OK_QUERY) - } - IssueOutcome::Capped { next_eligible_date } => { - land(&capped_query(&next_eligible_date)) - } - IssueOutcome::Failed => land(ISSUE_FAILED_QUERY), + }, + } +} + +/// What [`issue`] settled on — [`IssueOutcome`] plus the one case that is the +/// deployment's fault rather than the reconciler's, so each caller can land +/// it in its own words. +pub(super) enum Landing { + Issued { + created: bool, + }, + Capped { + next_eligible_date: String, + }, + Failed, + /// No control plane wired. Nothing was attempted. + Unwired, +} +use Landing::*; + +/// Run the reconciler for `user_id` inside what is left of the invocation. +/// +/// The shared half of two callbacks: the explicit `action=issue` press +/// ([`complete_issue`]) and, since 2026-08-26, the sign-in round-trip +/// ([`after_sign_in`]). What each does with the answer differs; what it costs +/// and how it is bounded does not, and it lives once. +/// +/// `started` is stamped when the **request** arrived — see [`ISSUE_BUDGET`]. +async fn issue(state: &AuthState, user_id: &str, started: Instant) -> Landing { + let Some(gateway) = state.issue.gateway.as_deref() else { + return Unwired; + }; + + // What is left of the invocation, not a constant. Everything before this + // — the exchange, both parameter reads, both Discord reads — has already + // been paid for out of the same budget. + let remaining = ISSUE_BUDGET.saturating_sub(started.elapsed()); + if remaining < RECONCILE_FLOOR { + tracing::error!( + remaining_ms = remaining.as_millis() as u64, + "eligibility passed but the invocation's budget is spent; \ + refusing to start a reconciliation that cannot finish" + ); + return Failed; + } + // `min`, so the configured deadline stays an upper bound and the + // `with_deadline` test seam keeps working. + let deadline = remaining.min(state.issue.deadline); + + match keys::issue_for(gateway, user_id, deadline).await { + IssueOutcome::Issued { created } => { + // A key now exists, so a cached "no key" on the usage route is + // false — same eviction the reveal performs, for the page this + // redirect is about to land on. + if let Some(cache) = &state.issue.usage_cache { + cache.invalidate_no_key(user_id); } + Issued { created } + } + IssueOutcome::Capped { next_eligible_date } => Capped { next_eligible_date }, + IssueOutcome::Failed => Failed, + } +} + +/// The key half of a **sign-in** callback (Adam, 2026-08-26): the landing +/// query for a visitor who has just proved membership and identity. +/// +/// **Why sign-in issues at all.** Task 0193's first acceptance criterion is +/// "first sign-in lands on the dashboard with the key visible and copyable; +/// returning shows the same key". Until this, sign-in proved identity and +/// stopped, and a first-timer met an empty dashboard with a button that ran +/// the whole round-trip again. This is that second round-trip folded into the +/// first, with two differences from an explicit press, both about what the +/// visitor is TOLD: +/// +/// - An adopted key lands on the plain dashboard, not the welcome. `?issue=ok` +/// says "Your API Key is ready · Just issued", which is false of a key that +/// has existed for months; the dashboard's own reveal shows it. +/// - A revoked key lands plain too. The explicit press lands `?issue=capped` +/// because it is answering a request for a new key; a sign-in made no such +/// request, and the dashboard's `GET /key` renders the revoked card, which +/// is the screen for that account. And once the period has rolled the +/// reconciler simply mints one — which is exactly what that card's footer +/// promises ("sign in again to receive a new key automatically"). +/// +/// **Age is checked here, not at sign-in's gate.** Membership was proved +/// before the session was written (a non-member gets no session); a too-young +/// account IS signed in and lands `?issue=too_young`, because an account old +/// enough once is old enough forever and it is entitled to read the dashboard +/// while it waits. What it must not do is reach the reconciler: `issue_for` +/// adopts without an age check, so skipping `decide` here would mint a key for +/// exactly the account the threshold exists to refuse. +/// +/// `Unwired` lands plain with an error line rather than `?issue=failed`: the +/// session is the sign-in's deliverable and it exists; the dashboard's own +/// issue control reaches `refuse_issue_start`, which reports the fault to the +/// visitor in the state that means "our key service, not you". +/// +/// **`Failed` lands plain too** — the sign-in made no request for a key, and +/// `?issue=failed` on a returning member's dashboard sits next to the working +/// key `GET /key` reveals a moment later: a banner saying our key service +/// failed, over a key it plainly did not fail to keep. On a cold start the +/// budget can already be spent by the exchange, the parameter reads and two +/// Discord calls, which made this the common case, not the corner. A visitor +/// with no key meets the no-key dashboard, whose issue control runs the +/// explicit round-trip and reports its own `failed` when it earns one. +/// +/// `min_age_minutes` is `None` when the parameter could not be read at +/// sign-in: the session still exists, but without the threshold there is no +/// safe verdict on age, so no key is issued and the landing is plain. +pub(super) async fn after_sign_in( + state: &AuthState, + member: &MemberLookup, + user_id: &str, + min_age_minutes: Option, + started: Instant, +) -> String { + let Some(min_age_minutes) = min_age_minutes else { + tracing::warn!( + "sign-in has no account-age threshold to check against; landing without a key" + ); + return String::new(); + }; + match eligibility::decide(member, user_id, min_age_minutes, eligibility::now_ms()) { + Eligibility::TooYoung { wait_secs } => { + tracing::info!(outcome = "too_young", wait_secs, "sign-in issued no key"); + too_young_query(wait_secs) + } + // Membership was decided before the session was written; `decide` + // re-derives the same answer from the same `MemberLookup`, so these + // two arms are unreachable. Land plain rather than panic — the + // dashboard's issue control will re-ask and land the real verdict. + Eligibility::NotMember | Eligibility::Unknown => { + tracing::warn!("sign-in passed membership but `decide` did not; landing without a key"); + String::new() } + Eligibility::Eligible => match issue(state, user_id, started).await { + Issued { created: true } => ISSUE_OK_QUERY.to_string(), + Issued { created: false } => String::new(), + Capped { .. } => String::new(), + Failed => { + tracing::warn!("sign-in could not issue or adopt a key; landing without one"); + String::new() + } + Unwired => { + tracing::error!( + "a sign-in callback arrived with no control plane wired; no key issued" + ); + String::new() + } + }, } } @@ -451,6 +581,26 @@ pub(super) async fn complete_issue( mod tests { use super::*; + /// The slow-but-not-failing worst case of both callbacks — the token + /// exchange, the two parameter reads (joined, so one wait), the + /// membership read and the identity read — has to finish inside the + /// invocation timeout, or Lambda kills it and the browser gets API + /// Gateway's bare `502` in place of every screen this module lands on. + /// The 15 is `apiHandler.timeoutSeconds` in `infra/envs/production.json`; + /// raise either constant, or add a call, and this is what fails first. + #[test] + fn budget_arithmetic_fits_the_lambda() { + const LAMBDA_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(15); + let worst = discord::REQUEST_TIMEOUT * 3 + crate::portal::eligibility::PARAMETER_TIMEOUT; + assert!( + worst < LAMBDA_TIMEOUT, + "worst case {worst:?} does not fit inside {LAMBDA_TIMEOUT:?}" + ); + // And the reconciler's share is measured from arrival, so it cannot + // extend the callback past the same line. + assert!(ISSUE_BUDGET < LAMBDA_TIMEOUT); + } + /// Every landing state is a distinct literal under the portal home, /// like `?signin=…` — extending `the_only_redirect_targets_are_the_portal /// _itself` to the issue flow. diff --git a/packages/prices-api/src/portal/auth/mod.rs b/packages/prices-api/src/portal/auth/mod.rs index fb586d92..89354656 100644 --- a/packages/prices-api/src/portal/auth/mod.rs +++ b/packages/prices-api/src/portal/auth/mod.rs @@ -7,7 +7,7 @@ //! //! | route | does | //! | --- | --- | -//! | `GET /auth/login` | mints `state` + PKCE, redirects to Discord | +//! | `GET /auth/login` | mints `state` + PKCE, redirects to Discord; the callback proves membership, signs in, and issues the first key | //! | `GET /auth/login?action=issue` | the same, for the eligibility-checked issue round-trip ([0189]) | //! | `GET /auth/callback` | verifies `state`, exchanges the code, completes the action it names | //! | `GET /auth/me` | reports who the caller is, or that they are nobody | @@ -20,12 +20,25 @@ //! //! # What a callback completes depends on what its `state` names //! -//! A `signin` callback issues a session and nothing else. An `issue` callback -//! ([`issue`], task 0189) additionally checks guild membership and account age -//! against the **fresh** token before handing off to the key path — that is -//! ADR 0010 §8's "the callback completes that action and nothing else", and -//! the action slot in [`state_token`] is what makes the two round-trips -//! non-interchangeable. Nothing here reads or writes any store — there is no +//! A `signin` callback proves **guild membership** and, if it holds, issues a +//! session — and then, since 2026-08-26, runs the same age check and reconciler +//! the `issue` callback runs, so a first sign-in lands with a key +//! ([`issue::after_sign_in`]; task 0193's first acceptance criterion). An +//! `issue` callback ([`issue`], task 0189) is the explicit press: it proves +//! membership **and account age** against its own **fresh** token before +//! handing off to the key path. The action slot in [`state_token`] still keeps +//! the two round-trips non-interchangeable — they land on different states for +//! the same outcome (an adopted key is the plain dashboard from sign-in and the +//! welcome from a press). +//! +//! ⚠️ **Sign-in checked identity alone until 2026-08-26 (Adam).** The gate was +//! [0189]'s and stood only at the key, so a non-member could sign in and meet +//! the refusal one press later. It now stands in both places — and it has to +//! stand in both: the session carries no eligibility claim (ADR 0010 §8), so +//! the sign-in check expires with the sign-in that ran it and proves nothing +//! about the next action. Age is deliberately NOT re-checked here; an account +//! old enough once is old enough forever, and failing a young account's +//! sign-in would lock it out of a dashboard it may read. Nothing here reads or writes any store — there is no //! registry yet ([0190] decides whether there ever is one) and no Discord //! token is kept (see [`session`]). //! @@ -58,6 +71,7 @@ use serde::{Deserialize, Serialize}; use crate::common::extract::ValidatedQuery; use crate::common::{cache_control, errors}; +use super::eligibility; use secret::OauthSecret; use session::Session; use state_token::{Action, StateError}; @@ -89,6 +103,39 @@ const PORTAL_HOME: &str = "/api-tokens/"; /// looking signed out. const CANCELLED_QUERY: &str = "?signin=cancelled"; +/// Appended to [`PORTAL_HOME`] when the visitor is not a member of the guild. +/// +/// ⚠️ **Sign-in refuses on membership as of 2026-08-26 (Adam).** Until then the +/// gate stood only on the issue round-trip ([0189]) and signing in proved +/// identity alone ([0186]): a non-member reached the dashboard and met the +/// refusal one press later, at the key. The gate is now BOTH places, not moved +/// — see the sign-in tail in [`callback`] for why the later one cannot go. +const NOT_MEMBER_QUERY: &str = "?signin=not_member"; + +/// Appended to [`PORTAL_HOME`] when the membership question could not be +/// answered — Discord unreachable, an unreadable guild parameter, a response +/// with no `pending` field, or a deployment with no eligibility settings wired. +/// +/// A separate landing from [`NOT_MEMBER_QUERY`] for the reason [0189] gives and +/// [0193] made an acceptance criterion: "could not verify" is our fault and is +/// retryable, "not a member" is a statement about the visitor that they cannot +/// act on if it is wrong. Never collapse the two. +const UNKNOWN_QUERY: &str = "?signin=unknown"; + +/// Appended to [`PORTAL_HOME`] when this deployment cannot ask the eligibility +/// question at all — no settings wired. +/// +/// ⚠️ **Not [`UNKNOWN_QUERY`], which it used to be.** That literal renders +/// [0189]'s "we could not check your membership — a problem talking to +/// Discord" copy, and on an unwired build there is no Discord problem and no +/// membership question in flight: the portal is not open yet. Landing both on +/// one screen is the collapse [0193]'s "could not verify is not not-a-member" +/// criterion forbids, one level up — a transient fault rendered for a +/// permanent state, telling the visitor to retry something that cannot succeed +/// until an operator acts. The page reads this literal and renders [0183]'s +/// closed-portal card, whose wording already exists and is true. +const NOT_OPEN_QUERY: &str = "?signin=not_open"; + /// Appended to [`PORTAL_HOME`] when Discord refused the request for a reason /// that is **not** the visitor declining. /// @@ -498,6 +545,125 @@ async fn callback( return issue::complete_issue(&state, oauth, token, drop_pending, started).await; } + // ⚠️ **Membership is proved HERE too, as of 2026-08-26 (Adam).** + // + // Task 0186 made sign-in identity-only and 0189 put the whole eligibility + // gate on the issue round-trip. That is why a non-member could sign in: + // nothing on this path ever asked. It now asks, and refuses before any + // session cookie is written — a refused sign-in leaves the visitor signed + // out, which is the only refusal a page with no dashboard behind it can + // express. + // + // **This ADDS a gate, it does not move one.** The issue and rework paths + // still re-prove membership per action, and must: ADR 0010 §8 forbids the + // session from carrying an eligibility claim, precisely because a cookie + // minted today would still be asserting "member" weeks later when the + // visitor has left the server. This check therefore expires with the + // sign-in that ran it and proves nothing about the next action. + // + // **Membership only — never the age check.** `eligibility::membership` is + // the half [0191]'s rework already re-proves on its own; an account old + // enough once is old enough forever, so making a first-day account fail to + // SIGN IN would lock it out of a dashboard it is entitled to read the + // moment it is old enough. Age stays where 0189 put it: at the key. + // + // The token is BORROWED here and consumed by the identity read below, in + // the order `issue::complete_issue` uses and for the same reason: one + // round-trip answers both questions. + // Both parameters are read, not just the guild: the age threshold is + // consulted AFTER the session is written, by `issue::after_sign_in`, and + // reading it here means one SSM round-trip serves both questions. But + // they are read SEPARATELY, and only the guild gates the session. ⚠️ They + // used to fail together: a min-account-age parameter that was unseeded, + // throttled or unreadable refused EVERY sign-in as `unknown` — returning + // members included, over a value the sign-in itself never consults. The + // age read failing now costs the visitor the key half only: they are + // signed in, land plain, and the dashboard's issue control re-asks. + let (checked, min_age): (Option, Option) = match state + .issue + .settings + .as_deref() + { + Some(settings) => { + let (guild_id, min_age) = + tokio::join!(settings.guild_id(), settings.min_account_age_minutes()); + let min_age = match min_age { + Ok(min_age) => Some(min_age), + Err(error) => { + tracing::error!( + error = %error, + "min-account-age parameter could not be read at sign-in; \ + the session proceeds, no key is issued" + ); + None + } + }; + match guild_id { + Ok(guild_id) => { + let looked_up = + discord::guild_member(&state.http, &state.endpoints, &token, &guild_id) + .await; + if let discord::MemberLookup::NotMember { code: 10_004 } = looked_up { + // "Unknown Guild" is far more likely to be OUR mis-seeded + // parameter than the visitor's standing. Same warn, same + // reasoning, as the issue path's. + tracing::warn!( + guild_id = %guild_id, + "sign-in membership check answered Unknown Guild (10004) — \ + is the discord-guild-id parameter right?" + ); + } + (Some(looked_up), min_age) + } + Err(error) => { + tracing::error!( + error = %error, + "guild-id parameter could not be read at sign-in; refusing without accusation" + ); + (None, min_age) + } + } + } + // Fail closed, and deliberately: an unwired deployment already refuses + // `action=issue`, so letting sign-in through would seat visitors on a + // dashboard whose only action is guaranteed to refuse them. A portal + // that cannot ask the question is a portal that is not open yet, which + // is [0183]'s state and has a screen of its own — and, since + // 2026-08-27, the screen this lands on: see `NOT_OPEN_QUERY`. + None => { + tracing::error!("a sign-in callback arrived with no eligibility settings wired"); + return redirect( + &format!("{PORTAL_HOME}{NOT_OPEN_QUERY}"), + vec![drop_pending], + ); + } + }; + let membership = checked + .as_ref() + .map(eligibility::membership) + .unwrap_or(eligibility::Membership::Unknown); + + match membership { + eligibility::Membership::Member => {} + eligibility::Membership::NotMember => { + tracing::info!(outcome = "not_member", "portal sign-in refused"); + return redirect( + &format!("{PORTAL_HOME}{NOT_MEMBER_QUERY}"), + vec![drop_pending], + ); + } + eligibility::Membership::Unknown => { + // The load-bearing warns (which check could not answer, and why) + // fired where the answer was known; this line says what the + // visitor was told. + tracing::info!( + outcome = "unknown", + "portal sign-in refused without accusation" + ); + return redirect(&format!("{PORTAL_HOME}{UNKNOWN_QUERY}"), vec![drop_pending]); + } + } + // `token` is moved here, so from this line on the handler cannot reach it. let user = match discord::current_user(&state.http, &state.endpoints, token).await { Ok(user) => user, @@ -505,8 +671,17 @@ async fn callback( }; let session = Session::issue(&user.id, &user.username, state_token::now_secs()); + + // The first key, on the first sign-in (Adam, 2026-08-26) — see + // `issue::after_sign_in` for what lands where. `checked` is `Some` here + // by construction: `Membership::Member` above is derived from it. + let landing = match checked.as_ref() { + Some(member) => issue::after_sign_in(&state, member, &user.id, min_age, started).await, + None => String::new(), + }; + redirect( - PORTAL_HOME, + &format!("{PORTAL_HOME}{landing}"), vec![ drop_pending, cookies::set( @@ -1020,6 +1195,8 @@ mod tests { #[test] fn the_two_landing_states_are_distinct_literals() { assert_eq!(CANCELLED_QUERY, "?signin=cancelled"); + assert_eq!(NOT_MEMBER_QUERY, "?signin=not_member"); + assert_eq!(UNKNOWN_QUERY, "?signin=unknown"); assert_eq!(FAILED_QUERY, "?signin=failed"); assert_ne!(CANCELLED_QUERY, FAILED_QUERY); assert_eq!(ERROR_ACCESS_DENIED, "access_denied"); @@ -1040,10 +1217,15 @@ mod tests { for query in [ CANCELLED_QUERY, FAILED_QUERY, + NOT_MEMBER_QUERY, + UNKNOWN_QUERY, + NOT_OPEN_QUERY, issue::ISSUE_OK_QUERY, issue::ISSUE_NOT_MEMBER_QUERY, issue::ISSUE_UNKNOWN_QUERY, issue::ISSUE_FAILED_QUERY, + issue::ISSUE_CANCELLED_QUERY, + issue::ISSUE_DENIED_QUERY, &issue::too_young_query(173), &issue::capped_query("2026-09-01"), ] { diff --git a/packages/prices-api/src/portal/eligibility.rs b/packages/prices-api/src/portal/eligibility.rs index a0966e45..d15a654f 100644 --- a/packages/prices-api/src/portal/eligibility.rs +++ b/packages/prices-api/src/portal/eligibility.rs @@ -58,6 +58,29 @@ use super::auth::discord::MemberLookup; /// The high 42 bits of a snowflake are milliseconds since this instant. const DISCORD_EPOCH_MS: u64 = 1_420_070_400_000; +/// How long one parameter read may take. +/// +/// The reads go to the Parameters and Secrets extension on localhost, which +/// answers from its own cache in microseconds when warm and makes a real SSM +/// call when cold — and had no bound at all, so a cold, throttled read could +/// sit inside the sign-in callback until the Lambda was killed. Two seconds is +/// far above a warm read and below what the callback can afford: it is the +/// `parameters` term in the arithmetic written out on `REQUEST_TIMEOUT` in +/// `portal/auth/discord.rs`, which is what keeps the worst case under the 15s +/// invocation timeout. +/// +/// A read that exceeds it is [`EligibilityError::Fetch`], which every caller +/// already renders as "could not verify" rather than as an accusation. +/// Only the extension client can be slow — the `Direct` source is a value +/// already in memory, and the build without the client fails immediately — so +/// the constant lives with the code that can actually wait. +/// +/// `pub(crate)` and unconditional so `auth::issue`'s budget test can add it +/// up against the invocation timeout in every build, not only the one with +/// the client that waits on it. +#[cfg_attr(not(feature = "aws-mtls"), allow(dead_code))] +pub(crate) const PARAMETER_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(2); + /// Where one eligibility parameter's value comes from. #[derive(Debug, Clone)] pub enum ParamSource { @@ -154,12 +177,27 @@ pub enum EligibilityError { /// per-action read does not call Systems Manager on a warm container. #[cfg(feature = "aws-mtls")] async fn fetch_parameter(name: &str) -> Result { - prices_clickhouse::mtls::fetch_parameter_string(name) - .await - .map_err(|e| EligibilityError::Fetch { + let fetch = prices_clickhouse::mtls::fetch_parameter_string(name); + match tokio::time::timeout(PARAMETER_TIMEOUT, fetch).await { + Ok(result) => result.map_err(|e| EligibilityError::Fetch { name: name.to_string(), message: e.to_string(), - }) + }), + Err(_) => { + tracing::error!( + name, + timeout_secs = PARAMETER_TIMEOUT.as_secs(), + "eligibility parameter read timed out" + ); + Err(EligibilityError::Fetch { + name: name.to_string(), + message: format!( + "the parameter read did not answer within {}s", + PARAMETER_TIMEOUT.as_secs() + ), + }) + } + } } #[cfg(not(feature = "aws-mtls"))] diff --git a/packages/prices-api/src/portal/keys/mod.rs b/packages/prices-api/src/portal/keys/mod.rs index 500d11aa..8ac588cd 100644 --- a/packages/prices-api/src/portal/keys/mod.rs +++ b/packages/prices-api/src/portal/keys/mod.rs @@ -278,6 +278,22 @@ struct KeyResponse { name: String, /// The key itself — what goes in `X-API-Key`. value: String, + /// `createdDate`, RFC 3339 — when API Gateway minted this key. + /// + /// The dashboard's "Issued" field (task 0193's frame `778:2499`). Read off + /// the listing this route already made, so it costs no extra call; `None` + /// only where AWS omitted the field, which it does not do in practice — + /// the option follows [`naming::KeyRecord`], which types it that way + /// because the SDK does. + created_at: Option, + /// `lastUpdatedDate`, RFC 3339 — the last change to the key RECORD. + /// + /// Deliberately not called "rotated": this build has no rotation, and the + /// audit under task 0191 measured that a no-op patch and a `description` + /// edit from the console both bump this value. It is what the re-issue cap + /// is decided against for a revoked key, and on the dashboard it is + /// labelled "Last updated" for exactly that reason. + last_updated_at: Option, } /// Both verbs, one handler, and both are the **reveal**. @@ -393,6 +409,8 @@ async fn reveal(state: &KeysState, headers: &HeaderMap) -> Response { // exists: everything else in this module can only hold the // value, never read it. value: value.expose().to_string(), + created_at: record.created_at.and_then(rfc3339), + last_updated_at: record.last_updated_at.and_then(rfc3339), }) .into_response(), ) @@ -462,10 +480,22 @@ fn no_key_response() -> Response { } /// Unix seconds → RFC 3339, for the envelopes. -fn rfc3339(secs: u64) -> String { - chrono::DateTime::::from_timestamp(secs as i64, 0) - .map(|t| t.to_rfc3339_opts(chrono::SecondsFormat::Secs, true)) - .unwrap_or_default() +/// +/// `None` for a value `chrono` cannot place on the calendar, never `""`. Both +/// instants in the reveal envelope are `Option` and the contract is +/// that an unknown one is `null` (`an_undated_key_reveals_a_null_instant_ +/// _rather_than_the_epoch`); an empty string would be a third shape no +/// consumer is written for, and `describeUtcDay('')` renders it as nothing at +/// all — the response would lie rather than admit the gap. +fn rfc3339(secs: u64) -> Option { + let stamped = chrono::DateTime::::from_timestamp(secs as i64, 0); + if stamped.is_none() { + tracing::warn!( + secs, + "a key instant is not a representable date; reporting null" + ); + } + stamped.map(|t| t.to_rfc3339_opts(chrono::SecondsFormat::Secs, true)) } /// What the revoke answers. @@ -595,13 +625,17 @@ async fn revoke(State(state): State, headers: HeaderMap) -> Response Cap::Capped { next_eligible_at, .. } => next_eligible_at, - Cap::Allowed => rfc3339(now_secs()), + // `now_secs()` is a representable date by construction, so + // the fallback is unreachable; it exists so that `None` can + // never be flattened into an empty string, which is the shape + // `rfc3339` was changed to stop producing. + Cap::Allowed => rfc3339(now_secs()).unwrap_or_else(|| Period::now().resets_at()), }; no_store( Json(RevokeResponse { revoked: true, next_eligible_at, - revoked_at: at.map(rfc3339), + revoked_at: at.and_then(rfc3339), partial, }) .into_response(), @@ -806,7 +840,11 @@ pub(crate) enum IssueOutcome { /// now or adopted. The value is deliberately not carried: the callback /// that consumes this answers with a redirect, and a credential must /// never ride in a `Location`. - Issued, + /// + /// `created` tells the two apart, and it matters since 2026-08-26: the + /// sign-in round-trip issues too, and a visitor whose months-old key was + /// merely ADOPTED must not land on "Your API Key is ready". + Issued { created: bool }, /// The owner revoked their key inside the current quota period, so no /// new one is issued until it rolls (task 0191). Nothing was written. Capped { @@ -842,7 +880,9 @@ pub(crate) async fn issue_for(gateway: &Gateway, sub: &str, deadline: Duration) created = outcome.created, "portal issued an API key" ); - IssueOutcome::Issued + IssueOutcome::Issued { + created: outcome.created, + } } Ok(Ok(Reconciled::Capped { next_eligible_date })) => { tracing::info!( diff --git a/packages/prices-api/tests/portal_auth.rs b/packages/prices-api/tests/portal_auth.rs index 4cdd98ca..5d2c8507 100644 --- a/packages/prices-api/tests/portal_auth.rs +++ b/packages/prices-api/tests/portal_auth.rs @@ -32,7 +32,14 @@ use tower::ServiceExt; #[path = "common/mock_discord.rs"] mod mock_discord; -use mock_discord::{GRANTED_SCOPE, MockDiscord}; +use mock_discord::{GRANTED_SCOPE, MemberReply, MockDiscord, USER_ID}; +// The mock control plane, for the sign-in tests that issue a key (2026-08-26). +// Only the two names, never a glob: the harness defines an `oauth_secret` and a +// `USER_ID` of its own, and this file already has both. +#[path = "portal_keys/harness.rs"] +mod harness; +use harness::{MockGateway, PLAN_ID}; +use prices_api::portal::keys::gateway::Gateway; // --------------------------------------------------------------------------- // Router under test @@ -88,6 +95,27 @@ fn app_against(mock: &MockDiscord) -> Router { ) } +/// The guild sign-in gates on. Any syntactically valid snowflake; the same +/// constant the issue suite uses, duplicated rather than shared because this +/// file does not pull in `portal_keys/harness.rs`. +const GUILD_ID: &str = "897514728459468821"; + +/// Eligibility settings wired straight from values, no SSM. +/// +/// ⚠️ Every sign-in test needs these as of 2026-08-26: the callback now proves +/// guild membership before it writes a session, and a router with no settings +/// refuses with `?signin=unknown` — fail-closed, deliberately. A test that +/// wants the refusal builds its own router; the default one is the happy path. +fn eligibility_settings() -> prices_api::portal::eligibility::EligibilitySettings { + use prices_api::portal::eligibility::{EligibilitySettings, ParamSource}; + EligibilitySettings { + // `min_account_age` is present but never consulted on this path — + // sign-in proves membership only. Age stays at the key (task 0189). + guild_id: ParamSource::Direct(GUILD_ID.to_string()), + min_account_age: ParamSource::Direct("5".to_string()), + } +} + fn build_app(portal_enabled: bool, endpoints: Endpoints) -> Router { let config = AppConfig { ch_enabled: false, @@ -100,7 +128,7 @@ fn build_app(portal_enabled: bool, endpoints: Endpoints) -> Router { // is what every non-portal test wants — with no client in the // config there is no code path here that can reach API Gateway. portal_keys: None, - portal_eligibility: None, + portal_eligibility: portal_enabled.then(eligibility_settings), portal_rate_limit: None, }; app(&config, AppState::without_ch()) @@ -481,6 +509,9 @@ async fn a_complete_round_trip_signs_the_visitor_in() { .await; assert_eq!(reply.status, StatusCode::SEE_OTHER); + // Plain, because `build_app` wires no control plane: with nothing to + // issue against, sign-in does not try. The issuing sign-in — the shape a + // real deployment has — is covered by the `app_with_keys` tests below. assert_eq!( reply.location(), "/api-tokens/", @@ -511,10 +542,19 @@ async fn a_complete_round_trip_signs_the_visitor_in() { // And the pending cookie is gone — the replay defence, on the wire. assert!(reply.clears(cookies::PENDING_COOKIE)); - // Sign-in checks identity only — the membership route is the ISSUE - // round-trip's, and a sign-in that consulted it would be re-inventing the - // session-carried eligibility ADR 0010 §8 forbids. - assert_eq!(mock.member_calls(), 0); + // ⚠️ **Sign-in now consults the membership route too (Adam, 2026-08-26).** + // This assertion read `0` until then, with the reasoning that the check + // belonged to the issue round-trip alone. What it must NOT become is a + // session that carries the verdict — ADR 0010 §8 — so the issue and rework + // paths still re-prove membership per action, and the cookie below still + // says nothing about eligibility. Asserted `1` rather than `>= 1`: one + // round-trip answers the membership question once. + assert_eq!(mock.member_calls(), 1); + assert_eq!( + mock.member_guild().as_deref(), + Some(GUILD_ID), + "the guild asked about is the configured one, not one from the request" + ); // The exchange really happened, with the client secret in the BODY and the // PKCE verifier that matches the challenge sent at login. @@ -542,6 +582,416 @@ async fn a_complete_round_trip_signs_the_visitor_in() { assert_ne!(verifier, started.challenge); } +// --------------------------------------------------------------------------- +// The sign-in membership gate (Adam, 2026-08-26) +// --------------------------------------------------------------------------- + +// --------------------------------------------------------------------------- +// The first key, on the first sign-in (Adam, 2026-08-26) +// --------------------------------------------------------------------------- + +/// A router with sign-in, eligibility AND the control plane wired — the shape +/// of a real deployment, and the one the tests below need. `build_app` leaves +/// the control plane out on purpose (see its comment), which is also the one +/// case sign-in lands plain regardless of key: with nothing to issue against, +/// issuing is not attempted. +fn app_with_keys(discord: &MockDiscord, gateway: &MockGateway) -> Router { + app_with_keys_and(discord, gateway, eligibility_settings()) +} + +fn app_with_keys_and( + discord: &MockDiscord, + gateway: &MockGateway, + eligibility: prices_api::portal::eligibility::EligibilitySettings, +) -> Router { + let config = AppConfig { + ch_enabled: false, + base_url: None, + api_keys: vec![], + portal_enabled: true, + portal_oauth: Some(oauth_secret()), + portal_endpoints: Endpoints { + api_base: discord.base.clone(), + ..Endpoints::default() + }, + portal_keys: Some(Gateway::against(&gateway.base, PLAN_ID.to_string())), + portal_eligibility: Some(eligibility), + portal_rate_limit: None, + }; + app(&config, AppState::without_ch()) +} + +fn key_name() -> String { + format!("discord-{USER_ID}-key") +} + +/// A snowflake for an account created `secs` ago — the too-young test's +/// input. Same arithmetic as `portal_issue.rs`. +fn snowflake_created_secs_ago(secs: u64) -> String { + const DISCORD_EPOCH_MS: u64 = 1_420_070_400_000; + let now_ms = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap() + .as_millis() as u64; + ((now_ms - DISCORD_EPOCH_MS - secs * 1_000) << 22).to_string() +} + +/// Task 0193's first acceptance criterion, on the wire: a first sign-in ends +/// with a session AND a key, and lands on the state the first-login card +/// reads. One create, and the key carries the visitor's name. +#[tokio::test] +async fn a_first_sign_in_issues_a_key_and_lands_on_the_welcome() { + let discord = MockDiscord::start(GRANTED_SCOPE, None).await; + let gateway = MockGateway::start().await; + + let reply = sign_in_against(&app_with_keys(&discord, &gateway)).await; + + assert_eq!(reply.status, StatusCode::SEE_OTHER); + assert_eq!(reply.location(), "/api-tokens/?issue=ok"); + assert!(reply.cookie(cookies::SESSION_COOKIE).is_some()); + assert_eq!(gateway.with(|s| s.create_calls), 1); + assert_eq!(gateway.with(|s| s.named(&key_name()).len()), 1); +} + +/// The other half of the criterion: "returning shows the same key". An +/// existing key is adopted, not re-minted — and the landing is the PLAIN +/// dashboard, because `?issue=ok` says "Just issued" and that would be a lie +/// about a key that has existed for months. +#[tokio::test] +async fn a_returning_sign_in_adopts_the_key_and_lands_plain() { + let discord = MockDiscord::start(GRANTED_SCOPE, None).await; + let gateway = MockGateway::start().await; + gateway.with(|s| s.seed(&key_name(), 1_700_000_000)); + + let reply = sign_in_against(&app_with_keys(&discord, &gateway)).await; + + assert_eq!(reply.location(), "/api-tokens/"); + assert!(reply.cookie(cookies::SESSION_COOKIE).is_some()); + assert_eq!( + gateway.with(|s| s.create_calls), + 0, + "adopting must not mint" + ); + assert_eq!(gateway.with(|s| s.named(&key_name()).len()), 1); +} + +/// The refusals leave any EXISTING session alone — deliberately, and this +/// pins it against a plausible "fix". +/// +/// ADR 0010's table grants the dashboard and the reveal to the session alone, +/// "works forever": an account that held a key and later left the guild keeps +/// reading it. So a refused sign-in must not clear the cookie — the refusal is +/// about the round-trip that just ran, not about access already granted. What +/// the refusal owes the visitor is to be VISIBLE, and that is the page's job: +/// `?signin=not_member` rides the redirect and the dashboard renders it. +#[tokio::test] +async fn a_refused_sign_in_leaves_an_existing_session_alone() { + let mock = MockDiscord::start_with( + GRANTED_SCOPE, + None, + MemberReply::NotFound { code: 10_007 }, + USER_ID, + ) + .await; + + let reply = sign_in_against(&app_against(&mock)).await; + + assert_eq!(reply.location(), "/api-tokens/?signin=not_member"); + // No session is issued … + assert!(reply.cookie(cookies::SESSION_COOKIE).is_none()); + // … and none is cleared either: the only `Set-Cookie` is the pending + // cookie's own clear, which every callback drops. + let set_cookies = reply.set_cookies(); + let clearing_session = set_cookies + .iter() + .any(|c| c.starts_with(&format!("{}=;", cookies::SESSION_COOKIE))); + assert!( + !clearing_session, + "a refused sign-in must not sign out a visitor who was already in: {set_cookies:?}" + ); +} + +/// ⚠️ **The age parameter does not gate the session.** Sign-in proves +/// membership only, so a min-account-age parameter that cannot be read must +/// cost the visitor the key half — not the sign-in. It used to refuse every +/// visitor as `unknown`, returning members included, over a value this path +/// never consults; a mis-seeded parameter locked the whole portal. +#[tokio::test] +async fn an_unreadable_age_parameter_signs_the_visitor_in_without_a_key() { + use prices_api::portal::eligibility::{EligibilitySettings, ParamSource}; + let discord = MockDiscord::start(GRANTED_SCOPE, None).await; + let gateway = MockGateway::start().await; + let settings = EligibilitySettings { + guild_id: ParamSource::Direct(GUILD_ID.to_string()), + min_account_age: ParamSource::Direct("five".to_string()), + }; + + let reply = sign_in_against(&app_with_keys_and(&discord, &gateway, settings)).await; + + assert_eq!(reply.location(), "/api-tokens/"); + assert!(reply.cookie(cookies::SESSION_COOKIE).is_some()); + assert_eq!(discord.member_calls(), 1, "membership is still proved"); + assert_eq!( + gateway.with(|s| s.create_calls), + 0, + "no threshold, no verdict on age, no key" + ); +} + +/// And the guild id still does: with no guild to ask, nothing was decided +/// about the visitor, and the refusal is `unknown` — regardless of the age +/// parameter being fine. +#[tokio::test] +async fn an_unreadable_guild_parameter_still_refuses_sign_in() { + use prices_api::portal::eligibility::{EligibilitySettings, ParamSource}; + let discord = MockDiscord::start(GRANTED_SCOPE, None).await; + let gateway = MockGateway::start().await; + let settings = EligibilitySettings { + guild_id: ParamSource::Direct(String::new()), + min_account_age: ParamSource::Direct("5".to_string()), + }; + + let reply = sign_in_against(&app_with_keys_and(&discord, &gateway, settings)).await; + + assert_eq!(reply.location(), "/api-tokens/?signin=unknown"); + assert!(reply.cookie(cookies::SESSION_COOKIE).is_none()); + assert_eq!(discord.member_calls(), 0); +} + +/// A control-plane fault during the sign-in's key half lands PLAIN, with the +/// session — not on `?issue=failed`. The sign-in asked for no key, and the +/// banner would sit next to whatever `GET /key` reveals: for a returning +/// member, a working key that the banner says our service failed to produce. +/// The explicit `action=issue` press keeps `?issue=failed` (see +/// `portal_issue.rs`); it is the one answering a request. +#[tokio::test] +async fn a_control_plane_failure_at_sign_in_lands_plain_with_a_session() { + let discord = MockDiscord::start(GRANTED_SCOPE, None).await; + let gateway = MockGateway::start().await; + gateway.with(|s| s.fail_list = true); + + let reply = sign_in_against(&app_with_keys(&discord, &gateway)).await; + + assert_eq!(reply.location(), "/api-tokens/"); + assert!(reply.cookie(cookies::SESSION_COOKIE).is_some()); + assert_eq!(gateway.with(|s| s.create_calls), 0); +} + +/// Age is checked at sign-in ONLY for issuing, never for the session: a +/// brand-new account is signed in and told to wait, and — the part that +/// matters — the reconciler is never reached, because `issue_for` adopts +/// without an age check and would mint for exactly this account. +#[tokio::test] +async fn a_too_young_account_signs_in_but_gets_no_key() { + let discord = MockDiscord::start_with( + GRANTED_SCOPE, + None, + MemberReply::Member { + pending: Some(false), + }, + &snowflake_created_secs_ago(30), + ) + .await; + let gateway = MockGateway::start().await; + + let reply = sign_in_against(&app_with_keys(&discord, &gateway)).await; + + assert!( + reply + .location() + .starts_with("/api-tokens/?issue=too_young&wait_secs="), + "{}", + reply.location() + ); + assert!( + reply.cookie(cookies::SESSION_COOKIE).is_some(), + "too young to hold a key is not too young to sign in" + ); + assert_eq!(gateway.with(|s| s.create_calls), 0); + assert_eq!( + gateway.with(|s| s.list_calls), + 0, + "the reconciler must not run" + ); +} + +/// An account that revoked its key this period signs in to the plain +/// dashboard — where `GET /key` renders the revoked card — rather than to +/// `?issue=capped`, which answers a request for a new key this visitor did +/// not make. Nothing is written. +#[tokio::test] +async fn a_revoked_account_signs_in_and_lands_plain() { + let discord = MockDiscord::start(GRANTED_SCOPE, None).await; + let gateway = MockGateway::start().await; + let now = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap() + .as_secs(); + gateway.with(|s| s.seed_revoked(&key_name(), now - 86_400, now - 60)); + + let reply = sign_in_against(&app_with_keys(&discord, &gateway)).await; + + assert_eq!(reply.location(), "/api-tokens/"); + assert!(reply.cookie(cookies::SESSION_COOKIE).is_some()); + assert_eq!(gateway.with(|s| s.create_calls), 0); +} + +/// The membership gate still comes first: a non-member gets neither a session +/// nor a key, and the control plane is never consulted. +#[tokio::test] +async fn a_non_member_gets_no_key_either() { + let discord = MockDiscord::start_with( + GRANTED_SCOPE, + None, + MemberReply::NotFound { code: 10_007 }, + USER_ID, + ) + .await; + let gateway = MockGateway::start().await; + + let reply = sign_in_against(&app_with_keys(&discord, &gateway)).await; + + assert_eq!(reply.location(), "/api-tokens/?signin=not_member"); + assert!(reply.cookie(cookies::SESSION_COOKIE).is_none()); + assert_eq!(gateway.with(|s| s.list_calls), 0); + assert_eq!(gateway.with(|s| s.create_calls), 0); +} + +/// Drive a full sign-in against `mock` and return the callback's reply. +async fn sign_in_against(router: &Router) -> Reply { + let started = start_login(router).await; + fetch( + router, + &format!("{CALLBACK_PATH}?code=an-auth-code&state={}", started.state), + &[(cookies::PENDING_COOKIE, &started.pending)], + ) + .await +} + +/// A non-member is refused AT SIGN-IN, and leaves with no session. +/// +/// The refusal has to be the absence of a cookie, not a dashboard that then +/// says no: a session is the only thing standing between a visitor and every +/// signed-in route, and issuing one to somebody we have just decided is not +/// entitled to a key would make the gate decorative. +#[tokio::test] +async fn a_non_member_cannot_sign_in() { + let mock = MockDiscord::start_with( + GRANTED_SCOPE, + None, + MemberReply::NotFound { code: 10_007 }, + USER_ID, + ) + .await; + let open = app_against(&mock); + + let reply = sign_in_against(&open).await; + + assert_eq!(reply.status, StatusCode::SEE_OTHER); + assert_eq!(reply.location(), "/api-tokens/?signin=not_member"); + assert!( + reply.cookie(cookies::SESSION_COOKIE).is_none(), + "a refused sign-in must not leave a session behind" + ); + // The pending cookie still goes, so the callback stays single-use even on + // a refusal — otherwise a refused visitor could replay the code. + assert!(reply.clears(cookies::PENDING_COOKIE)); + assert_eq!(mock.member_calls(), 1); +} + +/// `pending: true` — on the server but still inside its screening — is the +/// same refusal, and is task 0189's rule rather than a new one: `decide` and +/// `membership` share the single statement of it. +#[tokio::test] +async fn a_member_still_in_screening_cannot_sign_in() { + let mock = MockDiscord::start_with( + GRANTED_SCOPE, + None, + MemberReply::Member { + pending: Some(true), + }, + USER_ID, + ) + .await; + + let reply = sign_in_against(&app_against(&mock)).await; + + assert_eq!(reply.location(), "/api-tokens/?signin=not_member"); + assert!(reply.cookie(cookies::SESSION_COOKIE).is_none()); +} + +/// Every way of NOT KNOWING lands on `unknown`, never on `not_member`. +/// +/// This is the half that matters most: "not a member" is a statement about the +/// visitor which they cannot act on if we are wrong, and a throttle from +/// Discord is not evidence of anything about them. One refusal per shape, all +/// of them without accusation. +#[tokio::test] +async fn an_unanswerable_membership_question_refuses_without_accusing() { + for reply_shape in [ + // Discord throttled or fell over. + MemberReply::Status(StatusCode::TOO_MANY_REQUESTS), + MemberReply::Status(StatusCode::INTERNAL_SERVER_ERROR), + // A `200` we cannot read, and one whose `pending` is absent — 0180 + // item 2's arm, which is deliberately NOT read as membership. + MemberReply::Malformed, + MemberReply::Member { pending: None }, + // An unrecognised `404` code is a shape we do not know, not a verdict. + MemberReply::NotFound { code: 99_999 }, + ] { + let mock = MockDiscord::start_with(GRANTED_SCOPE, None, reply_shape, USER_ID).await; + + let reply = sign_in_against(&app_against(&mock)).await; + + assert_eq!(reply.location(), "/api-tokens/?signin=unknown"); + assert!(reply.cookie(cookies::SESSION_COOKIE).is_none()); + } +} + +/// A deployment with credentials but no eligibility parameters cannot ask the +/// question, so it refuses — as `not_open`, the closed-portal state. +/// +/// ⚠️ **It answered `?signin=unknown` until 2026-08-27.** That literal renders +/// 0189's "we could not check your membership — a problem talking to Discord" +/// card, and on an unwired build there is no Discord problem: nothing was +/// asked. The visitor was told to retry something that could not succeed until +/// an operator wired the parameters, in the voice reserved for a transient +/// fault. `not_open` renders 0183's closed-portal card, which is both true and +/// already written. +/// +/// Fail-closed on purpose either way: `login` already refuses `action=issue` +/// on an unwired build, so signing someone in here would seat them on a +/// dashboard whose only action is guaranteed to refuse them. +#[tokio::test] +async fn a_deployment_with_no_eligibility_settings_refuses_sign_in() { + let mock = MockDiscord::start(GRANTED_SCOPE, None).await; + let config = AppConfig { + ch_enabled: false, + base_url: None, + api_keys: vec![], + portal_enabled: true, + portal_oauth: Some(oauth_secret()), + portal_endpoints: Endpoints { + api_base: mock.base.clone(), + ..Endpoints::default() + }, + portal_keys: None, + portal_eligibility: None, + portal_rate_limit: None, + }; + let unwired = app(&config, AppState::without_ch()); + + let reply = sign_in_against(&unwired).await; + + assert_eq!(reply.location(), "/api-tokens/?signin=not_open"); + assert!(reply.cookie(cookies::SESSION_COOKIE).is_none()); + assert_eq!( + mock.member_calls(), + 0, + "with no guild id there is nothing to ask" + ); +} + /// The criterion "the page shows their Discord username and ID", from the /// backend's side: after the round-trip, `/auth/me` reports both. #[tokio::test] diff --git a/packages/prices-api/tests/portal_keys.rs b/packages/prices-api/tests/portal_keys.rs index 4262145c..9e53cfc5 100644 --- a/packages/prices-api/tests/portal_keys.rs +++ b/packages/prices-api/tests/portal_keys.rs @@ -151,6 +151,41 @@ async fn an_existing_key_is_revealed_with_its_value() { assert_eq!(body["value"], mock.with(|s| s.keys[0].value.clone())); } +/// The two instants the dashboard's metadata row states (task 0193). +/// +/// They come off the listing this route already makes — no extra call — and +/// they are RFC 3339 so the page renders them in UTC without parsing an epoch. +/// `1_000` seconds after the epoch is 1970-01-01T00:16:40Z, which is what the +/// literal below is: the point is the SHAPE and the wiring, not the date. +#[tokio::test] +async fn the_reveal_carries_the_keys_created_and_updated_instants() { + let mock = MockGateway::start().await; + let name = format!("discord-{USER_ID}-key"); + mock.with(|s| s.seed(&name, 1_000)); + + let body = reveal(&mock, USER_ID).await.json(); + assert_eq!(body["created_at"], "1970-01-01T00:16:40Z"); + assert_eq!(body["last_updated_at"], "1970-01-01T00:16:40Z"); +} + +/// An undated record answers `null`, not an invented epoch. +/// +/// The same rule the revocation instant follows (task 0191 finding #30): a +/// missing `lastUpdatedDate` rendered as "1 January 1970" is worse than a +/// field the page simply does not show. `undate` produces the shape AWS does +/// not send, which is exactly why it is worth pinning. +#[tokio::test] +async fn an_undated_key_reveals_a_null_instant_rather_than_the_epoch() { + let mock = MockGateway::start().await; + let name = format!("discord-{USER_ID}-key"); + let id = mock.with(|s| s.seed(&name, 1_000)); + mock.with(|s| s.undate(&id)); + + let body = reveal(&mock, USER_ID).await.json(); + assert!(body["last_updated_at"].is_null(), "{body}"); + assert_eq!(body["created_at"], "1970-01-01T00:16:40Z"); +} + /// **The acceptance criterion "issue is unreachable with a session cookie /// alone", verified by calling it directly with nothing else.** Both verbs, /// empty store — the state 0187's handler would have created in — and the diff --git a/scripts/measure-pending-absent.sh b/scripts/measure-pending-absent.sh new file mode 100755 index 00000000..46fb46f3 --- /dev/null +++ b/scripts/measure-pending-absent.sh @@ -0,0 +1,181 @@ +#!/usr/bin/env bash +# Measure whether Discord's REST member object carries `pending` — task 0180 +# item 2, 0189's risk R1, and the blocking question on PR #249. +# +# WHY THIS EXISTS. `portal/eligibility.rs` treats an absent `pending` as +# "could not verify" and refuses. That is the safe direction, but it is safe +# in a way that fails for EVERY visitor if Discord never sends the field — +# and to a visitor that looks exactly like a Discord outage. Since 2026-08-26 +# the arm also gates SIGN-IN, not just key issuance, so an absent field would +# make the portal unusable rather than merely un-issuing. +# +# WHAT IT DOES. Restarts the local `serve` against the REAL Stellar guild, +# captures its log, waits for you to complete one sign-in in the browser, then +# reads the verdict out of the log. It changes no AWS resource and deploys +# nothing. It does NOT touch the Developer Portal — see PREREQUISITES. +# +# PREREQUISITES, both yours and neither checkable from here: +# +# 1. The Discord application (client_id in .portal-oauth.json) must request +# the scope pair `identify guilds.members.read`. The code requests it; +# the REGISTRATION must allow it. If it does not, Discord refuses at the +# authorize step and this script reports `invalid_scope` rather than a +# measurement. +# 2. You must sign in with a Discord account that IS a member of the guild +# being measured. Measuring with an account that is not a member answers +# "not a member", not the question. +# 3. Know which way Membership Screening is set on that guild before you +# run — it is what the result means (see GUILD below). +# +# ⚠️ ONE REAL PRODUCTION KEY. Sign-in issues the first key, and +# PORTAL_FREE_PLAN_ID points at the real `pricing-api-free` plan, so a +# successful run creates `discord--key` in account 750702271865. +# The script prints the exact delete command at the end. It does not delete +# anything itself. +set -uo pipefail +cd "$(dirname "$0")/.." + +# WHICH GUILD, AND WHY IT DECIDES WHAT YOU LEARN. +# +# Discord populates `pending` only for guilds with Membership Screening +# enabled, so the guild is a variable of the experiment, not a detail. +# +# Default — the scratch guild (`1536303837785362432`). You own it, which +# makes it the BETTER instrument: toggle Membership Screening in Server +# Settings → Members and run this twice, and the two runs measure both arms +# of `eligibility.rs`'s `match m.pending` — more than one observation +# against a server whose settings we cannot change would ever give. +# +# ALREADY RUN, 2026-08-27: this guild answered `pending: false` (present). +# Recorded in task 0189's Step 0 table, item 2. Whether that was with +# screening on or off is UNCONFIRMED, and it is the whole difference +# between "item 2 answered" and "items 2 and 4 answered" — check the +# setting before running the other arm. +# +# GUILD=897514728459468821 — the real Stellar Developers guild, which is +# what production will gate on (task 0179 step 4). Needs an account that is +# a member of it. Run this once the scratch runs have told you what each +# arm looks like. +# +# What production ultimately turns on is whether the Stellar guild has +# screening enabled — visible in its `features` (MEMBER_VERIFICATION_GATE_ +# ENABLED) without any OAuth round-trip at all. +GUILD="${GUILD:-1536303837785362432}" +SECRET_FILE="${SECRET_FILE:-.portal-oauth.json}" +PORT="${PORT:-8080}" +LOG="${LOG:-/tmp/portal-pending-absent-$(date +%Y%m%dT%H%M%S).log}" +TIMEOUT_SECS="${TIMEOUT_SECS:-300}" + +say() { printf '\n\033[1m%s\033[0m\n' "$*"; } + +[ -f "$SECRET_FILE" ] || { echo "no $SECRET_FILE — see packages/prices-api/README.md §2"; exit 1; } +python3 - "$SECRET_FILE" <<'PY' || exit 1 +import json,sys +d=json.load(open(sys.argv[1])) +missing=[k for k in ("client_id","client_secret","redirect_uri","session_signing_key") + if not d.get(k) or "REPLACE" in str(d[k])] +if missing: sys.exit(f"{sys.argv[1]}: unset or placeholder: {', '.join(missing)}") +if not d["redirect_uri"].startswith("http://localhost:4200/"): + sys.exit(f"redirect_uri is {d['redirect_uri']} — this script drives the :4200 dev server") +print(f"secret file ok (client_id {d['client_id']}, redirect {d['redirect_uri']})") +PY + +say "1/4 stopping any running serve" +OLD=$(pgrep -f 'target/debug/serve' || true) +[ -n "$OLD" ] && { kill $OLD; sleep 1; echo "stopped pid(s): $OLD"; } || echo "none running" + +say "2/4 starting serve against guild $GUILD" +echo "log: $LOG" +PORTAL_ENABLED=true \ +PORTAL_OAUTH_SECRET_FILE="$SECRET_FILE" \ +PORTAL_GUILD_ID="$GUILD" \ +PORTAL_MIN_ACCOUNT_AGE_MINUTES="${PORTAL_MIN_ACCOUNT_AGE_MINUTES:-5}" \ +PORTAL_FREE_PLAN_ID="${PORTAL_FREE_PLAN_ID:-71t9im}" \ +PORT="$PORT" RUST_LOG="${RUST_LOG:-info}" \ + cargo run -q -p prices-api --features local-server --bin serve >"$LOG" 2>&1 & +SERVE=$! +for _ in $(seq 1 60); do + curl -sf -m 2 "http://localhost:$PORT/api-tokens/api/config" >/dev/null && break + kill -0 $SERVE 2>/dev/null || { echo "serve died at start:"; tail -20 "$LOG"; exit 1; } + sleep 1 +done +curl -sf -m 2 "http://localhost:$PORT/api-tokens/api/config" >/dev/null || { + echo "serve never answered /config:"; tail -20 "$LOG"; kill $SERVE; exit 1; } +echo "serve up on :$PORT (pid $SERVE)" + +say "3/4 now sign in, once, in the browser" +cat < Membership::Unknown\`)," + echo " in its own commit. Do NOT open the portal (0194) before it lands." + grep -n 'pending_absent' "$LOG" | head -3 + ;; + scope) + echo "⚠️ NOT A MEASUREMENT — Discord refused at the authorize step (invalid_scope)." + echo " The registration does not allow \`guilds.members.read\`. Add it in the" + echo " Developer Portal (runbook §1 step 3) and run this again." + ;; + guild) + echo "⚠️ NOT A MEASUREMENT — Discord answered Unknown Guild (10004) for $GUILD." + echo " Wrong snowflake, or the app cannot see that guild." + ;; + discord) + echo "⚠️ NOT A MEASUREMENT — the membership call failed (rate limit or 5xx)." + grep -n 'membership could not be verified' "$LOG" | head -3 + echo " Try again in a few minutes." + ;; + notmember) + echo "⚠️ NOT A MEASUREMENT — that account is not a member of guild $GUILD." + echo " Join it, or sign in with an account that is." + ;; + *) + echo "⏳ nothing conclusive within ${TIMEOUT_SECS}s. Read the log yourself:" + echo " $LOG" + ;; +esac + +say "clean-up" +echo "serve is still running as pid $SERVE (log: $LOG). Stop it with: kill $SERVE" +cat <<'EOF' + +If a key was issued, it is REAL. List and delete: + + AWS_REGION=eu-central-1 aws apigateway get-api-keys \ + --query 'items[?tags.ManagedBy==`prices-portal`].[id,name,createdDate]' --output table + AWS_REGION=eu-central-1 aws apigateway delete-api-key --api-key + +EOF diff --git a/web/portal/src/api/portal.ts b/web/portal/src/api/portal.ts index 4d1dde4e..989213d8 100644 --- a/web/portal/src/api/portal.ts +++ b/web/portal/src/api/portal.ts @@ -413,12 +413,24 @@ export async function signOut(): Promise { method: 'POST', signal: AbortSignal.timeout(PROBE_TIMEOUT_MS), }); - } catch { + } catch (error) { + // The same two branches as `getJson`, for the same reason: a stalled + // sign-out used to report as "could not be reached", which points at the + // visitor's network when the cause was a gateway that accepted and then + // said nothing. + if (isTimeout(error)) { + throw new PortalApiError( + `${url} did not answer within ${PROBE_TIMEOUT_MS / 1000}s`, + ); + } throw new PortalApiError(`${url} could not be reached`); } if (!response.ok) { + // The backend's own sentence where it wrote one — this was the one call + // that threw the envelope away, and a failed sign-out is the call whose + // message the visitor most needs to read (see `useSession`). throw new PortalApiError( - `${url} answered ${response.status}`, + failureMessage(url, response.status, await readEnvelope(response)), response.status, ); } @@ -439,6 +451,23 @@ export interface PortalKey { name: string; /** The credential itself — what goes in `X-API-Key`. */ value: string; + /** + * `createdDate`, RFC 3339 — when API Gateway minted the key. + * + * Optional in the type because the backend types it that way (the SDK makes + * the field optional and `KeyRecord` follows), and because a deployment + * running the previous build answers without it. The page renders the field + * only where it has a value. + */ + created_at?: string | null; + /** + * `lastUpdatedDate`, RFC 3339 — the last change to the key RECORD. + * + * NOT "last rotated": this build has no rotation, and any edit bumps it + * (measured under task 0191's audit — a no-op patch and a console + * `description` edit both do). The dashboard labels it "Last updated". + */ + last_updated_at?: string | null; } /** diff --git a/web/portal/src/app/app.spec.tsx b/web/portal/src/app/app.spec.tsx index 13f174e7..8f06deed 100644 --- a/web/portal/src/app/app.spec.tsx +++ b/web/portal/src/app/app.spec.tsx @@ -1,4 +1,10 @@ -import { fireEvent, render, screen, waitFor } from '@testing-library/react'; +import { + fireEvent, + render, + screen, + waitFor, + within, +} from '@testing-library/react'; import { MemoryRouter, useLocation } from 'react-router-dom'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; @@ -11,8 +17,11 @@ import App from './app'; * the banner stayed — `MemoryRouter` has no `window.location` to inspect. */ let lastSearch = ''; +let lastPath = ''; function LocationSpy() { - lastSearch = useLocation().search; + const location = useLocation(); + lastSearch = location.search; + lastPath = location.pathname; return null; } @@ -143,6 +152,322 @@ const renderApp = () => , ); +/** + * The login card alone — the one place on the page a sign-in control can be. + * + * Scoping exists because of task 0193: this app used to BE the panel, so + * "offers nothing to click" could be asserted against the whole document. + * Since the landing page arrived, the document also carries a navbar, a hero, + * a footer and a "Back to landing" link whose targets are `#features`, + * `#use-cases`, `#top` and the OpenAPI document — navigation that is correct + * whether the portal is open or shut, and that a closed-portal assertion has + * no business counting. + * + * The assertion itself is NOT relaxed. Inside this panel the rule is still + * zero controls while the flag is off, and the two controls that could promise + * a key from outside it — the hero's and the navbar's "Get API Key" — are + * rendered only on a confirmed-open probe and are covered by their own test + * below. Widening the scope back out would fail on the footer, not on a + * regression. + */ +function portalPanel() { + return within(screen.getByTestId('login-card')); +} + +/** + * The three routes, and the two redirects between them (task 0193). + * + * These exist because the routes are a CONTRACT with the backend, not a + * cosmetic split. `portal/auth/mod.rs` sends every OAuth outcome to + * `/api-tokens/` and says so deliberately — "when the portal grows a second + * page, the page it lands on decides where to go next; this handler still will + * not". `/` is that page. If these forwards break, a completed sign-in ends on + * the marketing page and the visitor never reaches the key they just proved + * they are entitled to, with nothing on screen to say why. + * + * They also pin the guard the brief asks for in the other direction: a visitor + * with no session who arrives at `/dashboard` goes to `/api-tokens/`. + */ +describe('routes', () => { + beforeEach(() => { + vi.restoreAllMocks(); + lastPath = ''; + lastSearch = ''; + }); + afterEach(() => { + vi.unstubAllGlobals(); + }); + + const renderAt = (entry: string) => + render( + + + + , + ); + + it('sends a signed-in visitor from the landing to the dashboard', async () => { + openAndSignedIn(); + renderAt('/'); + + // The OAuth callback lands here; the dashboard is where it must end up. + await waitFor(() => expect(lastPath).toBe('/dashboard')); + expect( + await screen.findByRole('heading', { name: /^api key$/i }), + ).toBeTruthy(); + }); + + it('carries the issue outcome through to the dashboard', async () => { + openAndSignedIn(); + renderAt('/?issue=not_member'); + + // The forward must not eat the query. `?issue=…` is a one-shot landing + // state owned by task 0189 and the dashboard is what renders it — dropping + // it here would swallow an eligibility refusal the visitor is owed. + await waitFor(() => expect(lastPath).toBe('/dashboard')); + expect(await screen.findByTestId('issue-not-member')).toBeTruthy(); + }); + + it('sends a returning visitor with a signin outcome to the login screen', async () => { + openAndSignedOut(); + renderAt('/?signin=cancelled'); + + await waitFor(() => expect(lastPath).toBe('/login')); + expect(await screen.findByTestId('signin-failed')).toBeTruthy(); + }); + + /** + * ⚠️ **"Back to landing" stays ABOVE the card, top-left** (Adam, + * 2026-08-26, Figma `824:140`). It briefly moved into the foot of the card + * and moved straight back; pinned here because the trap is the same in both + * directions — rendered in both places it is two links with one name and one + * target, and rendered unconditionally it appears on the landing page's own + * status panel, where it points at the page you are already reading. + */ + it('puts exactly one back link above the login card, and none on the landing', async () => { + openAndSignedOut(); + renderAt('/login'); + + await screen.findByRole('link', { name: /sign in with discord/i }); + const back = screen.getAllByRole('link', { name: /back to landing/i }); + expect(back).toHaveLength(1); + // Above the card, not inside it. + expect(screen.getByTestId('login-card').contains(back[0])).toBe(false); + }); + + it('offers no back link on the landing page itself', async () => { + openAndSignedOut(); + renderAt('/'); + + await screen.findAllByRole('link', { name: /get api key/i }); + expect(screen.queryByRole('link', { name: /back to landing/i })).toBeNull(); + }); + + it('leaves a signed-out visitor on the landing page', async () => { + openAndSignedOut(); + renderAt('/'); + + expect( + (await screen.findAllByRole('link', { name: /get api key/i })).length, + ).toBeGreaterThan(0); + expect(lastPath).toBe('/'); + // And the login card is not on the landing page — it is a route of its own. + expect(screen.queryByTestId('login-card')).toBeNull(); + }); + + it('shows the login screen on its own, with none of the landing page', async () => { + openAndSignedOut(); + renderAt('/login'); + + expect(await screen.findByTestId('login-card')).toBeTruthy(); + expect(lastPath).toBe('/login'); + // The marketing sections belong to `/`. "Only this one view" is the brief. + expect(document.getElementById('features')).toBeNull(); + expect(document.getElementById('use-cases')).toBeNull(); + }); + + it('sends a signed-in visitor away from the login screen', async () => { + openAndSignedIn(); + renderAt('/login'); + + await waitFor(() => expect(lastPath).toBe('/dashboard')); + }); + + it('sends a visitor with no session away from the dashboard', async () => { + openAndSignedOut(); + renderAt('/dashboard'); + + await waitFor(() => expect(lastPath).toBe('/')); + // By heading, not by text: the landing page's Self-Service section says + // "…your API key is ready immediately", which a loose text match hits. + expect(screen.queryByRole('heading', { name: /^api key$/i })).toBeNull(); + }); + + it('waits for the session before deciding about the dashboard', async () => { + // The redirect must not fire while `/auth/me` is still in flight: that is + // exactly the moment an arrival from the OAuth callback passes through, + // and bouncing it would break the one journey these routes exist for. + let answer: (value: unknown) => void = () => undefined; + const pending = new Promise((resolve) => { + answer = resolve; + }); + vi.stubGlobal( + 'fetch', + vi.fn(async (url: string) => { + if (url === CONFIG_URL) + return { + ok: true, + status: 200, + json: async () => ({ enabled: true }), + }; + if (url === ME_URL) { + await pending; + return { + ok: true, + status: 200, + json: async () => ({ + authenticated: true, + user_id: '1', + username: 'adam', + }), + }; + } + return { + ok: false, + status: 404, + json: async () => ({ code: 'no_key' }), + }; + }), + ); + + renderAt('/dashboard'); + expect( + await screen.findByText(/checking whether you are signed in/i), + ).toBeTruthy(); + expect(lastPath).toBe('/dashboard'); + + answer(undefined); + expect( + await screen.findByRole('heading', { name: /^api key$/i }), + ).toBeTruthy(); + expect(lastPath).toBe('/dashboard'); + }); + + it('sends an unknown path back to the landing page', async () => { + openAndSignedOut(); + renderAt('/nonsense'); + + await waitFor(() => expect(lastPath).toBe('/')); + }); + + it('serves the quick start to a signed-in visitor under the dashboard bar', async () => { + openAndSignedIn(); + renderAt('/quick-start'); + + expect( + await screen.findByRole('heading', { + name: /get your first response in under 5 minutes/i, + }), + ).toBeTruthy(); + // The signed-in bar — once `/auth/me` has answered — with THIS page + // underlined rather than the dashboard. + const bar = within( + await screen.findByRole('navigation', { name: 'Dashboard' }), + ); + expect( + bar + .getByRole('link', { name: 'Quick start' }) + .getAttribute('aria-current'), + ).toBe('page'); + expect( + bar.getByRole('link', { name: 'Dashboard' }).getAttribute('aria-current'), + ).toBeNull(); + expect(bar.getByText('adam')).toBeTruthy(); + expect(lastPath).toBe('/quick-start'); + }); + + it('points the footer dashboard link at the prefix the app is served from', async () => { + openAndSignedIn(); + // WITH the basename, unlike every other test here: the bug this pins only + // exists under one — a bare `href="/dashboard"` and a router link resolve + // to the same string when the app is mounted at the root, and to + // different ones on the deployment, where the bundle lives under + // `/api-tokens/`. + render( + + + , + ); + + // Wait for the session to settle: the footer offers the dashboard only + // to somebody who has one, and the signed-in bar is the proof it has. + await screen.findByRole('navigation', { name: 'Dashboard' }); + const footer = within(screen.getByRole('navigation', { name: 'Footer' })); + expect( + footer.getByRole('link', { name: 'Dashboard' }).getAttribute('href'), + ).toBe(`${ROUTER_BASENAME}/dashboard`); + }); + + it('serves the quick start to a signed-out visitor too, under the landing bar', async () => { + openAndSignedOut(); + renderAt('/quick-start'); + + expect( + await screen.findByRole('heading', { + name: /get your first response in under 5 minutes/i, + }), + ).toBeTruthy(); + expect(screen.getByRole('navigation', { name: 'Primary' })).toBeTruthy(); + expect(screen.queryByRole('navigation', { name: 'Dashboard' })).toBeNull(); + }); + + it('marks the section the quick start opens on in its rail', async () => { + openAndSignedOut(); + renderAt('/quick-start'); + await screen.findByRole('heading', { name: /^prerequisites$/i }); + + const rail = within( + screen.getByRole('navigation', { name: 'On this page' }), + ); + const entries = rail.getAllByRole('link'); + expect(entries).toHaveLength(10); + // Unscrolled, the rail points at the first section rather than at + // nothing — the frame underlines `Prerequisites` for the same reason. + expect(entries[0].getAttribute('aria-current')).toBe('location'); + expect(entries.filter((e) => e.getAttribute('aria-current'))).toHaveLength( + 1, + ); + }); + + it('switches the first-request snippet by language and copies it', async () => { + openAndSignedIn(); + const writeText = vi.fn().mockResolvedValue(undefined); + vi.stubGlobal('navigator', { ...navigator, clipboard: { writeText } }); + renderAt('/quick-start'); + await screen.findByRole('heading', { name: /first request/i }); + + const [tabs] = screen.getAllByRole('tablist', { name: 'Language' }); + fireEvent.click(within(tabs).getByRole('tab', { name: 'Python' })); + expect( + within(tabs) + .getByRole('tab', { name: 'Python' }) + .getAttribute('aria-selected'), + ).toBe('true'); + expect(screen.getByRole('heading', { name: 'python' })).toBeTruthy(); + + fireEvent.click( + screen.getByRole('button', { name: 'Copy python example' }), + ); + await waitFor(() => expect(writeText).toHaveBeenCalledTimes(1)); + expect(writeText.mock.calls[0][0]).toContain('requests.get('); + expect(writeText.mock.calls[0][0]).toContain('x-api-key'); + expect(await screen.findByText('Copied')).toBeTruthy(); + }); +}); + describe('portal home', () => { beforeEach(() => { vi.restoreAllMocks(); @@ -159,8 +484,10 @@ describe('portal home', () => { // The acceptance criterion is "no sign-in button", not "no button that // happens to say sign in" — assert on the role, so any control added here // fails this rather than sneaking past a string match. - expect(screen.queryAllByRole('button')).toHaveLength(0); - expect(screen.queryAllByRole('link')).toHaveLength(0); + expect(portalPanel().queryAllByRole('button')).toHaveLength(0); + expect(portalPanel().queryAllByRole('link')).toHaveLength(0); + // And nothing above the fold offers the key either (task 0193). + expect(screen.queryByRole('link', { name: /get api key/i })).toBeNull(); }); it('renders the open state when the flag is on', async () => { @@ -169,9 +496,17 @@ describe('portal home', () => { // Task 0185's "sign-in arrives with the next slice" placeholder is gone — // this slice IS that sign-in, so the open state is now the real control. + // + // Since task 0193 gave the portal routes, the control the LANDING offers is + // the one that leads to sign-in rather than the Discord button itself; the + // button is asserted on `/login`, where it now lives. What this test still + // pins is the gate: flag on, a way in appears and the closed sentence does + // not. + // `findAll`: the landing offers the same control in the navbar, the hero + // and the footer, and a `findBy` throws on more than one match. expect( - await screen.findByRole('link', { name: /sign in with discord/i }), - ).toBeTruthy(); + (await screen.findAllByRole('link', { name: /get api key/i })).length, + ).toBeGreaterThan(0); expect(screen.queryByText(/not yet available/i)).toBeNull(); }); @@ -262,17 +597,26 @@ describe('portal home', () => { }); it('says nothing about the outcome while the probe is still in flight', async () => { - stubFetch({}); + stubFetch({ json: async () => ({ enabled: true }) }); renderApp(); - // The evidence paragraph must not claim failure before there is an answer. + // Task 0185's `Reached /api-tokens/api/config successfully — same-origin…` + // line is gone (task 0193: the page must not read as a debug harness), so + // what this test guards has moved to the control that ACTS on the answer. + // The property is the same one and it is the one that matters: the page + // must not commit to an outcome it does not have yet. expect( screen.getByText(/Checking whether the portal is open/i), ).toBeTruthy(); - expect(screen.queryByText(/unsuccessfully/i)).toBeNull(); + // No offer of a key while nobody knows whether the portal is open… + expect(screen.queryByRole('link', { name: /get api key/i })).toBeNull(); + // …and no claim that it is shut, either. + expect(screen.queryByText(/not yet available/i)).toBeNull(); - // …and it must still report the outcome once one arrives. - expect(await screen.findByText(/successfully/i)).toBeTruthy(); + // …and the answer, once it arrives, is acted on. + expect( + (await screen.findAllByRole('link', { name: /get api key/i })).length, + ).toBeGreaterThan(0); }); // `renderApp` above mounts at `/` with no basename, so it cannot notice @@ -327,7 +671,7 @@ describe('sign in with Discord', () => { */ it('offers sign-in as a same-origin link, not a fetch', async () => { openAndSignedOut(); - renderAt('/'); + renderAt('/login'); const link = await screen.findByRole('link', { name: /sign in with discord/i, @@ -353,27 +697,75 @@ describe('sign in with Discord', () => { ); }); - /** The acceptance criterion: the page shows their Discord username and ID. */ - it('shows the username and the Discord ID once signed in', async () => { + /** + * The acceptance criterion: the page shows their Discord username and ID. + * + * ⚠️ **Task 0186's criterion has eroded twice and this test records both.** + * + * 1. The numeric ID left the screen on 2026-08-25, when Adam had the account + * column cut down to the handle to match the frame. It survived as the + * column's `title` — one hover away — and the assertion followed it there. + * 2. The column itself leaves the EMPTY-KEY card on 2026-08-26, with the + * `Dashboard - no key` frame. So for a signed-in visitor with no key, the + * id is nowhere at all and the username is only in the navbar. + * + * The test is split rather than weakened: the keyless half asserts what that + * visitor actually gets, and the half with a key still pins the id. If 0186's + * criterion is to be met again it needs somewhere on the empty dashboard to + * live, and that is a change to 0186. + */ + it('names the signed-in account, even with no key to show', async () => { openAndSignedIn(); renderAt('/'); - expect(await screen.findByText(/adam/)).toBeTruthy(); - expect(await screen.findByText('308994132968210433')).toBeTruthy(); + // The navbar. `findAll` because the dashboard can name the account more + // than once, and `findBy` throws on more than one match. + expect((await screen.findAllByText(/adam/)).length).toBeGreaterThan(0); // And the sign-in control is gone. expect( screen.queryByRole('link', { name: /sign in with discord/i }), ).toBeNull(); }); - it('renders signed-out as plain text with the button still there', async () => { - openAndSignedOut(); + it('carries the Discord ID on the account column, once there is a key', async () => { + stubRoutes({ + [CONFIG_URL]: openConfig, + [ME_URL]: () => ({ + json: async () => ({ + authenticated: true, + user_id: '308994132968210433', + username: 'adam', + }), + }), + [KEY_URL]: () => ({ + json: async () => ({ + key_id: 'identity-key', + name: 'discord-identity-key', + value: 'IDENTITYKEY00000000000000000000000000000', + }), + }), + [USAGE_URL]: usageNoKey, + }); renderAt('/'); - expect(await screen.findByText(/you are not signed in/i)).toBeTruthy(); - // "Plain text, not a screen" — the heading and the same-origin evidence - // paragraph are still on the page. + const account = await screen.findByTitle('308994132968210433'); + expect(account.textContent).toContain('adam'); + }); + + /** + * ⚠️ "You are not signed in." was removed on 2026-08-26 (Adam, Figma + * `824:140`). The state is now identified by what it OFFERS rather than by + * a sentence about what the visitor is not, so that is what this asserts. + */ + it('renders the signed-out card with its heading and the button', async () => { + openAndSignedOut(); + renderAt('/login'); + + expect( + await screen.findByRole('link', { name: /sign in with discord/i }), + ).toBeTruthy(); expect(screen.getByRole('heading', { level: 1 })).toBeTruthy(); + expect(screen.queryByText(/you are not signed in/i)).toBeNull(); }); /** @@ -383,24 +775,68 @@ describe('sign in with Discord', () => { * nothing. The invite is the registered vanity code; the account-age line * names no number, because the threshold is operator configuration the * backend reports when it matters. + * + * ⚠️ **Asserted on the LANDING page since 2026-08-26.** The sign-in card + * used to carry the same sentence and Adam removed it to match Figma + * `824:140`; the copy survives in the FAQ, which is what the criterion + * actually names ("the landing page states both prerequisites"). Moved + * rather than deleted, because a criterion with no test is a criterion + * nobody will notice breaking. */ - it('states both prerequisites before the visitor authenticates', async () => { + it('states both prerequisites on the landing page, before the visitor authenticates', async () => { openAndSignedOut(); renderAt('/'); - await screen.findByRole('link', { name: /sign in with discord/i }); + // With the portal open the landing page has no sign-in card — the + // Discord button is `/login`'s, and the line above it is asserted in the + // next test. What the landing page states, it states in the FAQ, and + // the row that carries the two rules is OPEN by default: a collapsed + // accordion states nothing, and the criterion says "states" (PR #249 + // review; 2026-08-26 had it collapsed, behind a click). + const row = await screen.findByRole('button', { + name: /how do i get an api key/i, + }); + expect(row.getAttribute('aria-expanded')).toBe('true'); + + const invite = screen.getByRole('link', { + name: /stellar developers discord/i, + }); + expect(invite.getAttribute('href')).toBe('https://discord.gg/stellardev'); + + // The age line names no number — a hard-coded threshold would drift the + // moment the SSM parameter changes. Scoped to the answer itself: the + // landing page says "under 5 minutes" about the quick start three + // sections away, and a whole-document match would fail on that. + const answer = screen.getByText(/not brand new/i); + expect(answer.textContent).not.toMatch(/\d+\s*(minute|hour|day)/i); + }); + + /** + * `/login` is the card alone — somebody who follows a link straight there + * must not learn the rules only by being refused. + */ + it('states both prerequisites on /login too', async () => { + openAndSignedOut(); + renderAt('/login'); + + // The button first: `portalPanel()` binds to the card node that exists + // when it is called, and before the session answers that is the + // "checking" card, which unmounts. + const button = await screen.findByRole('link', { + name: /sign in with discord/i, + }); + const card = portalPanel(); + const stated = card.getByTestId('prerequisites'); + // Above the control, not below it: the criterion says "before". expect( - screen.getByRole('link', { name: /stellar developers discord/i }), + stated.compareDocumentPosition(button) & Node.DOCUMENT_POSITION_FOLLOWING, ).toBeTruthy(); expect( - screen + card .getByRole('link', { name: /stellar developers discord/i }) .getAttribute('href'), ).toBe('https://discord.gg/stellardev'); - expect(screen.getByText(/not brand new/i)).toBeTruthy(); - // Not a hard-coded threshold — that would drift the moment the SSM - // parameter changes. - expect(document.body.textContent).not.toMatch(/5 minutes/i); + expect(stated.textContent).not.toMatch(/\d+\s*(minute|hour|day)/i); }); /** @@ -418,57 +854,200 @@ describe('sign in with Discord', () => { , ); - expect(await screen.findByText(/sign-in cancelled/i)).toBeTruthy(); + expect(await screen.findByTestId('signin-failed')).toBeTruthy(); await waitFor(() => expect(lastSearch).not.toContain('signin')); - // The banner survives the cleanup — it belongs to this landing. - expect(screen.getByText(/sign-in cancelled/i)).toBeTruthy(); + // The screen survives the cleanup — it belongs to this landing. + expect(screen.getByTestId('signin-failed')).toBeTruthy(); + }); + + /** + * ⚠️ **Both `?signin=cancelled` and `?signin=failed` now render one screen** + * (Adam, 2026-08-26, Figma `825:1284`), where task 0186 rendered two + * banners. + * + * What the merge kept is what 0186 was actually defending: the copy never + * accuses the visitor of anything, and it names the cancellation case out + * loud rather than implying a fault. What it gave up is the separate + * "Sign-in cancelled." wording. The backend split is untouched — the two + * literals and their logs are asserted in `tests/portal_auth.rs` — so this + * is one line to un-merge if it reads wrong in front of people. + */ + it.each(['cancelled', 'failed'])( + 'renders the OAuth error screen for signin=%s', + async (outcome) => { + openAndSignedOut(); + renderAt(`/?signin=${outcome}`); + + // The frame's header, verbatim — shared with the waiting card, which + // this screen is the error variant of. + expect( + await screen.findByRole('heading', { name: /redirecting to discord/i }), + ).toBeTruthy(); + const message = screen.getByTestId('signin-failed'); + // The frame's sentence, verbatim — the two causes it names, and never + // an accusation about who the visitor is. + expect(message.textContent).toMatch( + /discord returned an error during authorization/i, + ); + expect(message.textContent).toMatch(/denied access/i); + expect(message.textContent).toMatch(/timed out/i); + // The retry is the card's one filled control. + expect( + screen + .getByRole('link', { name: /try again with discord/i }) + .getAttribute('href'), + ).toBe('/api-tokens/api/auth/login'); + // And it never reads as a refusal about who the visitor is. + expect(screen.queryByTestId('signin-not-member')).toBeNull(); + }, + ); + + /** + * ⚠️ On the OAuth error screen the back link moves INSIDE the card, under + * "Try again with Discord" (Adam, 2026-08-26, Figma `825:1284`). + * + * The property that matters is not where it is but that there is still + * exactly ONE: the section above draws its own on every other login state, + * and both rendering it is the bug `useOwnBackLink` exists to prevent. + */ + it('moves the back link inside the error card, and leaves only one on the page', async () => { + openAndSignedOut(); + renderAt('/?signin=failed'); + + await screen.findByTestId('signin-failed'); + const back = screen.getAllByRole('link', { name: /back to landing/i }); + expect(back).toHaveLength(1); + expect(screen.getByTestId('login-card').contains(back[0])).toBe(true); }); /** - * The visitor pressed Cancel at Discord's consent screen; the callback - * redirected to `/api-tokens/?signin=cancelled`. Plain text, and the button - * stays where it was — this is not an error state. + * The card is REPLACED, so the ordinary sign-in control is gone — a visitor + * looking at an unfinished round-trip is offered the retry, once, rather + * than two buttons that do the same thing. */ - it('says sign-in was cancelled, in plain text, and still offers the button', async () => { + it('replaces the sign-in card rather than annotating it', async () => { openAndSignedOut(); - renderAt('/?signin=cancelled'); + renderAt('/?signin=failed'); - expect(await screen.findByText(/sign-in cancelled/i)).toBeTruthy(); + await screen.findByTestId('signin-failed'); expect( - screen.getByRole('link', { name: /sign in with discord/i }), - ).toBeTruthy(); - // Not dressed up as a failure. - expect(screen.queryByText(/could not/i)).toBeNull(); + screen.queryByRole('link', { name: /sign in with discord/i }), + ).toBeNull(); + expect( + screen.queryByRole('heading', { name: /get your api key/i }), + ).toBeNull(); }); /** - * `?signin=failed` is the other landing state: any OAuth error that is not - * the visitor declining. Rendering it as "cancelled" is what let a drifted - * scope registration look like every visitor changing their mind. + * ⚠️ Sign-in refuses a non-member as of 2026-08-26 (Adam), and the refusal + * is a SCREEN (Figma `825:1485`), not a banner over a sign-in button. + * + * The property under test is that the card is REPLACED: a visitor refused + * for membership must not still be offered the control that will hand them + * back to the same refusal, and must not be shown the prerequisites list + * they have just been refused under. */ - it('distinguishes a failed sign-in from a cancelled one', async () => { + it('replaces the login card with the access-not-available screen for a non-member', async () => { openAndSignedOut(); - renderAt('/?signin=failed'); + renderAt('/?signin=not_member'); - expect(await screen.findByText(/could not be completed/i)).toBeTruthy(); - expect(screen.queryByText(/sign-in cancelled/i)).toBeNull(); - // Still a plain-text state with the button, not an error screen. expect( - screen.getByRole('link', { name: /sign in with discord/i }), + await screen.findByRole('heading', { name: /access not available/i }), + ).toBeTruthy(); + const message = screen.getByTestId('signin-not-member'); + expect(message.textContent).toMatch(/members of the stellar discord/i); + // Kept from 0189: the one line that explains a visitor who HAS joined and + // is still refused (`pending: true`). + expect(message.textContent).toMatch(/screening/i); + + // The single filled action is the invite, and it is the real one. + const join = screen.getByRole('link', { name: /join stellar discord/i }); + expect(join.getAttribute('href')).toBe('https://discord.gg/stellardev'); + + // The sign-in control is GONE — replaced by the quiet second action. + expect( + screen.queryByRole('link', { name: /sign in with discord/i }), + ).toBeNull(); + expect( + screen.getByRole('button', { name: /try different account/i }), ).toBeTruthy(); + + // Never dressed as our fault, and never as the visitor's cancellation. + expect(screen.queryByText(/sign-in cancelled/i)).toBeNull(); + expect(screen.queryByTestId('signin-unknown')).toBeNull(); }); - it('does not claim a failure that did not happen', async () => { + /** + * ⚠️ "Try different account" opens a dialog rather than re-running the + * round-trip (Adam, 2026-08-26). The old link re-authorised the SAME account + * — OAuth has no `select_account` prompt, so no request this client can make + * will offer a chooser — and redrew the same refusal, which read as a dead + * button. + * + * What is pinned here is that the dialog says the switch happens ON DISCORD + * and offers a real way to get there, because that is the only step that can + * change the answer. + */ + it('offers a way to switch Discord account instead of re-running the same sign-in', async () => { openAndSignedOut(); - renderAt('/?signin=cancelled'); - expect(await screen.findByText(/sign-in cancelled/i)).toBeTruthy(); - expect(screen.queryByText(/could not be completed/i)).toBeNull(); + renderAt('/?signin=not_member'); + + fireEvent.click(await screen.findByTestId('switch-account-open')); + + const dialog = await screen.findByRole('dialog', { + name: /use a different discord account/i, + }); + expect(dialog.getAttribute('aria-modal')).toBe('true'); + // It says WHY, rather than pretending the portal can switch accounts. + expect(screen.getByTestId('switch-account-explainer').textContent).toMatch( + /switch account on discord/i, + ); + + // The way out is a real link to Discord, opened so this card survives. + const switchLink = screen.getByRole('link', { + name: /switch account on discord/i, + }); + expect(switchLink.getAttribute('href')).toBe('https://discord.com/login'); + expect(switchLink.getAttribute('target')).toBe('_blank'); + expect(switchLink.getAttribute('rel')).toContain('noopener'); + + // And the retry is still there, second, worded as a retry. + expect( + screen + .getByRole('link', { name: /try signing in again/i }) + .getAttribute('href'), + ).toBe('/api-tokens/api/auth/login'); + }); + + /** + * The 0193 criterion, on the sign-in path this time: "could not verify" and + * "not a member" must be tellable apart. One accuses the visitor of + * something and names a remedy; the other accuses nobody and says retry. + */ + it('keeps a failed membership check distinct from a refused one', async () => { + openAndSignedOut(); + renderAt('/?signin=unknown'); + + const banner = await screen.findByTestId('signin-unknown'); + expect(banner.textContent).toMatch( + /not a statement about your membership/i, + ); + expect(banner.textContent).toMatch(/again/i); + // The accusation is absent, and so is its remedy: nothing here tells the + // visitor to go and join anything, because we do not know that they have + // not. + expect(screen.queryByTestId('signin-not-member')).toBeNull(); + expect(banner.querySelector('a')).toBeNull(); + // The button is still there — this state is retryable. + expect( + screen.getByRole('link', { name: /sign in with discord/i }), + ).toBeTruthy(); }); it('does not claim a cancellation that did not happen', async () => { openAndSignedOut(); - renderAt('/'); - await screen.findByText(/you are not signed in/i); + renderAt('/login'); + await screen.findByRole('link', { name: /sign in with discord/i }); expect(screen.queryByText(/cancelled/i)).toBeNull(); }); @@ -498,7 +1077,16 @@ describe('sign in with Discord', () => { // assertions rather than warned about after them. fireEvent.click(button); - expect(await screen.findByText(/you are not signed in/i)).toBeTruthy(); + // Signing out empties the session, and `/dashboard` sends a visitor with + // no session to `/api-tokens/` — so the observable result is the landing + // page with its way back in, not the "you are not signed in" line, which + // belongs to `/login`. The property this test exists for is unchanged and + // asserted below: the request was a POST, and the session was re-read + // rather than assumed. + expect( + (await screen.findAllByRole('link', { name: /get api key/i })).length, + ).toBeGreaterThan(0); + expect(screen.queryByRole('button', { name: /sign out/i })).toBeNull(); const logout = fetchMock.mock.calls.find(([url]) => url === LOGOUT_URL); expect(logout).toBeTruthy(); // A GET sign-out is triggerable by any third-party page; the backend only @@ -506,6 +1094,55 @@ describe('sign in with Discord', () => { expect((logout?.[1] as RequestInit | undefined)?.method).toBe('POST'); }); + /** + * A failed sign-out is not a sign-out: the `HttpOnly` cookie was never + * cleared. This used to render as a successful one — the landing page — + * and a reload put the key back on screen, which on a shared machine is + * the one outcome the button exists to prevent (PR #249 review). + */ + it('says so when signing out fails, and stays on the dashboard', async () => { + stubRoutes({ + [CONFIG_URL]: openConfig, + [ME_URL]: () => ({ + json: async () => ({ + authenticated: true, + user_id: '1', + username: 'adam', + }), + }), + [LOGOUT_URL]: () => ({ + ok: false, + status: 503, + json: async () => ({ + code: 'unavailable', + message: 'the session store is unreachable', + }), + }), + [KEY_URL]: keyNoKey, + [USAGE_URL]: usageNoKey, + }); + + render( + + + + , + ); + fireEvent.click(await screen.findByRole('button', { name: /sign out/i })); + + const failed = await screen.findByTestId('sign-out-failed'); + // The backend's own sentence, not `answered 503`. + expect(failed.textContent).toMatch(/the session store is unreachable/); + expect(failed.textContent).toMatch(/still signed in/i); + expect( + screen.getByRole('button', { name: /try signing out again/i }), + ).toBeTruthy(); + expect(lastPath).toBe('/dashboard'); + expect( + screen.queryAllByRole('link', { name: /get api key/i }), + ).toHaveLength(0); + }); + /** * A backend that cannot answer "who am I" must not leave the page on a * spinner — the same rule the config probe follows, applied to the second @@ -516,7 +1153,7 @@ describe('sign in with Discord', () => { [CONFIG_URL]: openConfig, [ME_URL]: () => ({ ok: false, status: 502 }), }); - renderAt('/'); + renderAt('/login'); expect( await screen.findByText(/could not check your sign-in status/i), @@ -537,13 +1174,40 @@ describe('sign in with Discord', () => { [CONFIG_URL]: openConfig, [ME_URL]: () => ({ ok: false, status: 502 }), }); - renderAt('/'); + renderAt('/login'); await screen.findByText(/could not check your sign-in status/i); const link = screen.getByRole('link', { name: /sign in with discord/i }); expect(link.getAttribute('href')).toBe('/api-tokens/api/auth/login'); }); + /** + * "Not signed in" and "could not find out" are different answers, and + * only the first earns the redirect. A `502` from `/auth/me` on the + * dashboard used to bounce the visitor to the marketing page — with its + * "Get API Key" buttons and no mention of the failure (PR #249 review). + */ + it('renders the failure on the dashboard instead of bouncing to the landing page', async () => { + stubRoutes({ + [CONFIG_URL]: openConfig, + [ME_URL]: () => ({ ok: false, status: 502 }), + }); + // With the spy: this test's property is the path the visitor is left on. + render( + + + + , + ); + + expect(await screen.findByTestId('session-check-failed')).toBeTruthy(); + expect(screen.getByRole('button', { name: /try again/i })).toBeTruthy(); + expect(lastPath).toBe('/dashboard'); + expect( + screen.queryAllByRole('link', { name: /get api key/i }), + ).toHaveLength(0); + }); + /** * The closed portal must ask nothing about sessions. The gate answers * `/auth/me` with an empty 404 while the flag is off, so a call here would @@ -562,6 +1226,378 @@ describe('sign in with Discord', () => { }); }); +/** + * The sign-in popup (task 0193). + * + * The control stays an `` with a real `href` and the popup is layered on + * top of it, so these tests pin BOTH halves: that a click opens the + * round-trip in a second window, and that a browser which refuses to open one + * falls through to the navigation that has always worked. + */ +describe('the sign-in popup', () => { + beforeEach(() => { + vi.restoreAllMocks(); + }); + afterEach(() => { + vi.unstubAllGlobals(); + }); + + const renderAt = (entry: string) => + render( + + + , + ); + + /** A stand-in for the second window, and the `window.open` that returns it. */ + const stubPopup = (opened: Partial | null) => { + // The parameters are declared even though the stub ignores them: the + // assertion below reads `calls[0][0]`, and without them the recorded call + // tuple has length 0 and will not typecheck. + const open = vi.fn( + (_url: string, _name?: string, _features?: string) => + opened as Window | null, + ); + vi.stubGlobal('open', open); + return open; + }; + + const clickSignIn = async () => { + const link = await screen.findByRole('link', { + name: /sign in with discord/i, + }); + fireEvent.click(link); + return link; + }; + + it('opens the round-trip in a second window and waits', async () => { + openAndSignedOut(); + const open = stubPopup({ closed: false, focus: () => undefined }); + renderAt('/login'); + + await clickSignIn(); + + // The URL is the backend's own login route, relative — the popup goes + // through `/auth/login` so the PKCE `pending` cookie is set same-origin, + // exactly as the full-page flow does. + expect(open.mock.calls[0][0]).toBe('/api-tokens/api/auth/login'); + expect(await screen.findByText(/redirecting to discord/i)).toBeTruthy(); + expect(screen.getByText(/waiting for discord/i)).toBeTruthy(); + // And the escape hatch the copy promises actually points somewhere. + expect( + screen.getByRole('link', { name: /click here/i }).getAttribute('href'), + ).toBe('/api-tokens/api/auth/login'); + }); + + it('falls back to navigating this tab when the popup is blocked', async () => { + openAndSignedOut(); + stubPopup(null); + renderAt('/login'); + + const link = await clickSignIn(); + + // No waiting screen: the click was not intercepted, so the browser is + // following the `href` and this document is already on its way out. A + // "Waiting for Discord…" spinner here would be a lie about a window that + // never opened. + expect(screen.queryByText(/waiting for discord/i)).toBeNull(); + expect(link.getAttribute('href')).toBe('/api-tokens/api/auth/login'); + // Still the signed-out card, identified by the control it offers. + expect( + screen.getByRole('heading', { name: /get your api key/i }), + ).toBeTruthy(); + }); + + it('leaves a modified click alone so it can open a tab', async () => { + openAndSignedOut(); + const open = stubPopup({ closed: false, focus: () => undefined }); + renderAt('/login'); + + const link = await screen.findByRole('link', { + name: /sign in with discord/i, + }); + fireEvent.click(link, { metaKey: true }); + + expect(open).not.toHaveBeenCalled(); + expect(screen.queryByText(/waiting for discord/i)).toBeNull(); + }); + + it('reports the refusal the popup brings back, on the one screen that renders it', async () => { + openAndSignedOut(); + stubPopup({ closed: false, focus: () => undefined }); + renderAt('/login'); + + await clickSignIn(); + await screen.findByText(/waiting for discord/i); + + fireEvent( + window, + new MessageEvent('message', { + origin: window.location.origin, + data: { source: 'stellar-portal-oauth', search: '?signin=cancelled' }, + }), + ); + + // The same screen the full-page flow shows, from the same one place in + // the component — not a second wording for the popup. + expect(await screen.findByTestId('signin-failed')).toBeTruthy(); + expect(screen.queryByText(/waiting for discord/i)).toBeNull(); + }); + + /** + * ⚠️ **Shutting the window is reported (Adam, 2026-08-26)**, and reported + * only after the server has been asked. + * + * The old behaviour went quietly back to offering the button, which is what + * a broken button looks like. The new one must still not cry failure over a + * sign-in that WORKED — the popup posts and closes in the same breath — so + * the outcome is decided by `/auth/me`, not by the close. + */ + it('reports a shut window as a failure only when no session was created', async () => { + openAndSignedOut(); + const popup = { closed: false, focus: () => undefined }; + stubPopup(popup); + renderAt('/login'); + + await clickSignIn(); + await screen.findByText(/waiting for discord/i); + + popup.closed = true; + + // Slower than a plain `findBy`: the close holds its verdict for the + // message grace before asking `/auth/me`. + expect( + await screen.findByTestId('signin-failed', {}, { timeout: 5000 }), + ).toBeTruthy(); + }); + + /** + * ⚠️ **The message must still win a race it starts late.** The callback's + * 303 sets the cookie and lands the popup on `?issue=too_young`, but the + * popup has to download the bundle before it posts — and the 1.5 s poll can + * see `authenticated: true` first. Ending the wait on the poll tore the + * listener down, and the query the popup was about to forward was lost: the + * too-young visitor landed on a plain dashboard with no explanation. + */ + it('lets a late popup message overtake a poll that already saw the session', async () => { + let meCalls = 0; + stubRoutes({ + [CONFIG_URL]: openConfig, + [ME_URL]: () => { + meCalls += 1; + return { + json: async () => + // The first read is the app's own on mount; from the first poll + // onwards the cookie is already there. + meCalls > 1 + ? { authenticated: true, user_id: '1', username: 'adam' } + : { authenticated: false }, + }; + }, + [KEY_URL]: keyNoKey, + [USAGE_URL]: usageNoKey, + }); + stubPopup({ closed: false, focus: () => undefined }); + render( + + + + , + ); + + await clickSignIn(); + await screen.findByText(/waiting for discord/i); + // The poll has run and seen the session … + await waitFor(() => expect(meCalls).toBeGreaterThan(1), { + timeout: 3000, + }); + // … and the popup's message arrives only now. + fireEvent( + window, + new MessageEvent('message', { + origin: window.location.origin, + data: { + source: 'stellar-portal-oauth', + search: '?issue=too_young&wait_secs=600', + }, + }), + ); + + expect( + await screen.findByTestId('issue-too-young', {}, { timeout: 5000 }), + ).toBeTruthy(); + expect(lastPath).toBe('/dashboard'); + }); + + /** + * ⚠️ **Same race, the other slow signal.** `bridgeOAuthPopup` posts and + * closes in the same breath, so the 500 ms `closed` watch can observe the + * window gone while the message is still queued. Deciding on the close + * alone turned "not a member" into the generic failure card — exactly the + * distinction task 0193 makes an acceptance criterion. + */ + it('lets a popup message overtake the closed-window watch', async () => { + openAndSignedOut(); + const popup = { closed: false, focus: () => undefined }; + stubPopup(popup); + renderAt('/login'); + + await clickSignIn(); + await screen.findByText(/waiting for discord/i); + + popup.closed = true; + // Let the watch observe the close before the message lands. + await new Promise((resolve) => setTimeout(resolve, 700)); + fireEvent( + window, + new MessageEvent('message', { + origin: window.location.origin, + data: { source: 'stellar-portal-oauth', search: '?signin=not_member' }, + }), + ); + + expect( + await screen.findByTestId('signin-not-member', {}, { timeout: 5000 }), + ).toBeTruthy(); + expect(screen.queryByTestId('signin-failed')).toBeNull(); + }); + + /** + * ⚠️ The first sign-in issues a key (Adam, 2026-08-26), and the callback + * lands the POPUP on `?issue=ok` — a query this tab never navigated to. + * What is pinned: the opener forwards it, so the visitor lands on the + * dashboard's first-login card rather than on the plain one. + */ + it('lands the key the sign-in issued on the first-login card', async () => { + let authenticated = false; + stubRoutes({ + [CONFIG_URL]: openConfig, + [ME_URL]: () => ({ + json: async () => + authenticated + ? { + authenticated: true, + user_id: '308994132968210433', + username: 'adam', + } + : { authenticated: false }, + }), + [KEY_URL]: () => ({ + json: async () => ({ + key_id: 'abc123', + name: 'discord-308994132968210433-key', + value: 'sk_test_0123456789abcdef', + }), + }), + [USAGE_URL]: usageNoKey, + }); + stubPopup({ closed: false, focus: () => undefined }); + render( + + + + , + ); + + await clickSignIn(); + await screen.findByText(/waiting for discord/i); + + authenticated = true; + fireEvent( + window, + new MessageEvent('message', { + origin: window.location.origin, + data: { source: 'stellar-portal-oauth', search: '?issue=ok' }, + }), + ); + + expect(await screen.findByTestId('issue-ok')).toBeTruthy(); + // The card retitles once `/key` has answered — `justIssued` needs both + // the landing AND a key on screen — so this is awaited, not read. + expect( + await screen.findByRole('heading', { name: /your api key is ready/i }), + ).toBeTruthy(); + expect(await screen.findByTestId('api-key')).toBeTruthy(); + expect(lastPath).toBe('/dashboard'); + }); + + /** + * A deployment that cannot ask the eligibility question lands its own + * literal, and gets 0183's closed-portal card — not 0189's "problem talking + * to Discord", which claims a transient fault for a permanent state and + * offers a retry that cannot succeed until an operator acts. + */ + it('renders the closed-portal card for a deployment that cannot check eligibility', async () => { + openAndSignedOut(); + renderAt('/login?signin=not_open'); + + expect(await screen.findByTestId('portal-closed')).toBeTruthy(); + expect(screen.queryByTestId('signin-unknown')).toBeNull(); + // Nothing to press: the round-trip cannot succeed on this build. + expect( + screen.queryByRole('link', { name: /sign in with discord/i }), + ).toBeNull(); + }); + + it('ignores a message from another origin', async () => { + openAndSignedOut(); + stubPopup({ closed: false, focus: () => undefined }); + renderAt('/login'); + + await clickSignIn(); + await screen.findByText(/waiting for discord/i); + + fireEvent( + window, + new MessageEvent('message', { + origin: 'https://not-us.example', + data: { source: 'stellar-portal-oauth', search: '?signin=failed' }, + }), + ); + + // Still waiting. A `message` event arrives from any window that cares to + // send one, and without the origin check a third-party page could end the + // wait and make this card claim an outcome that never happened. + expect(screen.getByText(/waiting for discord/i)).toBeTruthy(); + expect(screen.queryByText(/could not be completed/i)).toBeNull(); + }); + + it('goes to the dashboard when the popup completes the sign-in', async () => { + // The popup reports no refusal; the session it created is what the app + // then finds. This is the whole journey the routes exist for. + let authenticated = false; + stubRoutes({ + [CONFIG_URL]: openConfig, + [ME_URL]: () => ({ + json: async () => + authenticated + ? { authenticated: true, user_id: '1', username: 'adam' } + : { authenticated: false }, + }), + [KEY_URL]: keyNoKey, + [USAGE_URL]: usageNoKey, + }); + stubPopup({ closed: false, focus: () => undefined }); + renderAt('/login'); + + await clickSignIn(); + await screen.findByText(/waiting for discord/i); + + authenticated = true; + fireEvent( + window, + new MessageEvent('message', { + origin: window.location.origin, + data: { source: 'stellar-portal-oauth', search: '' }, + }), + ); + + expect( + await screen.findByRole('heading', { name: /^api key$/i }), + ).toBeTruthy(); + }); +}); + /** * The API key (task 0187; issuance re-shaped by task 0189). * @@ -572,6 +1608,167 @@ describe('sign in with Discord', () => { * top-level navigation through the eligibility round-trip, and the round-trip * outcomes land back here as `?issue=`. */ +/** + * `/login` before the portal is open — a URL a reviewer can land on directly, + * and two states that had no test. + */ +describe('the login route when there is nothing to sign in to', () => { + beforeEach(() => { + vi.restoreAllMocks(); + }); + afterEach(() => { + vi.unstubAllGlobals(); + }); + + it('renders the closed-portal card, with nothing to press', async () => { + stubRoutes({ + [CONFIG_URL]: () => ({ json: async () => ({ enabled: false }) }), + [ME_URL]: () => ({ json: async () => ({ authenticated: false }) }), + }); + render( + + + , + ); + + expect(await screen.findByTestId('portal-closed')).toBeTruthy(); + expect( + screen.queryByRole('link', { name: /sign in with discord/i }), + ).toBeNull(); + }); + + it('names the reason when the backend cannot be reached at all', async () => { + stubRoutes({ + [CONFIG_URL]: () => { + throw new Error('network down'); + }, + [ME_URL]: () => ({ json: async () => ({ authenticated: false }) }), + }); + render( + + + , + ); + + // The error skin and the reason — not the closed-portal card, which would + // claim a deliberate state for a broken one. + expect( + await screen.findByText(/could not reach the portal backend/i), + ).toBeTruthy(); + expect(screen.queryByTestId('portal-closed')).toBeNull(); + }); +}); + +/** + * Landing states that arrive on a visitor with no session. + */ +describe('a landing state with no session behind it', () => { + beforeEach(() => { + vi.restoreAllMocks(); + }); + afterEach(() => { + vi.unstubAllGlobals(); + }); + + /** + * ⚠️ `RootRoute` forwarded only `?signin=…` to `/login`. An issue + * round-trip whose session expired mid-flight landed with `?issue=…`, + * matched neither branch, and rendered the marketing page — the one journey + * where the visitor is owed an answer ending on the page that answers + * nothing. + */ + it('carries an issue outcome to the sign-in card rather than dropping it', async () => { + openAndSignedOut(); + render( + + + + , + ); + + // The sign-in card, not the landing page — and the query survives the hop, + // so signing back in lands it on the dashboard that reads it. + await screen.findByRole('link', { name: /sign in with discord/i }); + await waitFor(() => expect(lastPath).toBe('/login')); + expect(lastSearch).toContain('issue=capped'); + expect(lastSearch).toContain('next_eligible_at=2026-09-01'); + }); +}); + +/** + * The two navigation bars, on the pages that are not the landing page. + */ +describe('navigation off the landing page', () => { + beforeEach(() => { + vi.restoreAllMocks(); + }); + afterEach(() => { + vi.unstubAllGlobals(); + }); + + /** + * ⚠️ The landing navbar renders on `/quick-start` for a signed-out visitor, + * and its three links name landing-page sections (`#features`, + * `#get-started`, `#faq`). None of those ids exists there, so all three did + * nothing at all when clicked. Off the landing page they point back at it. + */ + it('points the quick start navbar back at the landing page sections', async () => { + openAndSignedOut(); + render( + + + , + ); + + const features = await screen.findByRole('link', { name: /^features$/i }); + expect(features.getAttribute('href')).toBe(`${ROUTER_BASENAME}/#features`); + expect( + screen.getByRole('link', { name: /^faq$/i }).getAttribute('href'), + ).toBe(`${ROUTER_BASENAME}/#faq`); + }); + + /** + * ⚠️ Every non-current link in the signed-in bar was `display: none` at + * `xs`, so on a phone the bar showed only where the visitor already was: no + * way back to the dashboard from the quick start, and "OpenAPI Docs" + * reachable from neither page. The 375px criterion is about being usable, + * not merely about fitting. + */ + it('keeps every signed-in destination reachable, at any width', async () => { + stubRoutes({ + [CONFIG_URL]: openConfig, + [ME_URL]: () => ({ + json: async () => ({ + authenticated: true, + user_id: '308994132968210433', + username: 'adam', + }), + }), + [KEY_URL]: keyNoKey, + [USAGE_URL]: usageNoKey, + }); + render( + + + , + ); + + // Scoped to the signed-in bar: the footer names some of the same + // destinations, and it is not what this is about. + const bar = within( + (await screen.findByRole('navigation', { + name: /dashboard/i, + })) as HTMLElement, + ); + for (const name of [/^dashboard$/i, /openapi docs/i, /quick start/i]) { + const link = bar.getByRole('link', { name }); + expect(getComputedStyle(link).display, String(name)).not.toBe('none'); + } + }); +}); + describe('the API key', () => { beforeEach(() => { vi.restoreAllMocks(); @@ -659,18 +1856,30 @@ describe('the API key', () => { /** * No key: the control is a **link into the issue round-trip**, not a button * with a fetch — the eligibility proof needs a fresh Discord token, which - * only a top-level navigation can carry — and the prerequisites are stated - * right where the decision is made. + * only a top-level navigation can carry. + * + * ⚠️ **This test used to also assert the prerequisites were stated on this + * card** (`/not brand new/i`), which was task 0189's decision: say them where + * the decision is made. The `Dashboard - no key` frame (Adam, 2026-08-26) + * gives the card a red strip and one button and nothing else, so they are no + * longer here — and the assertion is dropped rather than left failing. They + * are still stated in full on the landing page, BEFORE the visitor + * authenticates, which is where the epic's criterion actually places them and + * where authorising-for-nothing is the risk 0189 was guarding against. + * + * The rest of the test is unchanged and is the part that matters most: it is + * still a link, it still points at the round-trip, and the card still makes + * no request that could create anything. */ - it('offers get-my-api-key as a link into the issue round-trip, not a fetch', async () => { + it('offers the issue round-trip as a link, not a fetch', async () => { const fetchMock = signedInWithoutKey(); renderApp(); - const link = await screen.findByRole('link', { name: /get my api key/i }); + // The frame's label. The other states' retry links still read "Get my API + // key" — this is the empty card's control, not a rename across the app. + const link = await screen.findByRole('link', { name: /generate api key/i }); expect(link.getAttribute('href')).toBe(ISSUE_HREF); expect(link.getAttribute('href')?.startsWith('http')).toBe(false); - // The prerequisites, at the point of decision. - expect(screen.getAllByText(/not brand new/i).length).toBeGreaterThan(0); // And no request was made that could have created anything. expect( fetchMock.mock.calls.every( @@ -679,6 +1888,20 @@ describe('the API key', () => { ).toBe(true); }); + /** + * The frame's red strip, and the reason it says more than "you have no key": + * the diagnosis is what tells a signed-in visitor that pressing the button is + * worth doing rather than a repeat of something that already failed. + */ + it('names the likely cause on the empty card, not just the empty state', async () => { + signedInWithoutKey(); + renderApp(); + + const notice = await screen.findByTestId('no-key-notice'); + expect(notice.textContent).toMatch(/no api key found for your account/i); + expect(notice.textContent).toMatch(/issuance failed during sign-in/i); + }); + /** * Only the backend's own `no_key` envelope means "no key". The gate's empty * 404 (task 0183, reachable when the portal closes under an open tab) must @@ -696,7 +1919,41 @@ describe('the API key', () => { renderApp(); expect(await screen.findByText(/could not get your api key/i)).toBeTruthy(); - expect(screen.queryByRole('link', { name: /get my api key/i })).toBeNull(); + expect( + screen.queryByRole('link', { name: /generate api key/i }), + ).toBeNull(); + }); + + /** + * ⚠️ "Copied." used to be set once and never cleared, so a SECOND copy gave + * no sign it had worked. The confirmation is also announced — the button's + * own label does not change, so without `role="status"` the copy succeeds + * silently for anyone not looking at the text. + * + * Real timers, not fake ones: `findBy*` is timer-driven, and swapping the + * clock out from under it makes every query in this file hang. + */ + it('clears the copy confirmation so a second copy is visible too', async () => { + const writeText = vi.fn(() => Promise.resolve()); + vi.stubGlobal('navigator', { clipboard: { writeText } }); + signedInWithKey(); + renderApp(); + + await screen.findByTestId('api-key'); + const copy = screen.getByRole('button', { name: /copy key/i }); + + fireEvent.click(copy); + expect((await screen.findByRole('status')).textContent).toMatch(/copied/i); + + // It goes away on its own — the two-second window the quick start's copy + // button uses. + await waitFor(() => expect(screen.queryByRole('status')).toBeNull(), { + timeout: 4000, + }); + + // And comes back on the next press, which is the whole point. + fireEvent.click(copy); + expect((await screen.findByRole('status')).textContent).toMatch(/copied/i); }); /** @@ -718,15 +1975,21 @@ describe('the API key', () => { } }); - it('reveals and re-hides the value on the toggle', async () => { + /** + * ⚠️ "Reveal"/"Hide" became "Show key"/"Hide key" inside the ring on + * 2026-08-26 (Adam) — one control and one wording across both dashboards, + * where the ordinary card used to name the act differently from the + * first-login one and put it in a different row. + */ + it('shows and re-hides the value on the toggle', async () => { signedInWithKey(); renderApp(); await screen.findByTestId('api-key'); - fireEvent.click(screen.getByRole('button', { name: /^reveal$/i })); + fireEvent.click(screen.getByRole('button', { name: /^show key$/i })); expect(screen.getByTestId('api-key').textContent).toBe(KEY_VALUE); - fireEvent.click(screen.getByRole('button', { name: /^hide$/i })); + fireEvent.click(screen.getByRole('button', { name: /^hide key$/i })); expect(screen.getByTestId('api-key').textContent).not.toContain(KEY_VALUE); }); @@ -737,7 +2000,7 @@ describe('the API key', () => { renderApp(); await screen.findByTestId('api-key'); - fireEvent.click(screen.getByRole('button', { name: /^copy$/i })); + fireEvent.click(screen.getByRole('button', { name: /copy key/i })); // The masked display must not become what gets copied — the reason the // component keeps the value in state rather than reading it back out of the @@ -757,7 +2020,7 @@ describe('the API key', () => { renderApp(); await screen.findByTestId('api-key'); - fireEvent.click(screen.getByRole('button', { name: /^copy$/i })); + fireEvent.click(screen.getByRole('button', { name: /copy key/i })); expect(await screen.findByText(/copy it by hand/i)).toBeTruthy(); // And the key is still there to copy by hand. @@ -789,10 +2052,15 @@ describe('the API key', () => { /** The key belongs to the session, so signing out must take it off screen. */ it('is not rendered while signed out', async () => { openAndSignedOut(); - renderApp(); + // `/login`, not `/`: the key lives on the dashboard since task 0193 gave + // the portal routes, and a signed-out visitor never reaches that route — + // this asserts the key is absent from the screen they DO reach. + renderApp('/login'); await screen.findByRole('link', { name: /sign in with discord/i }); - expect(screen.queryByRole('link', { name: /get my api key/i })).toBeNull(); + expect( + screen.queryByRole('link', { name: /generate api key/i }), + ).toBeNull(); expect(screen.queryByTestId('api-key')).toBeNull(); }); @@ -805,21 +2073,182 @@ describe('the API key', () => { renderApp(); await screen.findByText(/not yet available/i); - expect(screen.queryAllByRole('button')).toHaveLength(0); - expect(screen.queryAllByRole('link')).toHaveLength(0); + expect(portalPanel().queryAllByRole('button')).toHaveLength(0); + expect(portalPanel().queryAllByRole('link')).toHaveLength(0); + expect(screen.queryByRole('link', { name: /get api key/i })).toBeNull(); + }); + + // ------------------------------------------------------------------------- + // The issue round-trip's landing states (task 0189) — the wording this task + // decides, and 0193 restyles without re-deciding. + // ------------------------------------------------------------------------- + + it('welcomes a completed issue and shows the key', async () => { + signedInWithKey(); + renderApp('/?issue=ok'); + + expect(await screen.findByTestId('issue-ok')).toBeTruthy(); + expect(await screen.findByTestId('api-key')).toBeTruthy(); + }); + + /** + * The first-login card (Figma `843:2356`). + * + * ⚠️ **This asserted the opposite until 2026-08-26**, when Adam pointed at + * the frame: the box holds a run of dots and a "Show key" control, so task + * 0187's mask has no exception. The card's own controls are still its own — + * "Copy key" and the quick-start link, not the ordinary card's "Reveal". + */ + it('masks the key on the first-login card, behind its own Show key control', async () => { + signedInWithKey(); + renderApp('/?issue=ok'); + + const field = await screen.findByTestId('api-key'); + expect(field.textContent).not.toContain(KEY_VALUE); + expect(field.textContent).toMatch(/^•+$/); + expect( + screen.getByRole('heading', { name: /your api key is ready/i }), + ).toBeTruthy(); + expect(screen.getByRole('button', { name: /copy key/i })).toBeTruthy(); + expect( + screen.getByRole('link', { name: /view quick start/i }), + ).toBeTruthy(); + + // The same control, in the same place, as the ordinary card's — see the + // toggle test above. + expect(screen.queryByRole('button', { name: /^reveal$/i })).toBeNull(); + fireEvent.click(screen.getByTestId('show-key')); + expect(screen.getByTestId('api-key').textContent).toBe(KEY_VALUE); + expect(screen.getByRole('button', { name: /hide key/i })).toBeTruthy(); + }); + + /** + * The mask survives a copy: pressing "Copy key" hands over the real value + * without putting it on screen, which is the whole point of masking a card + * whose purpose is to hand a credential over. + */ + it('copies the real value while the key is still masked', async () => { + const writeText = vi.fn(() => Promise.resolve()); + vi.stubGlobal('navigator', { clipboard: { writeText } }); + signedInWithKey(); + renderApp('/?issue=ok'); + + await screen.findByTestId('api-key'); + fireEvent.click(screen.getByRole('button', { name: /copy key/i })); + + expect(writeText).toHaveBeenCalledWith(KEY_VALUE); + expect(screen.getByTestId('api-key').textContent).toMatch(/^•+$/); + }); + + /** + * The metadata row's two instants come from `GET /key`, which task 0193 + * extended to carry `createdDate` and `lastUpdatedDate` — not from this + * machine's clock, and not invented where AWS omits them. + * + * The label is "Last updated": the frame's "Last rotated" was taken on + * 2026-08-25 and reversed on 2026-08-27 (PR #249 review), because the value + * is `lastUpdatedDate`, this build rotates nothing, and both ends of the + * contract say the dashboard labels it so. The reversal is recorded at the + * render site. + */ + it('dates the key from the control plane, in UTC', async () => { + signedInWithKey({ + key_id: 'abc123', + name: 'discord-308994132968210433-key', + value: KEY_VALUE, + created_at: '2026-04-13T09:30:00Z', + last_updated_at: '2026-04-30T22:45:00Z', + }); + renderApp('/'); + + expect(await screen.findByText('Issued')).toBeTruthy(); + expect(screen.getByText('13 April 2026')).toBeTruthy(); + expect(screen.getByText('Last updated')).toBeTruthy(); + // 22:45 UTC on the 30th stays the 30th — rendered in a zone behind UTC it + // would read as the 1st of May, which is a different quota period. + expect(screen.getByText('30 April 2026')).toBeTruthy(); + }); + + /** A build whose backend has no timestamps yet drops the fields. */ + it('omits the dates rather than inventing them when the API sends none', async () => { + signedInWithKey(); + renderApp('/'); + + await screen.findByTestId('api-key'); + expect(screen.queryByText('Issued')).toBeNull(); + expect(screen.queryByText('Last updated')).toBeNull(); + }); + + /** + * The frame's yellow strip. Its words were the frame's (Adam, 2026-08-25: + * "Key rotation is limited to once per calendar month") until 2026-08-27, + * when PR #249's review round returned it to task 0191's model — nothing + * rotates, nothing is issued now, once per quota period — like the two + * landing sentences before it. + * + * The date is what this pins: whichever wording the strip wears, the instant + * it names is the start of the next quota period, and it must be a real date + * rather than the words "next month". `/usage` is stubbed `no_key` here, so + * this exercises the computed fallback rather than `resets_at`. + */ + it('names the next rotation date in the notice strip', async () => { + signedInWithKey(); + renderApp('/'); + + const note = await screen.findByRole('note'); + expect(note.textContent).toMatch(/once per quota period/i); + expect(note.textContent).toMatch(/issues nothing now/i); + expect(note.textContent).not.toMatch(/rotat/i); + const nextMonth = new Date( + Date.UTC(new Date().getUTCFullYear(), new Date().getUTCMonth() + 1, 1), + ); + expect(note.textContent).toContain( + nextMonth.toLocaleDateString('en-GB', { + day: 'numeric', + month: 'long', + year: 'numeric', + timeZone: 'UTC', + }), + ); }); - // ------------------------------------------------------------------------- - // The issue round-trip's landing states (task 0189) — the wording this task - // decides, and 0193 restyles without re-deciding. - // ------------------------------------------------------------------------- + /** + * The mask is the default everywhere else, which is the half of the pair + * above that keeps 0187's rule true: no `?issue=ok`, no unmasking. + */ + it('masks the key on an ordinary visit and keeps the plain card title', async () => { + signedInWithKey(); + renderApp('/'); + + const field = await screen.findByTestId('api-key'); + expect(field.textContent).not.toBe(KEY_VALUE); + // Beside the value, not in the row of actions below it. + const show = screen.getByTestId('show-key'); + expect(show.textContent).toMatch(/^show key$/i); + expect(field.parentElement?.contains(show)).toBe(true); + expect(screen.getByRole('heading', { name: /^api key$/i })).toBeTruthy(); + expect( + screen.queryByRole('link', { name: /view quick start/i }), + ).toBeNull(); + }); - it('welcomes a completed issue and shows the key', async () => { + /** + * "Issued" is only ever rendered where the round-trip that just ended + * created the key, because `GET /key` carries no timestamp — and the rate + * limit comes from `/config`, which the stub answers with 1 req/s. + * + * The quota column is deliberately NOT asserted here: this stub's `/usage` + * says "no key yet", so the page has not been told a limit and the field is + * absent rather than invented. + */ + it('states when the key was issued and at what rate limit', async () => { signedInWithKey(); renderApp('/?issue=ok'); - expect(await screen.findByTestId('issue-ok')).toBeTruthy(); - expect(await screen.findByTestId('api-key')).toBeTruthy(); + expect(await screen.findByText('Issued')).toBeTruthy(); + expect(screen.getByText(/just now/i)).toBeTruthy(); + expect(screen.getByText('Rate limit')).toBeTruthy(); + expect(screen.queryByText('Monthly quota')).toBeNull(); }); /** @@ -896,10 +2325,63 @@ describe('the API key', () => { renderApp('/?issue=ok'); expect(await screen.findByTestId('issue-ok-settling')).toBeTruthy(); + // Nor the empty state's red pill over it: the key exists, the listing is + // merely behind, and "Not issued" above "your key was created" was the + // page contradicting itself (PR #249 review, both reviewers). + expect(screen.queryByText(/not issued/i)).toBeNull(); // Not the success line — the key is not on screen to be ready. expect(screen.queryByTestId('issue-ok')).toBeNull(); // And not the "you have no key" branch, whose control issues another one. - expect(screen.queryByRole('link', { name: /get my api key/i })).toBeNull(); + expect( + screen.queryByRole('link', { name: /generate api key/i }), + ).toBeNull(); + }); + + /** + * ⚠️ **A sign-in refusal must be VISIBLE to a visitor who is still signed + * in.** ADR 0010 grants the dashboard and the reveal to the session alone, + * so somebody who leaves the Stellar Discord keeps reading the key they + * already hold and the callback rightly leaves that session standing. But + * `RootRoute` forwards every authenticated arrival to `/dashboard` with the + * query attached, and `?signin=…` was read only by the signed-OUT card — so + * the refusal rendered nowhere at all and the dashboard said nothing. + */ + it('renders a sign-in membership refusal on the dashboard of a signed-in visitor', async () => { + signedInWithKey(); + renderApp('/?signin=not_member'); + + const refusal = await screen.findByTestId('issue-not-member'); + expect(refusal.textContent).toMatch(/stellar developers discord/i); + // The session is untouched: the key they already hold is still on screen. + expect(await screen.findByTestId('api-key')).toBeTruthy(); + // And it is one-shot, like every other landing state. + await waitFor(() => expect(lastSearch).toBe('')); + }); + + it('keeps could-not-verify distinct from not-a-member on the dashboard too', async () => { + signedInWithKey(); + renderApp('/?signin=unknown'); + + const refusal = await screen.findByTestId('issue-unknown'); + expect(refusal.textContent).toMatch(/problem talking to discord/i); + expect(screen.queryByTestId('issue-not-member')).toBeNull(); + }); + + /** + * The two outcomes the dashboard must NOT claim: they are about a + * round-trip that never reached a verdict, and the remedy is the sign-in + * button this page does not have. + */ + it('leaves a cancelled or failed sign-in to the card that owns the button', async () => { + for (const outcome of ['cancelled', 'failed']) { + signedInWithKey(); + const view = renderApp(`/?signin=${outcome}`); + + expect(await screen.findByTestId('api-key')).toBeTruthy(); + expect(screen.queryByTestId('issue-not-member'), outcome).toBeNull(); + expect(screen.queryByTestId('issue-unknown'), outcome).toBeNull(); + view.unmount(); + } }); /** @@ -1168,10 +2650,40 @@ describe('usage against quota', () => { username: 'adam', }), }), - [KEY_URL]: keyNoKey, + // ⚠️ **A key, not `keyNoKey`** (2026-08-26). This suite is about the + // usage panel, and every test in it used to run against an account with + // no key — incidental when the two cards were independent, and wrong + // since the `Dashboard - no key` frame made the key card's answer empty + // this one. An account with usage figures is an account with a key, so + // the stub now says so and the panel under test actually renders. + // + // The one test that IS about having no key asserts the empty tile + // instead, below. + [KEY_URL]: () => ({ + json: async () => ({ + key_id: 'usage-suite-key', + name: 'discord-usage-key', + value: 'USAGESUITEKEY000000000000000000000000000', + }), + }), [USAGE_URL]: usage, }); + /** The same stub with the account's key genuinely absent. */ + const signedInWithoutAnyKey = () => + stubRoutes({ + [CONFIG_URL]: openConfig, + [ME_URL]: () => ({ + json: async () => ({ + authenticated: true, + user_id: '308994132968210433', + username: 'adam', + }), + }), + [KEY_URL]: keyNoKey, + [USAGE_URL]: usageNoKey, + }); + const renderApp = () => render( @@ -1194,10 +2706,18 @@ describe('usage against quota', () => { // The limits as numbers, not prose (task 0157's figures). expect(screen.getByText(/request per second/i)).toBeTruthy(); - // The reset rule is OURS — the 1st of the month, 00:00 UTC — and the next - // date is rendered from the response, not computed in the page. - expect(screen.getByText(/1st of each month, 00:00 UTC/i)).toBeTruthy(); - expect(screen.getByText(/2026-09-01/)).toBeTruthy(); + // ⚠️ The reset RULE sentence ("the 1st of each month, 00:00 UTC") was cut + // from this card on 2026-08-25 at Adam's instruction, along with the lag + // line and the Refresh button — the frame has none of them and task 0226's + // chart takes the space. What survives is the date itself, as the caption + // under the bar, which is the half a visitor acts on. + expect(screen.getByText('Resets 1 September')).toBeTruthy(); + // Task 0188's lag line, verbatim, back under the meter since 2026-08-27 + // (PR #249 review) — the panel must not present an AWS-lagged figure as + // live. `as_of` is the fixture's, rendered in UTC. + const asOf = screen.getByTestId('usage-as-of'); + expect(asOf.textContent).toMatch(/Last updated .*10:15:00 UTC/); + expect(asOf.textContent).toMatch(/AWS reports usage with a delay/); // And the URL is relative: same-origin, cookie attached by the browser. const call = fetchMock.mock.calls.find(([url]) => url === USAGE_URL) as [ @@ -1207,27 +2727,17 @@ describe('usage against quota', () => { }); /** - * **The wording this task decides once** (task 0193 restyles it without - * re-deciding): every rendered figure carries when it was last refreshed and - * that AWS reports with a delay. Without this line, a visitor who just made - * requests reads the dashboard as broken. + * ⚠️ **The lag line was deleted on 2026-08-25 and is back since 2026-08-27.** + * + * Task 0188 decided that every figure on this card carries "Last updated … + * — AWS reports usage with a delay, so requests made in the last few minutes + * may not be counted yet". Adam removed the line on 2026-08-25 ("to jest do + * usunięcia, tutaj będą wykresy") together with the reset-rule sentence and + * the Refresh button; PR #249's review sent it back, on 0188's own terms — + * this slice restyles the line and does not re-decide it. It is asserted in + * the numbers test above; what stays deleted is the Refresh button and the + * reset-rule sentence, whose date survives as the meter caption. */ - it('states when the figure was last refreshed, and that AWS lags', async () => { - signedInWithUsage(); - renderApp(); - - await screen.findByTestId('usage-used'); - expect(screen.getByText(/last updated/i).textContent).toMatch( - /AWS reports usage with a delay/i, - ); - // The timestamp is the backend's `as_of` — the moment of the GetUsage — - // rendered in UTC (the decided wording says UTC, not toUTCString's - // "GMT"), not the moment of the page load. - expect(screen.getByText(/last updated/i).textContent).toContain( - new Date(USAGE.as_of).toUTCString().replace(/GMT$/, 'UTC'), - ); - expect(screen.getByText(/last updated/i).textContent).not.toContain('GMT'); - }); /** Usage is read-only, so it may and does load on mount. */ it('fetches usage on mount, without any button press', async () => { @@ -1246,16 +2756,36 @@ describe('usage against quota', () => { }); /** A signed-in visitor with no key is told so, in words they can act on. */ - it('renders the no-key state rather than an error', async () => { - signedInWithUsage(usageNoKey); + /** + * ⚠️ **Both cards go EMPTY when the account has no key** (Adam, 2026-08-26, + * from the `Dashboard - no key` frame), and this test used to assert the + * opposite of each half. + * + * It asserted the usage panel's "you have no API key yet" sentence, which is + * no longer rendered on this state, and it asserted that the rate limit still + * showed — with a comment arguing the figure belongs to the plan rather than + * to a key, which is true and was overruled: the frame gives the empty + * dashboard one action, on the card above, and nothing beside it to compete. + * + * What it still guards is the part that matters — the absence is rendered as + * a deliberate empty state and never as a failure. + */ + it('renders no key as two empty tiles, not as an error', async () => { + signedInWithoutAnyKey(); renderApp(); - expect(await screen.findByText(/no API key yet/i)).toBeTruthy(); + // The key card's own state is what proves the page got there. + await screen.findByTestId('no-key-notice'); + + // Both panels keep their titles and lose their bodies. + expect(screen.getByText('Monthly Usage')).toBeTruthy(); + expect(screen.getByText('Rate Limit')).toBeTruthy(); + expect(screen.queryByText(/request per second/i)).toBeNull(); + expect(screen.queryByTestId('usage-used')).toBeNull(); + // "Active" is a claim about a key, and there is none. + expect(screen.queryByText('Active')).toBeNull(); + // And nothing anywhere reads as a failure. expect(document.body.textContent).not.toContain('Could not load'); - // The limits still render — they belong to the plan, not to a key, and a - // visitor deciding whether to issue one is exactly who they inform. - expect(screen.getByText(/request per second/i)).toBeTruthy(); - expect(screen.getByText(/1st of each month, 00:00 UTC/i)).toBeTruthy(); }); /** @@ -1298,7 +2828,6 @@ describe('usage against quota', () => { expect(await screen.findByText(/not recorded any usage/i)).toBeTruthy(); expect(screen.queryByTestId('usage-used')).toBeNull(); expect(screen.getByText(/request per second/i)).toBeTruthy(); - expect(screen.getByText(/last updated/i)).toBeTruthy(); }); /** @@ -1498,6 +3027,18 @@ describe('usage against quota', () => { [CONFIG_URL]: () => ({ json: async () => ({ enabled: true, rate_limit_per_second: 5 }), }), + // ⚠️ Added 2026-08-27. Without it `fetchKey` rejects with "unexpected + // request", the key card renders its failure state, and this test — and + // the one below — passed against a dashboard that was broken in a way + // neither of them was about. + [KEY_URL]: () => ({ + json: async () => ({ + key_id: 'rate-limit-suite-key', + name: 'discord-rate-limit-key', + value: 'aBcDeF0123456789aBcDeF0123456789aBcDeF01', + created_at: '2026-08-01T09:00:00Z', + }), + }), [ME_URL]: () => ({ json: async () => ({ authenticated: true, @@ -1514,6 +3055,70 @@ describe('usage against quota', () => { // Plural, because the figure is no longer the one the sentence was // written around. expect(screen.getByText(/requests per second/i)).toBeTruthy(); + // The per-minute tile carries the per-minute unit — it read "req/s" + // once, next to a tile that also read "req/s" with a different number. + expect(screen.getByText('req/min')).toBeTruthy(); + }); + + /** + * The state a cut-off developer lands on, and the one that had no test at + * all: at and past the ceiling the card must say why the API answers 429 + * and when that stops being true. + */ + it('says the quota is reached at the ceiling, and past it', async () => { + for (const used of [1000, 1200]) { + signedInWithUsage(() => ({ + json: async () => ({ + ...USAGE, + used, + remaining: 0, + limit: 1000, + resets_at: '2026-09-01T00:00:00Z', + }), + })); + const view = renderApp(); + + const notice = await screen.findByTestId('usage-quota-reached'); + expect(notice.textContent, `used=${used}`).toMatch(/HTTP 429/); + expect(notice.textContent, `used=${used}`).toMatch(/1 September/); + expect(screen.getByText(/limit reached/i)).toBeTruthy(); + view.unmount(); + } + }); + + /** An unparseable reset instant drops the date rather than guessing one. */ + it('states the quota is reached even when the reset date is unusable', async () => { + signedInWithUsage(() => ({ + json: async () => ({ + ...USAGE, + used: 1000, + remaining: 0, + limit: 1000, + resets_at: 'not-a-date', + }), + })); + renderApp(); + + const notice = await screen.findByTestId('usage-quota-reached'); + expect(notice.textContent).toMatch(/HTTP 429/); + expect(notice.textContent).not.toMatch(/Invalid Date/); + }); + + /** + * "Limit reached" is decided on the counts, not on a rounded percentage. + * 995 of 1,000 rounds to 100 %, and the bar used to go red and say the + * limit was reached while `remaining` still said 5 and the quota-reached + * notice — gated on `used >= limit` — stayed away. + */ + it('does not call the quota reached before it is', async () => { + signedInWithUsage(() => ({ + json: async () => ({ ...USAGE, used: 995, remaining: 5, limit: 1000 }), + })); + renderApp(); + + expect((await screen.findByTestId('usage-used')).textContent).toBe('995'); + expect(screen.queryByText(/limit reached/i)).toBeNull(); + expect(screen.getByText('99% used')).toBeTruthy(); }); /** @@ -1521,9 +3126,17 @@ describe('usage against quota', () => { * fallback figure would be the same silent staleness one layer down — and * unlike the missing line, it would look authoritative. */ - it('omits the rate limit when the backend does not report one', async () => { + it('falls back to the plan rate rather than dropping the Rate Limit card', async () => { stubRoutes({ [CONFIG_URL]: () => ({ json: async () => ({ enabled: true }) }), + [KEY_URL]: () => ({ + json: async () => ({ + key_id: 'rate-limit-suite-key', + name: 'discord-rate-limit-key', + value: 'aBcDeF0123456789aBcDeF0123456789aBcDeF01', + created_at: '2026-08-01T09:00:00Z', + }), + }), [ME_URL]: () => ({ json: async () => ({ authenticated: true, @@ -1535,12 +3148,16 @@ describe('usage against quota', () => { }); renderApp(); - // The rest of the panel is unaffected — only the one line it cannot - // honestly render goes missing. + // ⚠️ The OPPOSITE of what this pinned until 2026-08-25, when Adam found the + // whole Rate Limit card missing on a local run. `/config` without a limit + // used to drop the panel; it now shows the free plan's documented 1 req/s + // (task 0157), the same figure the landing page states to every visitor. + // A stated figure beats a third of the dashboard disappearing — and where + // the deployment DOES answer, its value still wins (the test above). await screen.findByTestId('usage-used'); - expect(screen.queryByTestId('rate-limit')).toBeNull(); - expect(screen.queryByText(/per second/i)).toBeNull(); - expect(screen.getByText(/quota resets on the 1st/i)).toBeTruthy(); + expect((await screen.findByTestId('rate-limit')).textContent).toBe('1'); + expect(screen.getByText(/per-minute limit/i)).toBeTruthy(); + expect(screen.getByText(/request per second/i)).toBeTruthy(); }); /** @@ -1592,31 +3209,20 @@ describe('usage against quota', () => { }); /** The refresh control re-asks; the backend's cache bounds what that costs. */ - it('refreshes on the button', async () => { - const fetchMock = signedInWithUsage(); - renderApp(); - await screen.findByTestId('usage-used'); - const before = fetchMock.mock.calls.filter( - ([url]) => url === USAGE_URL, - ).length; - - fireEvent.click(screen.getByRole('button', { name: /refresh/i })); - - await waitFor(() => - expect( - fetchMock.mock.calls.filter(([url]) => url === USAGE_URL).length, - ).toBe(before + 1), - ); - await screen.findByTestId('usage-used'); - }); + /** + * ⚠️ **DELETED with the control.** The usage card had a Refresh button and + * this pinned that pressing it re-read `/usage`; Adam removed it on + * 2026-08-25 with the two lines beside it. The panel still refetches on its + * own — on mount, and when a key appears on screen (the tests above) — but a + * visitor who wants a fresher figure now reloads the page. + */ /** A backend failure is a stated failure, not a blank section. */ - it('reports a failure and keeps the refresh control', async () => { + it('reports a failure rather than an empty card', async () => { signedInWithUsage(() => ({ ok: false, status: 502 })); renderApp(); expect(await screen.findByText(/could not load your usage/i)).toBeTruthy(); - expect(screen.getByRole('button', { name: /refresh/i })).toBeTruthy(); }); /** @@ -1718,11 +3324,33 @@ describe('replace my key', () => { expect(revokeCalls(fetchMock)).toHaveLength(0); }); + /** + * Task 0193 made it a real modal rather than a section spliced into the + * page: the frame draws a floating card over a dimmed dashboard, and the + * properties that come with that — an accessible name, `aria-modal`, and a + * confirm carrying the frame's verb — are what a visitor and a screen + * reader both need to know which decision they are being asked for. + */ + it('opens as a modal dialog named for the action, with the frame verb on the confirm', async () => { + signedIn(); + renderApp(); + + await openDialog(); + const dialog = screen.getByRole('dialog', { + name: /regenerate api key\?/i, + }); + expect(dialog.getAttribute('aria-modal')).toBe('true'); + expect( + (screen.getByTestId('replace-key-confirm') as HTMLButtonElement) + .textContent, + ).toMatch(/^regenerate$/i); + }); + it('does not offer replacement to a visitor with no key', async () => { signedIn(undefined, keyNoKey); renderApp(); - await screen.findByRole('link', { name: /get my api key/i }); + await screen.findByRole('link', { name: /generate api key/i }); expect(screen.queryByTestId('replace-key-open')).toBeNull(); }); @@ -1748,8 +3376,38 @@ describe('replace my key', () => { expect(warning.textContent).toMatch(/next quota period/i); }); - /** Confirm is disabled until `delete-key` is typed — exactly that phrase. */ - it('keeps confirm disabled until the visitor types delete-key', async () => { + /** + * Confirm is disabled until `regenerate-key` is typed — exactly that + * phrase. The string is Adam's (2026-08-26, following the button's word); + * the property task 0191 decided is the one asserted here, that a + * near-miss never arms the control and never reaches the backend. + */ + /** + * ⚠️ The dialog declared `aria-describedby="replace-key-warning"` against a + * node that carried only a `data-testid`, so the two sentences it exists to + * deliver — the deactivation window and "no new key is issued now" — were + * never announced. A destructive confirmation that a screen reader hears as + * a bare title is a confirmation of nothing. + */ + it('describes the destructive dialog with the warning a screen reader needs', async () => { + signedIn(); + renderApp(); + + fireEvent.click(await screen.findByTestId('replace-key-open')); + const dialog = await screen.findByRole('dialog'); + const describedBy = dialog.getAttribute('aria-describedby'); + expect(describedBy).toBe('replace-key-warning'); + + const description = document.getElementById(describedBy as string); + expect( + description, + 'aria-describedby must resolve to a real node', + ).toBeTruthy(); + expect(description?.textContent).toMatch(/no new key is issued now/i); + expect(description?.textContent).toMatch(/deactivates the current one/i); + }); + + it('keeps confirm disabled until the visitor types regenerate-key', async () => { const fetchMock = signedIn(); renderApp(); @@ -1760,14 +3418,19 @@ describe('replace my key', () => { const phrase = screen.getByTestId('replace-key-phrase') as HTMLInputElement; expect(confirm.disabled).toBe(true); - for (const typed of ['delete', 'delete key', 'DELETE-KEY', 'delete-key ']) { + for (const typed of [ + 'regenerate', + 'regenerate key', + 'REGENERATE-KEY', + 'regenerate-key ', + ]) { fireEvent.change(phrase, { target: { value: typed } }); expect(confirm.disabled, typed).toBe(true); fireEvent.click(confirm); } expect(revokeCalls(fetchMock)).toHaveLength(0); - fireEvent.change(phrase, { target: { value: 'delete-key' } }); + fireEvent.change(phrase, { target: { value: 'regenerate-key' } }); expect(confirm.disabled).toBe(false); }); @@ -1785,7 +3448,7 @@ describe('replace my key', () => { 'replace-key-confirm', )) as HTMLButtonElement; fireEvent.change(screen.getByTestId('replace-key-phrase'), { - target: { value: 'delete-key' }, + target: { value: 'regenerate-key' }, }); fireEvent.click(confirm); @@ -1819,7 +3482,9 @@ describe('replace my key', () => { expect(screen.queryByTestId('api-key')).toBeNull(); expect(document.body.textContent).not.toContain(KEY.value); // And no issue link while the date is ahead. - expect(screen.queryByRole('link', { name: /get my api key/i })).toBeNull(); + expect( + screen.queryByRole('link', { name: /generate api key/i }), + ).toBeNull(); }); /** @@ -1839,7 +3504,7 @@ describe('replace my key', () => { await openDialog(); fireEvent.change(screen.getByTestId('replace-key-phrase'), { - target: { value: 'delete-key' }, + target: { value: 'regenerate-key' }, }); fireEvent.click(screen.getByTestId('replace-key-confirm')); @@ -1854,7 +3519,9 @@ describe('replace my key', () => { const revoked = screen.getByTestId('key-revoked'); expect(revoked.textContent).not.toMatch(/do not have a working key/i); expect(revoked.textContent).not.toMatch(/1 September 2026/); - expect(screen.queryByRole('link', { name: /get my api key/i })).toBeNull(); + expect( + screen.queryByRole('link', { name: /generate api key/i }), + ).toBeNull(); }); /** The ordinary answer carries no flag, and renders no warning. */ @@ -1864,7 +3531,7 @@ describe('replace my key', () => { await openDialog(); fireEvent.change(screen.getByTestId('replace-key-phrase'), { - target: { value: 'delete-key' }, + target: { value: 'regenerate-key' }, }); fireEvent.click(screen.getByTestId('replace-key-confirm')); @@ -1893,7 +3560,7 @@ describe('replace my key', () => { await openDialog(); fireEvent.change(screen.getByTestId('replace-key-phrase'), { - target: { value: 'delete-key' }, + target: { value: 'regenerate-key' }, }); fireEvent.click(screen.getByTestId('replace-key-confirm')); @@ -1935,12 +3602,19 @@ describe('replace my key', () => { const revoked = await screen.findByTestId('key-revoked'); expect(revoked.textContent).toMatch(/1 September 2026/); - expect(screen.queryByRole('link', { name: /get my api key/i })).toBeNull(); + expect( + screen.queryByRole('link', { name: /generate api key/i }), + ).toBeNull(); expect(screen.queryByTestId('replace-key-open')).toBeNull(); // The key section's keyless copy (and its issue link) must not render; // the usage section beside it may still say "no key" — that is its own // endpoint's answer, stubbed here, not the key section's. - expect(screen.queryByText(/one key, on the free plan/i)).toBeNull(); + // + // ⚠️ This asserted the absence of "one key, on the free plan" until + // 2026-08-27 — a sentence that exists nowhere in the repo, so it held + // however the card rendered. `no-key-notice` is the testid the keyless + // branch actually carries. + expect(screen.queryByTestId('no-key-notice')).toBeNull(); }); /** @@ -1961,7 +3635,7 @@ describe('replace my key', () => { await openDialog(); fireEvent.change(screen.getByTestId('replace-key-phrase'), { - target: { value: 'delete-key' }, + target: { value: 'regenerate-key' }, }); fireEvent.click(screen.getByTestId('replace-key-confirm')); await screen.findByTestId('key-revoked'); @@ -1976,6 +3650,140 @@ describe('replace my key', () => { ); }); + /** + * ⚠️ **Signing in to an account whose key is already revoked replaces the + * whole dashboard with one card** (Adam, 2026-08-26, Figma `997:2114`). + * + * Three boundaries are asserted here rather than the pixels, because each + * one is a way this could quietly swallow something a visitor needs: + * the card must carry task 0191's decided sentence, it must not appear for a + * revocation that happened in THIS page load, and it must not appear on a + * landing that carries an `?issue=…` outcome it cannot explain. + */ + it('replaces the dashboard for an account that arrives already revoked', async () => { + signedIn(undefined, keyRevoked); + renderApp(); + + const card = await screen.findByTestId('key-revoked'); + expect( + screen.getByRole('heading', { name: /your api key has been revoked/i }), + ).toBeTruthy(); + expect(screen.getByTestId('revoked-next-eligible').textContent).toMatch( + /1 September 2026/, + ); + // The frame's Reason, verbatim. + expect(screen.getByTestId('revoked-reason').textContent).toMatch( + /monthly quota exceeded repeatedly/i, + ); + // Both of the frame's actions, and the footer's date. + expect(screen.getByTestId('revoked-contact')).toBeTruthy(); + expect(screen.getByTestId('revoked-sign-out')).toBeTruthy(); + expect(card.textContent).toMatch(/After 1 September 2026, sign in again/i); + // No key panel behind it: nothing to copy, reveal or regenerate. + expect(screen.queryByTestId('api-key')).toBeNull(); + expect(screen.queryByTestId('replace-key-open')).toBeNull(); + }); + + /** + * The refusals the card must not swallow arrive as `?signin=…` too, since + * sign-in runs the membership check: an account whose key is revoked and + * which has since left the Discord gets exactly this landing. The guard + * read only `issue`, the card replaced the page, and the one component + * that renders the refusal was unmounted — the visitor was refused and + * told nothing (PR #249 review, both reviewers). + */ + it('keeps a sign-in refusal on screen for an account that arrives revoked', async () => { + signedIn(undefined, keyRevoked); + renderApp('/?signin=not_member'); + + expect(await screen.findByTestId('issue-not-member')).toBeTruthy(); + // The ordinary dashboard, with its revoked panel — not the card. Awaited: + // the banner renders before `GET /key` has answered, the panel after. + expect(await screen.findByTestId('key-revoked')).toBeTruthy(); + expect(screen.queryByTestId('revoked-reason')).toBeNull(); + }); + + /** + * The revoked card's own sign-out was rendered and asserted present, but + * never pressed — so nothing pinned that it does anything. It is the only + * control on that screen that leads anywhere. + */ + it('signs out from the revoked card, with a POST', async () => { + let authenticated = true; + const fetchMock = stubRoutes({ + [CONFIG_URL]: openConfig, + [ME_URL]: () => ({ + json: async () => + authenticated + ? { + authenticated: true, + user_id: '308994132968210433', + username: 'adam', + } + : { authenticated: false }, + }), + [KEY_URL]: keyRevoked, + [USAGE_URL]: usageNoKey, + [LOGOUT_URL]: () => { + authenticated = false; + return { status: 204, json: async () => ({}) }; + }, + }); + renderApp(); + + fireEvent.click(await screen.findByTestId('revoked-sign-out')); + + const logout = await waitFor(() => { + const call = fetchMock.mock.calls.find(([url]) => url === LOGOUT_URL); + expect(call).toBeTruthy(); + return call as [string, RequestInit]; + }); + // A GET sign-out is triggerable by any third-party page. + expect(logout[1].method).toBe('POST'); + // And the session is re-read rather than assumed: the card goes. + await waitFor(() => expect(screen.queryByTestId('key-revoked')).toBeNull()); + }); + + /** + * The account is not stuck: once the period has rolled, the frame's + * "Contact us about this decision" gives way to the control that actually + * issues a key. The frame's footer says one arrives automatically on signing + * in, which nothing implements. + */ + it('offers the issue round-trip from the revoked card once the period has passed', async () => { + signedIn(undefined, () => ({ + ok: false, + status: 404, + json: async () => ({ + code: 'key_revoked', + details: { next_eligible_at: '2020-01-01T00:00:00Z' }, + }), + })); + renderApp(); + + await screen.findByTestId('key-revoked'); + expect(screen.getByTestId('revoked-issue').getAttribute('href')).toBe( + ISSUE_HREF, + ); + expect(screen.queryByTestId('revoked-contact')).toBeNull(); + }); + + /** + * The `?issue=…` outcomes are reachable BY a revoked account — `capped` is + * the obvious one, but `not_member`, `too_young` and `unknown` all are — and + * each says something this card cannot. A landing carrying one keeps the + * ordinary dashboard. + */ + it('keeps the ordinary dashboard when the landing carries an issue outcome', async () => { + signedIn(undefined, keyRevoked); + renderApp('/?issue=capped&next_eligible_at=2026-09-01'); + + expect(await screen.findByTestId('issue-capped')).toBeTruthy(); + expect( + screen.queryByRole('heading', { name: /your api key has been revoked/i }), + ).toBeNull(); + }); + /** * A malformed `next_eligible_at` must NOT unlock the issue link — the safe * direction for garbage is to keep waiting (the server would only refuse). @@ -1992,7 +3800,9 @@ describe('replace my key', () => { renderApp(); await screen.findByTestId('key-revoked'); - expect(screen.queryByRole('link', { name: /get my api key/i })).toBeNull(); + expect( + screen.queryByRole('link', { name: /generate api key/i }), + ).toBeNull(); expect(document.body.textContent).toMatch( /start of the next quota period/i, ); @@ -2020,17 +3830,22 @@ describe('replace my key', () => { * "deactivated on just now", and never the next-eligible phrase presented * as the revocation instant. */ + /** Same move as the test above, and for the same reason. */ it('renders an undated revocation without inventing an instant', async () => { - signedIn(undefined, () => ({ - ok: false, - status: 404, + signedIn(() => ({ json: async () => ({ - code: 'key_revoked', - details: { next_eligible_at: '2026-09-01T00:00:00Z' }, + revoked: true, + next_eligible_at: '2026-09-01T00:00:00Z', }), })); renderApp(); + await openDialog(); + fireEvent.change(screen.getByTestId('replace-key-phrase'), { + target: { value: 'regenerate-key' }, + }); + fireEvent.click(screen.getByTestId('replace-key-confirm')); + const revoked = await screen.findByTestId('key-revoked'); expect(revoked.textContent).toMatch(/deactivated/i); expect(screen.queryByTestId('revoked-at')).toBeNull(); @@ -2047,20 +3862,29 @@ describe('replace my key', () => { * days after the revocation (the reveal path), it must not tell the owner * of a long-dead key to keep treating it as live. */ + /** + * ⚠️ Driven through the in-page revoke since 2026-08-26. Task 0191's + * sentence lives on that path and on the partial one; an account that + * ARRIVES already revoked now gets the frame's card + * (`RevokedDashboard`), whose Reason box is a fixed string. The property + * being pinned — the tense follows the propagation window — is unchanged. + */ it('states the propagation window in the past tense for an old revocation', async () => { - signedIn(undefined, () => ({ - ok: false, - status: 404, + signedIn(() => ({ json: async () => ({ - code: 'key_revoked', - details: { - next_eligible_at: '2026-09-01T00:00:00Z', - revoked_at: '2026-08-01T09:00:00Z', - }, + revoked: true, + next_eligible_at: '2026-09-01T00:00:00Z', + revoked_at: '2026-08-01T09:00:00Z', }), })); renderApp(); + await openDialog(); + fireEvent.change(screen.getByTestId('replace-key-phrase'), { + target: { value: 'regenerate-key' }, + }); + fireEvent.click(screen.getByTestId('replace-key-confirm')); + const revoked = await screen.findByTestId('key-revoked'); expect(screen.getByTestId('revoked-at').textContent).toBe( '1 August 2026, 09:00 UTC', @@ -2088,7 +3912,7 @@ describe('replace my key', () => { await openDialog(); fireEvent.change(screen.getByTestId('replace-key-phrase'), { - target: { value: 'delete-key' }, + target: { value: 'regenerate-key' }, }); fireEvent.click(screen.getByTestId('replace-key-confirm')); @@ -2108,6 +3932,20 @@ describe('replace my key', () => { expect(capped.querySelector('a')).toBeNull(); }); + /** + * `Date.UTC` rolls an impossible month or day over instead of failing, so a + * value that passes the shape check can still be garbage — and used to + * render as a confident "14 February 2027". + */ + it('sanitises a next_eligible_at whose month or day does not exist', async () => { + signedIn(undefined, keyRevoked); + renderApp('/?issue=capped&next_eligible_at=2026-13-45'); + + const capped = await screen.findByTestId('issue-capped'); + expect(capped.textContent).toMatch(/start of the next quota period/i); + expect(capped.textContent).not.toMatch(/2027/); + }); + it('sanitises a nonsense next_eligible_at instead of rendering it', async () => { signedIn(undefined, keyRevoked); renderApp('/?issue=capped&next_eligible_at=