Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
22 commits
Select commit Hold shift + click to select a range
70f0867
feat(lore-0311): add four paid usage plans and widen portal IAM
adamkoot Sep 24, 2026
723de4e
feat(lore-0311): report the key's own plan on /api/usage
adamkoot Sep 24, 2026
7696eca
feat(lore-0311): keep the plan across a rework
adamkoot Sep 24, 2026
681c03f
feat(lore-0311): show the key's own plan on the dashboard
adamkoot Sep 24, 2026
f7a5215
docs(lore-0311): cover the five plans and upgrading a user
adamkoot Sep 24, 2026
f0ef449
fix(lore-0311): grant the usage-plan calls on the handler role
adamkoot Sep 24, 2026
e772c6a
fix(lore-0311): keep the plan across a rework and a refused attach
adamkoot Sep 24, 2026
807322d
test(lore-0311): pin that a month offset never shifts the period
adamkoot Sep 24, 2026
395c03b
fix(lore-0311): show no free-plan figure until the plan is known
adamkoot Sep 24, 2026
cfe3fce
docs(lore-0311): guard the plan move and cover recovery and revert
adamkoot Sep 24, 2026
56c9d2c
fix(lore-0311): report the usage of the key revoked last
adamkoot Sep 24, 2026
34c2fae
fix(lore-0311): evict a cached no-plan answer on issue
adamkoot Sep 24, 2026
e8c1fc3
feat(lore-0311): load the portal sources on the first portal request,…
adamkoot Sep 25, 2026
c8b6197
feat(lore-0311): alarm on a failed portal load instead of a cold-star…
adamkoot Sep 25, 2026
dfb3037
feat(lore-0311): retry every portal extension read with jittered backoff
adamkoot Sep 25, 2026
0989f4e
fix(lore-0311): land a failed portal load where each flow renders it
adamkoot Sep 25, 2026
9b7369f
fix(lore-0311): retry only transient reads and cool down after a fail…
adamkoot Sep 25, 2026
a3ae084
fix(lore-0311): wait out the backend's slowest portal answer on usage
adamkoot Sep 25, 2026
5945b7a
docs(lore-0311): record the five-plan herd and capacity test
adamkoot Sep 25, 2026
0e0deb8
ci(lore-0311): pin the three usage-plan grants
adamkoot Sep 25, 2026
380291c
docs(lore-0311): record the 09-25 fix, deploy and live checks
adamkoot Sep 25, 2026
1a8aac9
fix(lore-0311): settle a 409 attach without reading the plan back
adamkoot Sep 25, 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
12 changes: 8 additions & 4 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -82,10 +82,14 @@ jobs:
- 'nx.json'
- '.github/workflows/ci.yml'
- 'tools/scripts/**'
# The portal-closed alarm's metric filter matches a log line this
# file writes (task 0249). The guard that ties the two together
# is `tools/scripts/portal-closed-filter-guard.test.mjs`, run by
# this job — so a PR that only rewords the line must run it.
# The portal-load-failed alarm's metric filter matches a log line
# `portal/sources.rs` writes, through the JSON subscriber
# `main.rs` sets up (tasks 0249, 0311). The guard that ties the
# three together is
# `tools/scripts/portal-load-failed-filter-guard.test.mjs`, run
# by this job — so a PR that only rewords the line, or only
# touches the subscriber, must run it.
- 'packages/prices-api/src/portal/sources.rs'
- 'packages/prices-api/src/main.rs'

typescript:
Expand Down
312 changes: 284 additions & 28 deletions docs/runbooks/manual-api-key-tier.md

Large diffs are not rendered by default.

72 changes: 43 additions & 29 deletions docs/runbooks/portal-oauth-deploy-prep.md
Original file line number Diff line number Diff line change
Expand Up @@ -247,14 +247,19 @@ Both must print `prices/production/portal-discord-oauth`.

