Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
21 changes: 20 additions & 1 deletion .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -146,14 +146,33 @@ jobs:
steps:
- uses: actions/checkout@v4

# PINNED, and the pin is the whole point — see the cargo-lambda step.
#
# rustc 1.98.0 (2026-08-18) began passing `-Wl,--fix-cortex-a53-843419`
# on aarch64-unknown-linux-gnu. Zig's linker rejects it outright
# ("error: unsupported linker arg"), so every aarch64 link fails and the
# `Build Lambda bootstraps` step dies on the first crate that links —
# `crc-fast`, pulled in by aws-smithy-checksums. Nothing in this repo
# changed: PR #227 passed on 1.97.1 and PR #230 failed on 1.98.0 with the
# identical cargo-lambda 1.9.1 / zig 0.16.0 pair.
#
# cargo-zigbuild filters the arg since v0.23.0 (rust-cross/cargo-zigbuild
# #451, fixed by #452), but cargo-lambda 1.9.1 still vendors 0.20.1, so
# the fix is not reachable by upgrading cargo-lambda today. Unpin BOTH
# lines together once a cargo-lambda release carries zigbuild >= 0.23.
- uses: dtolnay/rust-toolchain@stable
with:
toolchain: 1.97.1
components: clippy, rustfmt

- uses: Swatinem/rust-cache@v2

# Pinned alongside the toolchain above: `pip3 install cargo-lambda`
# resolves to whatever is newest at run time, which is how the vendored
# cargo-zigbuild version — the half of this pair that actually carries
# the fix — could change under a green build without a commit.
- name: Install cargo-lambda
run: pip3 install cargo-lambda
run: pip3 install cargo-lambda==1.9.1

- run: cargo fmt --all -- --check
- run: cargo check --workspace
Expand Down
116 changes: 103 additions & 13 deletions docs/runbooks/portal-oauth-deploy-prep.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,10 +9,12 @@ This is a decision recorded in the ADR, not a convention — the registration li
in one person's Discord Developer Portal account and re-pointing it is a manual
act nobody else can perform.

Introduced by task 0186 (sign-in, identity only). Task 0189 amends **step 2**
when it adds the `guilds.members.read` scope. Task 0187 added **§7**, which is
about key issuance rather than sign-in and needs no manual provisioning — but it
does add a deploy-ordering precondition to §5.
Introduced by task 0186 (sign-in, identity only). Task 0189 amended **§1
step 3** (the `guilds.members.read` scope — a Developer Portal change the
operator must make, see there) and added **§2a** (the two operator-seeded
eligibility parameters). Task 0187 added **§7**, which is about key issuance
rather than sign-in and needs no manual provisioning — but it does add a
deploy-ordering precondition to §5.

## What this covers

