Skip to content

feat(0191): replace my key — revoke now, re-issue next quota period - #238

Merged
adamkoot merged 10 commits into
developfrom
feat/0191_rework-key-once-per-quota-period
Aug 25, 2026
Merged

feat(0191): replace my key — revoke now, re-issue next quota period#238
adamkoot merged 10 commits into
developfrom
feat/0191_rework-key-once-per-quota-period

Conversation

@adamkoot

@adamkoot adamkoot commented Aug 21, 2026

Copy link
Copy Markdown
Collaborator

Summary

  • "Replace my key" is a revocation (model reversed by Adam on 2026-08-21 after seeing the original swap live): POST /key/rework disables the caller's key immediately (UpdateApiKey enabled=false, session-only, no Discord round-trip) and issues nothing. A new key can be issued only from the start of the next quota period — the 1st of next month, 00:00 UTC, our period rule (portal/period.rs, shared with the usage panel).
  • The re-issue cap lives on the issue path: the disabled key is the revocation record (its lastUpdatedDate decides), so "Get my API key" inside that period passes eligibility and lands on ?issue=capped&next_eligible_at=… with nothing written; once the period rolls, the same press deletes the record and creates the new key. The reveal answers 404 key_revoked with the date and never hands the dead value out again. No registry needed (0190 stays cancelled); 0192 (revoke) is absorbed.
  • Frontend: "Replace my key…" opens a confirmation stating the key is deactivated immediately and that no new key is issued until the next period; confirm arms only on typing delete-key, sends one POST, disables on submit; the revoked state renders the date and defers the issue link until it has passed.
  • Infra: one new grant, apigateway:PATCH on /apikeys/* (PortalReadDisableAndDeleteOwnApiKeys), reasoning at the statement. No UpdateUsagePlan/UpdateUsage. Also: AWS credentials resolved once at Gateway construction (a local-run race on the SSO profile provider answered every call with 502).
  • Step 0 (item 7): the 0180 run had died after three samples; the DAY-proxy poller was re-started 2026-08-21 11:51Z and its verdict goes into the task's Step 0 table. The boundary is no longer presented as AWS-documented anywhere (0157, epic doc, runbooks); the MONTH confirmation is dated 1 September 2026. The epic's 2026-08-07 swap decision is struck through with the reversal dated, not deleted.
  • Tests: 19 HTTP in portal_rework.rs, cap/naming/period units, 12 frontend — workspace 640 Rust / 88 portal, 0 failed.

Update — audit round + review of it (2026-08-21)

Two further rounds on this branch, one commit (they share files).

Four-lens audit. One selector and one cap instant for four readers — the reveal capped on the earliest record's date while the issue capped on the latest, so two revocation records from different months made the page offer an issue the round-trip refused, and /usage ignored enabled entirely. The re-listing after a post-roll create excludes what it just deleted and anything disabled (GetApiKeys is eventually consistent; a phantom record would win, 404 on attach, and spend the retry). A create starts only with 4s of the deadline left, and CreateApiKey is sent without SDK retries — no idempotency token, so a retried request whose first try landed is a duplicate. The revoke requires the portal's own request marker and refuses cross-site Sec-Fetch-Site: SameSite=Lax is site-scoped, and after the custom-domain cutover (0195) a sibling host's form POST would have cost the victim their key for a month. IAM /apikeys/* is tag-scoped on GET/PATCH/DELETE — the per-key GET with includeValue=true was the account-wide exposure, not the listing.

Review of that diff. An undated record no longer poisons the cap instant: None is capped, and a capped answer recomputes next_eligible_at from the current period on every read, so one undated duplicate rolled the date forward every month and locked its owner out permanently. The revoke decides next_eligible_at through cap::decide like the other four readers, so an idempotent press after the period rolled answers "now" rather than naming a month that is not owed. revoked_at is nullable instead of an invented epoch, and the page renders a revocation without an instant rather than "1 January 1970", "just now", or the next-eligible phrase in the instant's place. The propagation window is present-tense only while it is open — the revoked view renders on every page load, days after the fact. RECONCILE_FLOOR below CREATE_FLOOR is recorded as deliberate with a const assertion (an adoption still fits in the gap; a create does not). README §3c drops "immediately", the claim the dialog had already lost.

Tests: 648 Rust / 93 portal, 0 failed. cargo fmt --check, clippy --all-targets (0 warnings), cargo check --features lambda, nx run-many -t lint typecheck build test -p portal, nx format:check --all green.

Still open on the task, deliberately: the item-7 DAY-proxy verdict, the MONTH confirmation dated 1 September 2026, and the audit's B6/B9/B10/B11/C/D/E items (control-plane call budget, the 0205 deploy, CI allow-list, dialog focus/Escape, doc corrections).

…nd-trip

"Replace my key" on the dashboard: an `action=rework` OAuth round-trip
that re-proves Discord membership (never account age) against a fresh
token, then swaps the key — the new one is created and attached BEFORE
the old one is deleted, so the visitor is never keyless, and every
failure leaves them holding the key they had (a failed delete rolls the
replacement back). Capped at one per quota period, decided from the
surviving key's `createdDate` against the calendar month in UTC: a key
issued this period cannot be reworked this period either, or a fresh
key would be a fresh counter.

The `409` the task asks for is the read-only pre-check
`POST /key/rework` (`rework_capped`, `details.next_eligible_at`), which
the confirmation dialog calls on open so a capped visitor reads
"1 September 2026" instead of typing `delete-key` for a refusal. The
callback decides the cap again before anything moves. Eight `?rework=`
landings, all literals; the only session-reachable route writes nothing.

The period rule now lives once, in `portal/period.rs`, shared by the
usage panel and the cap — with the cap it stopped being a label and
became a correctness property. No infra change: five calls the role
already holds, no UpdateUsagePlan, no UpdateUsage.

Step 0's measurement is NOT done, and the record says so: the 0180
item-7 run had died after three samples (the archived note said
"running" for a week), and the AWS session was expired for this build.
The wording half is done everywhere the boundary was presented as
AWS-documented; the MONTH confirmation is dated 1 September 2026.

Tests: 31 new over HTTP (write ordering asserted on the mock's call
sequence), 12 unit, 23 frontend; workspace 655 Rust / 93 portal, 0 failed.
A dashboard load fires /key and /usage in parallel, and on a local serve
run against an SSO profile the two raced the credential chain's lazy
first initialisation: the loser got 'profile file credentials provider
initialization error already taken' and every call answered 502. One
eager provide_credentials() at cold start serialises it; in the Lambda
the chain reads the environment and this is a no-op.
… next period

Reversed by Adam after seeing the swap live. "Replace my key" no longer
issues a new key: it disables the current one immediately
(UpdateApiKey enabled=false, session-only, no Discord round-trip) and a
new key can be issued only from the start of the next quota period.
The disabled key is the revocation record — its lastUpdatedDate is what
the re-issue cap reads — so there is still no registry.

The cap moved to the issue path: a revoked user's "Get my API key"
passes eligibility and lands on ?issue=capped&next_eligible_at=…; once
the period has rolled the same press deletes the record and creates the
new key. The reveal answers 404 key_revoked with the date and never
hands the dead value out again. The action=rework OAuth round-trip, its
eight landings and the swap are removed; one IAM grant is added
(apigateway:PATCH on /apikeys/*) and explained at the statement.

The 2026-08-07 swap decision is struck through in the epic doc with the
reversal dated, not deleted. 0192 (revoke) is absorbed.

BREAKING CHANGE: POST /api-tokens/api/key/rework now revokes the key
instead of pre-checking a swap; GET /key can answer 404 key_revoked.

Tests: 19 HTTP in portal_rework.rs (rewritten), cap/naming units,
12 frontend; workspace 640 Rust / 88 portal, 0 failed.
@adamkoot adamkoot changed the title feat(0191): replace a key once per quota period — the rework round-trip feat(0191): replace my key — revoke now, re-issue next quota period Aug 21, 2026
Two rounds on top of the revoke-now/re-issue-next-period slice: the
2026-08-21 four-lens audit, and the code review of that audit's own diff.
They share files, so they land as one commit.

From the audit:

- One selector and one cap instant for four readers. The reveal capped on
  the earliest record's date while the issue capped on the latest, so two
  revocation records from different months made the page offer an issue the
  round-trip refused. Usage ignored `enabled` entirely.
- The re-listing after a post-roll create excludes what it just deleted and
  anything disabled: `GetApiKeys` is eventually consistent, and a phantom
  earlier-created record would win, 404 on attach, and spend the retry.
- A create starts only with 4s of the deadline left, and `CreateApiKey` is
  sent without SDK retries — no idempotency token, so a retried request
  whose first try landed is a duplicate.
- The revoke requires the portal's own request marker and refuses
  cross-site `Sec-Fetch-Site`. `SameSite=Lax` is site-scoped; after the
  custom-domain cutover a sibling host's form POST would have cost the
  victim their key for a month.
- IAM `/apikeys/*` is tag-scoped on GET/PATCH/DELETE. The per-key GET with
  `includeValue=true` was the account-wide exposure, not the listing.

From the review of it:

- An undated record no longer poisons the cap instant. `None` is capped,
  and a capped answer recomputes `next_eligible_at` from the current
  period on every read — so one undated duplicate rolled the date forward
  every month and locked its owner out for good.
- The revoke decides `next_eligible_at` through `cap::decide` like the
  other four readers, so an idempotent press after the period rolled
  answers "now" instead of naming a month that is not owed.
- `revoked_at` is nullable rather than an invented epoch, and the page
  renders a revocation without an instant instead of "1 January 1970",
  "just now", or the next-eligible phrase in the instant's place.
- The propagation window is present-tense only while it is open: the
  revoked view renders on every page load, days after the fact.
- `RECONCILE_FLOOR` below `CREATE_FLOOR` is recorded as deliberate, with a
  const assertion — an adoption still fits in the gap, a create does not.
- README §3c drops "immediately", the claim the dialog had already lost.

648 Rust, 93 portal, 0 failed.
`a8693ec` on develop landed with `*emphasis*` where prettier writes
`_emphasis_`, so `nx format:check --all` fails on every PR that merges
develop. Four lines, no content change.
Step 0's DAY-period rollover measurement is dropped deliberately rather
than attempted a third time, and the scratch stack it needed is gone.

Two runs died silently: 2026-08-13 (three samples, dead two minutes in,
while the archived note said "running" for a week) and 2026-08-21 (183
samples, dead ~9 h before the UTC midnight it existed to observe).
Nothing was ever measured.

The economics inverted. The proxy existed to avoid waiting for the real
MONTH rollover, 19 days away when it was designed and 8 days away now,
and a third run needs the script's defects fixed plus credentials that
outlive a 26 h window -- to produce what the task itself calls evidence,
not proof. What replaces it is better: the 1 September rollover read off
production from the GetUsage reset warn in keys/gateway.rs. Nothing in
the build waits on the answer; the cap is our rule, defined once in
portal/period.rs, and a different AWS instant moves a dashboard label.

The criterion this served -- stop presenting the boundary as
AWS-documented -- was already met by the wording work on 2026-08-21, so
the AC closes on that and says plainly that the measurement was dropped.

Scratch key, usage plan and REST API deleted 07:45Z; the account greps
clean of lore0180* and the production plan is untouched. The script's
own teardown reported success while leaving the plan alive -- it deletes
the plan before the REST API whose stage it references, and || true
swallows the refusal. That defect and three others (a poll loop that
dies silently under pipefail, a 26 h window outliving an SSO session, a
log appended across runs that can make analyse span two of them) are now
recorded at the top of the script, which is kept as a harness.

Dead poll logs moved to .trash/. One AC left: the 1 September check.
@karczuRF

Copy link
Copy Markdown
Collaborator

Code review — feat(0191): replace my key

Scope: git diff origin/develop...origin/feat/0191_rework-key-once-per-quota-period (34 files — the Rust portal slice, the portal frontend, and the IAM statement in compute-stack.ts).

The core cap logic (period.rs, cap.rs, naming::revocation_instant) is sound: boundary handling, December rollover, the None → capped decision, and the "latest revocation governs" rule all check out, and the four readers (reveal, revoke, usage, issue) genuinely go through the same cap::decide. The findings below are mostly in error propagation, plus the IAM change bundled into the feature.


🔴 1 + 4 interact — read these together

4. infra/src/lib/stacks/compute-stack.ts:591 — medium. PortalReadDisableAndDeleteOwnApiKeys adds StringEquals: aws:ResourceTag/ManagedBy = prices-portal to apigateway:GET and apigateway:DELETE, not just the new PATCH. That is a behaviour change to two already-deployed code paths, shipped in a feature PR: Gateway::value_of (GetApiKey includeValue=true) and the reconciler's duplicate sweep now both AccessDenied on any untagged exact-name key.

1. packages/prices-api/src/portal/keys/mod.rs:922 — medium. In attempt(), the post-roll cleanup uses gateway.delete(&dead.id).await?, propagating the first failure and aborting the whole issue. The loser-sweep loop ~130 lines below deliberately does not do this, and its comment gives the exact reason:

compute-stack.ts records that task 0194 may put an aws:ResourceTag/ManagedBy condition on DELETE, and an exact-name duplicate created by hand in the console carries no tag. With a ? here that key would AccessDenied on every request and the user would never get theirs at all.

This PR ships that condition, so the hypothetical is now live — and it is worse on this path than on the one the comment protects. A single untagged, disabled, exact-name key means every future ?action=issue press returns ?issue=failed permanently, with no in-product recovery.

Suggested: either split the GET/DELETE tightening out for 0194 as originally planned, or land log-and-continue here (matching the sibling loop) in the same change.

Also: packages/prices-api/README.md §3c documents the new PATCH grant but not the new condition on the two existing ones.


2. packages/prices-api/src/portal/keys/mod.rs:657 — medium

disable_all propagates gateway.disable(&key.id).await? from inside the loop, so a failure after the first key leaves some keys disabled while the route answers 502 and the dialog renders "Could not deactivate your key — it is still active" (app.tsx:594). That statement is false, and it is precisely the one the dialog's own doc comment says must never be made.

The single-key case has the same shape via a lost response: an SdkError::TimeoutError on an UpdateApiKey that actually landed disables the key, tells the user it is still active, and silently caps them for the rest of the month.

Consider reporting partial success (e.g. Done with a warning) rather than a flat 502 when at least one disable succeeded.

3. packages/prices-api/src/portal/keys/mod.rs:662 — low

disable_all returns Revocation::Done { at: Some(now_secs()) } even when every gateway.disable returned Ok(false) (the documented deletion race — the key was listed and is gone). The page then renders "your key was deactivated, next eligible 1 September", but no revocation record exists in the account, so the very next ?action=issue press finds live.is_empty() && revoked.is_empty() and creates a new key immediately. The two answers contradict each other.

Tracking whether any disable actually applied, and returning NoKey when none did, would keep them consistent.

5. packages/prices-api/src/portal/keys/naming.rs:118 — low

The doc block opening "The key the owner currently holds, among records…" is attached to revocation_instant, not to current_key (naming.rs:125), which has no doc comment at all. The two paragraphs were concatenated in the edit, so revocation_instant's rustdoc now describes the enabled-beats-disabled selection rule that belongs to current_key, followed by its own rule.

6. web/portal/src/app/app.tsx:1084 — low

The comment on the post-revoke refetch says the answer "may legitimately still carry the revoked key's figures (its counter is preserved), rendered under the 'deactivated' wording below". There is no such wording in the view.state === 'ok' branch — the only "deactivated" copy is at line 1191, inside the no-key branch. Because usage::fetch deliberately keeps returning the revoked key's counters while the cap is Capped, the common post-revoke path renders an ordinary usage panel ("Quota resets on the 1st…") with nothing indicating the key is dead.

7. packages/prices-api/tests/portal_keys/harness.rs:923 — low

rework_round_trip is left over from the abandoned swap design and is now not merely unused but non-functional: Action::parse("rework") returns None (asserted in state_token.rs), so /auth/login?action=rework answers 400 and the helper can never produce a 303. Worth deleting with the model it belonged to.


✅ Checked and cleared — not findings

  • into_service_error() in the new Gateway::disable does not panic on non-service errors in aws-smithy-runtime-api 1.12 (it builds an unhandled error), so a timeout there is handled, not a crash.
  • ApiKey::enabled() is bool (not Option<bool>) in aws-sdk-apigateway 1.108, and GetApiKeys returns lastUpdatedDate, so the new KeyRecord fields map correctly.
  • The CSRF stance holds end-to-end: x-requested-with is not CORS-safelisted, and CloudFront's ALL_VIEWER_EXCEPT_HOST_HEADER origin-request policy forwards both it and Sec-Fetch-Site to the Lambda.
  • ISSUE_PARAMS is a module constant, so widening it does not destabilise useOneShotParams' effect identity.
  • stillWaiting / describeNextEligible correctly handle both the RFC 3339 (reveal/revoke) and YYYY-MM-DD (landing query) shapes, and fail closed on garbage.

Seven findings from the PR #238 review, verified against the branch first —
five held as written, two needed correcting, and two more turned up while
checking them.

The tag condition goes on `PATCH` alone, in its own statement; `GET` and
`DELETE` go back to 0187's unconditioned grant, because narrowing those two
changes shipped behaviour and 0187's comment forbade it in as many words.
Narrowing them stays task 0194's, with the audit in hand.

The post-roll cleanup logs and steps over a failed delete, like the loser
sweep does — that branch runs on every press once the name holds nothing but
revoked keys, so one undeletable record was `?issue=failed` forever.

A revocation is now `Done`, `Partial` or an error. A partial one reaches the
page as `partial: true` and renders "a duplicate may still work" rather than a
plain "revoked"; the `502` copy no longer claims the key "is still active",
which is a state the page cannot know. Every disable racing away is `NoKey`,
not a dated record no issue press will honour.

The revocation instant now comes off the `UpdateApiKey` response instead of
this process's clock, so the answer and `cap::decide` cannot fall either side
of a period boundary on the 1st.

Also: `revocation_instant`'s rustdoc gets its own body back, and the dead
`rework_round_trip` helper is deleted.
The revoked view still rendered "you do not have a working key" and the
next-eligible date for a `Partial`. Both are false: the duplicate that refused
to be disabled is a working key, and the issue path adopts it rather than
refusing — `a_partial_revocation_is_reported_as_partial` asserts `?issue=ok`
for exactly that state. The warning above them already carries the only
instruction that applies.

Also asserts the clean answer omits the `partial` flag rather than sending it
as `false`, so a client that never learned about it reads the shape it always
did.
@adamkoot
adamkoot merged commit e0f73f7 into develop Aug 25, 2026
3 checks passed
@adamkoot
adamkoot deleted the feat/0191_rework-key-once-per-quota-period branch August 25, 2026 07:28
adamkoot added a commit that referenced this pull request Aug 25, 2026
The revoke model shipped in PR #238 (approved, CI green): deactivate every
key under the caller's name, issue nothing, and refuse the re-issue until
the next quota period. 0192 is already archived as superseded — its rule
and measurements live here.

The one criterion that could not be met is the MONTH rollover confirmation:
the next real boundary is 1 September 2026, after this task closes. Spawned
as 0221 rather than left as prose, and the criterion is marked deferred, not
done. Nothing in the build waits on it — the cap is our rule, defined once
in portal/period.rs.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants