Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
30 commits
Select commit Hold shift + click to select a range
64ccc77
feat(lore-0193): build the portal UI from the Figma design
adamkoot Aug 24, 2026
359df18
feat(lore-0193): build the dashboard from the Figma design
adamkoot Aug 24, 2026
3e265de
chore: add the Figma MCP server to the workspace
adamkoot Aug 24, 2026
b18da9b
Merge branch 'develop' into feat/0193_portal-presentable-ui-pass
adamkoot Aug 25, 2026
e5ffc7b
fix(lore-0193): close Adam's review of the landing page
adamkoot Aug 25, 2026
4eb3418
feat(lore-0193): build the mobile layout and use the Figma icons
adamkoot Aug 25, 2026
4d7dcba
feat(lore-0193): build the dashboard from its Figma frames
adamkoot Aug 25, 2026
b7eb30e
feat(lore-0193): build the quick start from its Figma frame
adamkoot Aug 25, 2026
efbd7d2
fix(lore-0193): close Adam's review of the quick start
adamkoot Aug 25, 2026
234605f
Merge remote-tracking branch 'origin/develop' into feat/0193_portal-p…
adamkoot Aug 26, 2026
79bc8e6
style: reformat the 0072 runbook after e3154c8
adamkoot Aug 26, 2026
556e290
feat(lore-0193): render the spent-quota and no-key dashboards
adamkoot Aug 26, 2026
0f825d9
feat(lore-0193): gate sign-in on eligibility and finish the login card
adamkoot Aug 26, 2026
e9cf735
fix(lore-0193): let the popup message win its race, and three dashboa…
adamkoot Aug 27, 2026
cdf9fea
fix(lore-0193): sign in on the guild alone, land a failed adoption plain
adamkoot Aug 27, 2026
5d2a047
docs(lore-0191): record regenerate-key as the arming phrase
adamkoot Aug 27, 2026
38a1f06
docs(lore-0193): renumber the daily-chart task 0222 to 0226
adamkoot Aug 27, 2026
88efb9f
fix(lore-0193): stop the sign-in callback outliving its own invocation
adamkoot Aug 27, 2026
7fed545
fix(lore-0193): render the refusals and the states that rendered nothing
adamkoot Aug 27, 2026
11908e0
fix(lore-0193): join the issue callback's parameter reads
adamkoot Aug 27, 2026
adfe4db
fix(lore-0193): render the session states the dashboard bounced
adamkoot Aug 27, 2026
c2d1c0f
fix(lore-0193): say what the page can stand behind — measured 429, ou…
adamkoot Aug 27, 2026
ac37135
docs(lore-0191): record the landing page as a second home of the revo…
adamkoot Aug 27, 2026
f3a7308
chore(lore-0193): keep the Figma MCP config out of the workspace
adamkoot Aug 27, 2026
778a7b1
fix(lore-0193): state the rules before the button, name the sign-out,…
adamkoot Aug 27, 2026
031f56e
docs(lore-0191): record the key card's strip as the third home of the…
adamkoot Aug 27, 2026
7d37337
fix(lore-0193): fit the signed-in bar in two rows at 375 px, and say …
adamkoot Aug 27, 2026
cd37384
chore(lore-0193): script the pending_absent measurement PR #249 block…
adamkoot Aug 27, 2026
46dc735
docs(lore-0189): record item 2 — Discord's REST member object carries…
adamkoot Aug 27, 2026
25b8646
chore(lore-0193): default the measurement to the scratch guild, and r…
adamkoot Aug 27, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -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
10 changes: 5 additions & 5 deletions docs/runbooks/0072-current-prices-mv-rollout.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
36 changes: 36 additions & 0 deletions infra/src/lib/stacks/portal-hosting-stack.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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;
Expand Down
70 changes: 69 additions & 1 deletion lore/1-tasks/active/0193_FEATURE_portal-presentable-ui-pass.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand Down Expand Up @@ -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
`<span>`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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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.
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand All @@ -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
Expand Down
Loading
Loading