`PORTAL_ENABLED` is still `false` at this point and the routes still answer an
empty `404`. That is correct: **the api-handler does not read this secret while
the portal is closed** (see `AppConfig::load_portal_oauth`), so creating it does
not change any behaviour, and forgetting to create it before opening the portal
closes the portal again at the _next_ cold start — `/config` answers
`enabled: false` and the api-handler logs `portal closed at cold start` naming
`PORTAL_OAUTH_SECRET_NAME`, which also pages as
`prices-production-api-handler-portal-closed` (task 0249) — rather than
silently serving a broken sign-in (`AppConfig::load_portal_or_close`). `/v1`
is unaffected either way.
the portal is closed** (see `packages/prices-api/src/portal/sources.rs`), so
creating it does not change any behaviour. Forgetting to create it before
opening the portal fails the portal's load on the first portal request per
execution environment (the `/config` probe triggers it): that `/config` answers
`enabled: false` and the api-handler logs `portal sources failed to load`
naming `PORTAL_OAUTH_SECRET_NAME`, which pages as
`prices-production-api-handler-portal-load-failed` (tasks 0249, 0311) — rather
than silently serving a broken sign-in. A failed load is remembered for a 2 s
cooldown (requests inside it answer the same way without reading anything), and
the first request after it retries, so creating the secret fixes it without a
redeploy. A successful load is kept for the
execution environment's life, so changing the secret's VALUE later still needs
a recycle, as before. `/v1` is unaffected either way.

## 4. Verify locally before opening production