Expand Down Expand Up @@ -85,15 +87,17 @@ In the [Discord Developer Portal](https://discord.com/developers/applications):
completing the flow.

3. **Scopes are part of the registration, not just of the authorize URL.**
Task 0186 requests exactly **`identify`**. Do not add `guilds` or `email` —
ADR 0010 rejects both, the first for returning every server a user belongs to
and the second for collecting data we have decided not to hold. The handler
**verifies the granted scope on the token response** and refuses anything
wider, so a registration that drifts fails closed rather than quietly
collecting more.

> Task 0189 adds `guilds.members.read` here **and** in `discord::SCOPE`. Both,
> or the flow refuses its own grant.
The code requests exactly **`identify guilds.members.read`** (task 0189;
`discord::SCOPE`) — `identify` for who the visitor is, `guilds.members.read`
so the issue round-trip can ask whether they are a member of the Stellar
guild with their own consented token. **Declare exactly that pair in the
registration.** Do not add `guilds` or `email` — ADR 0010 rejects both, the
first for returning every server a user belongs to and the second for
collecting data we have decided not to hold. The handler **verifies the
granted scope on the token response** — as a set, order-independent — and
refuses anything wider _or narrower_, so a registration that drifts fails
closed rather than quietly collecting more (or quietly turning every
membership check into a refusal).

**Drift is caught in two places, and they fire at different moments.** If the
registration asks for LESS than the code requests, Discord refuses at the
Expand All @@ -105,6 +109,11 @@ In the [Discord Developer Portal](https://discord.com/developers/applications):
closed and says so — but only the first is visible before a code is ever
issued, so `invalid_scope` in the logs points at this step and nothing else.

> **Consent-screen note (0180 item 5, still to capture):** while making this
> change, screenshot Discord's consent screen once with `identify` alone and
> once with the pair, into the 0189 task's `sources/` — it is free while the
> browser flow is open and awkward to reproduce later.

4. Copy the **Client ID** and reset/copy the **Client Secret**.

## 2. Create the secret
Expand Down Expand Up @@ -144,6 +153,56 @@ breaks no session in progress beyond the ten-minute pending window.
Note that `create-secret` fails if the name exists — that is deliberate, and it
is why CDK does not create it.

## 2a. Seed the eligibility parameters (task 0189)

The eligibility gate reads two knobs from SSM **at runtime, per issuance** —
which Discord guild membership is checked against, and the minimum account age.
Seed both by hand, alongside the mTLS material and the secret above:

```bash
# The guild whose membership gates key issuance. The stellar_test guild while
# building; task 0179 step 4 re-points it at the real Stellar Developers guild
# (897514728459468821) — with `put-parameter --overwrite`, no deploy.
aws ssm put-parameter \
--name /prices/production/discord-guild-id \
--type String \
--value "<guild snowflake>"

# Minimum Discord account age, in whole minutes. 5 matches the Stellar guild's
# own verification_level: 2 ("registered on Discord for longer than 5 minutes")
# — ADR 0010 §3: we do not set a stricter bar than the server whose gate we
# depend on.
aws ssm put-parameter \
--name /prices/production/min-account-age-minutes \
--type String \
--value "5"
```

| | owned by |
| ----------------------------------------------------------------------------------------------- | --------------------------------------- |
| The parameter **names** (`PORTAL_GUILD_ID_PARAM`, `PORTAL_MIN_ACCOUNT_AGE_PARAM` on the Lambda) | CDK — `compute-stack.ts` |
| The IAM grant to read them (api-handler role) | CDK — `PortalReadEligibilityParameters` |
| The parameter **values** | **you**, by hand, out of band |

The same ownership split as the secret, with the same reason sharpened: **CDK
must never create these parameters.** A CloudFormation-managed parameter is
restored to the committed value by the next `cdk deploy`, which after task 0179
would silently un-flip production back to the test guild. CI enforces the rule
(`verify-openapi-routes.mjs` check 7 refuses any `AWS::SSM::Parameter` with
either name in any synthesized template).

**Changing a value needs no redeploy.** The handler resolves both parameters
per issuance through the Parameters and Secrets extension; the extension's
in-process cache (~5 minutes) is the only delay between a `put-parameter
--overwrite` and the running Lambda honouring it.

**A bad seed fails loudly, at the right moment.** With the portal open, cold
start probes both values once: a missing parameter, an empty guild id, or a
threshold that is not a whole number of minutes fails Lambda init (`Init
Errors`) with the parameter named — not a per-visitor refusal. A guild id that
is not a bare snowflake (e.g. a guild _name_) additionally refuses at issuance
as "could not verify", with a warning in CloudWatch naming the value.

## 3. Confirm the wiring

```bash
Expand Down Expand Up @@ -210,6 +269,37 @@ finishing a slice. Before it happens, both must be true:
- **`ApiGatewayStack` has deployed at least once since task 0187 merged**, so
that `/prices/production/pricing-api-free-plan-id` exists. See §7 — if it does
not, opening the portal fails Lambda init and takes `/v1` down with it.
- **Both eligibility parameters are seeded** (§2a) and the Developer Portal
registration carries the scope pair (§1 step 3). A missing parameter fails
Lambda init exactly like a missing plan id; a registration still on
`identify` alone refuses every issue round-trip at the authorize step.

### After the first live issue attempt: check for `pending_absent`

One behaviour the gate depends on is **undocumented and still unmeasured**
(task 0180 item 2): whether Discord's REST member object carries the `pending`
field at all. The code treats an absent `pending` as "could not verify" and
refuses — never as "cleared" — which is the safe direction, but it is safe in a
way that fails for **every** visitor if the field turns out never to be sent.
Nothing about that failure looks different from a Discord outage on the page:
each visitor sees "we could not verify your Discord membership just now".

The one signal that tells the two apart is in the log, so look for it after the
first real issue attempt:

```bash
aws logs filter-log-events \
--log-group-name /aws/lambda/prices-production-api-handler \
--filter-pattern pending_absent \
--start-time "$(($(date +%s) - 3600))000"
```

Nothing found: the field is present and the gate is working as designed. One
line per attempt: the field is absent, **every member is being refused**, and
the fix is the one arm named in `eligibility::decide` — not a parameter, not a
redeploy of anything else. Record the result in task 0189's step 0 table with
its date either way; that is the measurement, taken from production instead of
from a scratch guild.

---

Expand Down
40 changes: 40 additions & 0 deletions infra/src/lib/stacks/compute-stack.ts
Original file line number Diff line number Diff line change
Expand Up @@ -611,6 +611,34 @@ export class ComputeStack extends cdk.Stack {
}),
);

// The eligibility gate's two knobs (task 0189), read at runtime — per
// issuance, not at cold start alone. The same currently-redundant-and-kept
// reasoning as `PortalReadFreePlanIdParameter` above: the baseline's
// `ReadSsmNamespaces` already covers `/prices/${envName}/*`, and this
// statement names the two parameters the gate depends on so a narrowed
// baseline cannot silently break issuance.
//
// **CDK must never CREATE these parameters** — no `ssm.StringParameter`,
// and no `valueForStringParameter` (which freezes the value into the
// template at deploy time, defeating "tunable without a redeploy"). A
// CloudFormation-managed parameter is CDK-owned, so the next `cdk deploy`
// silently restores the committed value — which, after task 0179 points
// production at the real Stellar guild, would un-flip it back to the test
// guild. The operator seeds both values at deploy prep (runbook §2a), the
// same ownership split as the OAuth secret. CI pins the rule:
// `verify-openapi-routes.mjs` check 7 refuses any `AWS::SSM::Parameter`
// with either name in any synthesized template.
this.apiHandlerRole.addToPrincipalPolicy(
new iam.PolicyStatement({
sid: 'PortalReadEligibilityParameters',
actions: ['ssm:GetParameter'],
resources: [
`arn:aws:ssm:${awsRegion}:${accountId}:parameter/prices/${envName}/discord-guild-id`,
`arn:aws:ssm:${awsRegion}:${accountId}:parameter/prices/${envName}/min-account-age-minutes`,
],
}),
);

// The single axum api-handler (ADR 0008). Reads as `prices_reader` over
// mTLS; reuses the same `chDomain` SSM value + secrets extension layer as
// the ledger processor. No `API_KEYS` env → the in-app key gate stays
Expand Down Expand Up @@ -674,6 +702,18 @@ export class ComputeStack extends cdk.Stack {
// Set unconditionally alongside `PORTAL_OAUTH_SECRET_NAME`, and for the
// same reason: opening the portal stays a one-word diff.
PORTAL_FREE_PLAN_PARAM: this.portalFreePlanParameterName,
// The NAMES of the eligibility gate's two SSM parameters (task 0189):
// which Discord guild membership is checked against, and the minimum
// account age in minutes. Names, never values — the handler resolves
// them through the extension **per issuance**, so an operator's
// `aws ssm put-parameter` takes effect without a redeploy (bounded
// only by the extension's ~5 min cache). The values are
// operator-seeded and deliberately NOT CloudFormation resources — see
// the `PortalReadEligibilityParameters` statement above for the
// un-flip-after-0179 hazard that rule prevents. Set unconditionally,
// same one-word-diff reasoning as the two names above.
PORTAL_GUILD_ID_PARAM: `/prices/${envName}/discord-guild-id`,
PORTAL_MIN_ACCOUNT_AGE_PARAM: `/prices/${envName}/min-account-age-minutes`,
// The free plan's per-key rate limit, for the portal dashboard to STATE
// (task 0188) — the same `pricingApiFreePlanRateLimit` ApiGatewayStack
// hands to `addUsagePlan`, so the figure on the page and the figure the
Expand Down
Loading
Loading