Expand Down Expand Up @@ -429,34 +434,43 @@ openapi:verify-routes` asserts exactly this against the synthesized templates,
so a drift fails CI rather than a deploy — but the _existence_ of the deployed
parameter is not something CI can see.

**If the parameter is missing when `PORTAL_ENABLED` becomes `true`, the
api-handler closes the portal at cold start** — `/config` answers
`enabled: false` and the log carries `portal closed at cold start` naming
`PORTAL_FREE_PLAN_PARAM` — and `/v1` is unaffected. It used to fail init
instead, which took `/v1` down with it (one router serves every route group,
ADR 0008); task 0194's PR review is where that changed, and the reasoning is on
`AppConfig::load_portal_or_close`. The shape is still "found only at the moment
of opening", as with the OAuth secret in §3, and the alternative it avoids is
still a portal with a key button that answers `503` — a closed portal answers
before any button renders. What it costs: the closure pages as
`prices-production-api-handler-portal-closed` (task 0249), but only when a
cold start happens, so the `/config` probe after the deploy stays the check
that runs _now_, not an optional confirmation; the alarm is what catches a
closure in a LATER cold start (a throttled Parameter Store read in a
scale-out).
**If the parameter is missing when `PORTAL_ENABLED` becomes `true`, every load
of the portal's sources fails** — each `/config` answers `enabled: false` and
the log carries `portal sources failed to load` naming `PORTAL_FREE_PLAN_PARAM`
— and `/v1` is unaffected: since task 0311 the sources load on the first
portal request per execution environment, never at cold start. It used to fail
init, which took `/v1` down with it (one router serves every route group, ADR
0008), and then (task 0194) to close the portal in that environment for its
life. Now a failed load costs that one request, and the next retries, so
publishing the parameter fixes it without a redeploy or a recycle. The shape is
still "found only at the moment of opening", as with the OAuth secret in §3,
and the alternative it avoids is still a portal with a key button that answers
`503` — `/config` says the portal is not open before any button renders. Each
failed load pages as `prices-production-api-handler-portal-load-failed` (tasks
0249, 0311), so the alarm keeps firing while the parameter is missing; the
`/config` probe after the deploy stays the check that runs _now_ (it triggers
the load itself), not an optional confirmation, and the alarm is what catches a
LATER failed load (a throttled Parameter Store read under portal traffic).

While the portal is closed the handler reads neither, so nothing here changes
any behaviour until the flag moves.

### The IAM, and the three limits that come with it

CDK grants the api-handler role seven control-plane actions and nothing else:
CDK grants the api-handler role eight control-plane actions and nothing else:
`GET`/`POST` on `/apikeys`, `GET`/`PATCH`/`DELETE` on `/apikeys/*` (`PATCH`
is task 0191's revoke — see below), `POST` on
`/usageplans/{the free plan}/keys`, and — task 0188 — `GET` on
`/usageplans/{the free plan}/usage` (`GetUsage`, the dashboard's usage read).
The last two are declared in `api-gateway-stack.ts` rather than
`compute-stack.ts`, because that is the only stack that knows the plan id.
is task 0191's revoke — see below), and three on `/usageplans` since task
0311: `GET /usageplans` (`GetUsagePlans` by key — which plan a key is on),
`GET /usageplans/*/usage` (`GetUsage` on the key's own plan, the dashboard's
usage read) and `POST /usageplans/*/keys` (attaching a key to any plan — the
free plan on a first issue, the previous key's plan on a rework). All eight
are on the role's own policy in `compute-stack.ts`. The `/usageplans` grants
used to be a standalone policy in `api-gateway-stack.ts`, because they named
the free plan's id; since task 0311 they name no id and moved to the stack
that ships the code using them, which deploys first — so the grant is in place
before the new handler runs (`manual-api-key-tier.md`, "Rolling this out").
No `DELETE` or `PATCH` on a plan or a plan key, and no `GET /usageplans/{id}`:
moving a key between plans is an operator's job (`manual-api-key-tier.md`).

Two of the six cannot be scoped any further, and one can but is not yet. All
three are written out in full in `compute-stack.ts`; the short version:
Expand Down
4 changes: 2 additions & 2 deletions infra/Makefile
Original file line number Diff line number Diff line change
Expand Up @@ -202,8 +202,8 @@ deploy-production-eventbridge: build-production build-lambdas
destroy-production-eventbridge: build-production
npx cdk --app "$(PRODUCTION_APP)" destroy Prices-production-EventBridge --force

# Needs the api-handler log group to exist: the portal-closed metric filter
# (task 0249) is created on `/aws/lambda/prices-production-api-handler`, which
# Needs the api-handler log group to exist: the portal-load-failed metric
# filter (tasks 0249, 0311) is created on `/aws/lambda/prices-production-api-handler`, which
# Compute owns and `destroy-production-compute` deletes (the log removal
# policy is DESTROY). On a fresh environment, or after a Compute destroy,
# deploy Compute first or this target fails with "log group does not exist".
Expand Down
6 changes: 6 additions & 0 deletions infra/envs/production.json
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,12 @@
"pricingApiFreePlanRateLimit": 1,
"pricingApiFreePlanBurstLimit": 5,
"pricingApiFreePlanMonthlyQuota": 100000,
"pricingApiPaidPlans": {
"basic": { "rateLimit": 3, "burstLimit": 15, "monthlyQuota": 1000000 },
"analyst": { "rateLimit": 5, "burstLimit": 25, "monthlyQuota": 5000000 },
"lite": { "rateLimit": 10, "burstLimit": 50, "monthlyQuota": 20000000 },
"pro": { "rateLimit": 25, "burstLimit": 125, "monthlyQuota": 50000000 }
},
"apiGatewayCacheEnabled": true,
"coverageSweepEnabled": true,
"apiBaseUrl": "https://prices-api.sorobanscan.rumblefish.dev",
Expand Down
6 changes: 1 addition & 5 deletions infra/src/lib/app.ts
Original file line number Diff line number Diff line change
Expand Up @@ -39,10 +39,6 @@ export function createApp({ config }: CreateAppOptions): void {
env,
config,
apiHandlerFunction: compute.apiHandlerFunction,
// The role, so ApiGatewayStack can grant the one control-plane action that
// needs the usage-plan id (task 0187). Same direction as the Function
// above, so it adds no new dependency and cannot create a cycle.
apiHandlerRole: compute.apiHandlerRole,
});

// No hosting stack for the portal. `PortalHostingStack` (task 0184) — a
Expand Down Expand Up @@ -70,7 +66,7 @@ export function createApp({ config }: CreateAppOptions): void {
// stack: alarms key on function names, queue names and log-group names
// as plain strings, so it stays deployable on its own. One of its
// resources still needs another stack's resource to EXIST: the
// portal-closed metric filter (task 0249) is created on the api-handler
// portal-load-failed metric filter (tasks 0249, 0311) is created on the api-handler
// log group ComputeStack owns, and `fromLogGroupName` emits no
// dependency for it. `addDependency` orders Compute first under
// `deploy --all` without adding a reference; the `--exclusively` target
Expand Down
Loading
Loading