Skip to content

feat(sts): let platform IdP tokens act as accounts that trust them - #237

Open
alukach wants to merge 5 commits into
mainfrom
feat/platform-trust
Open

alukach wants to merge 5 commits into
mainfrom
feat/platform-trust

Conversation

@alukach

@alukach alukach commented Sep 25, 2026 •

Copy link
Copy Markdown
Contributor

Important

What I'm changing

A token from a platform issuer, GitHub Actions to begin with, can now be exchanged at /.sts. It acts as the service account its RoleArn names, arn:aws:iam::<owner>--<name>:role/FullAccess, and only if that account trusts the token's issuer and subject (ADR-014). The path runs ahead of the STS route, next to the API-key exchange:

  1. Route by issuer. The token's iss is read unverified. If it is in PLATFORM_ISSUERS, this path takes the token; otherwise the STS route does, unchanged.
  2. Check the request locally. The Role must be one the proxy serves (feat(sts): serve the FullAccess and ReadOnly roles #236), and RoleArn must name an account. That account must be a service account, {owner}--{name} as source.coop's SERVICE_ACCOUNT_ID_REGEX defines it (82 characters at most). Any other account is refused as an untrusting one is, before the token is verified or anything is signed as it.
  3. Verify the token against the issuer's JWKS: signature, issuer, that issuer's own audiences, nbf, and a required exp.
  4. Rate-limit. STS_EXCHANGE_LIMIT, 100 a minute per client address, is feat(sts): exchange opaque API keys at /.sts by hash lookup #235's API-key limit renamed. It now covers every exchange that may cost a Source API call.
  5. Ask the account. POST {SOURCE_API_URL}/api/v1/accounts/{account}/trusts/exchanges with the verified {issuer, subject}, authenticated as the account, which is the contract on source.coop main (feat(accounts): trust subjects per account, the way a role's trust policy does source.coop#566). Per account, issuer and subject, a yes is cached for 60 seconds and a no for 10.
  6. Mint under the named Role, with the account as the principal, never the token's subject. Every refusal of the trust reads AccessDenied: Not authorized to perform sts:AssumeRoleWithWebIdentity (request id …), and the proxy logs the account, issuer and subject at WARN.

Nothing unverified reaches the Source API: a token that fails steps 2 or 3 costs no lookup.

Configuration (#223). PLATFORM_ISSUERS is a JSON object from issuer to the audiences its tokens must carry. The audiences are per issuer, so one issuer's audience never admits another's token (ADR-009). An issuer with no audience is refused, and a value that doesn't parse trusts none. AUTH_ISSUER and AUTH_AUDIENCE are unchanged: they configure the person issuer, whose tokens still act as their own subject and ignore RoleArn's account.

Config Person issuer Platform issuers
production (wrangler.toml) Ory, as before GitHub, audience https://data.source.coop
staging ([env.staging]) staging Ory, as before GitHub, audience https://data.staging.source.coop
previews (wrangler.preview.toml) staging Ory, as before GitHub, audience https://data.staging.source.coop
CI (ci.yml's .dev.vars) https://auth.example.invalid, which mints nothing GitHub, audience source-data-proxy-ci

GitHub's audience is the proxy's origin because source.coop's workflow snippet mints the token with audience: <proxy origin>. A preview's hostname changes per PR, so previews accept staging's origin, as they already accept staging's Ory clients.

Why one PR for #222 and #223. The trust check (#222) is reachable only once a second issuer is configured (#223). The configuration is safe only with the trust check: without it, a GitHub token would act as its own subject under an unlimited Role, the risk ADR-009's important note describes. Neither is useful or safe alone.

#222's issue body is superseded by its later comment, the source-cooperative/source.coop#566 contract. The body asked for an issuer-qualified principal. Under the comment's contract, a platform token's subject never becomes a principal at all: the principal is the account that trusts it. The trust answer is cached per issuer, so two issuers' identical subjects cannot share an entry, which is what #222's "done when" asks.

Decisions to flag

  • Only a service account can be named. The minted principal is the account segment as given, and source.coop resolves a principal as an Ory identity first. An Ory UUID fits the person and organisation handle grammar, so if a trust ever existed on such an account, platform credentials would act as a person. source.coop writes trusts only to service accounts today, so this is defense in depth, and it matches ADR-014. The 000000000000 placeholder from _default's ARN is refused here too.
  • A yes is cached for 60 seconds, a no for 10. cached_fetch caches 200s only, and the route says no with a 403, or a 401 for an account it can't resolve. That refusal is stored next to the positive entry, under its own key, through a cache_put extracted from cached_fetch. So replaying one token from many addresses costs about one lookup per 10 seconds per account, issuer and subject, and a trust just added works within 10 seconds.
  • The body is read as well as the status. A 200 whose body says trusted: false is refused and mints nothing. It would also be cached like any 200, as the keys route's refusals are, so it fails closed if the route ever moved to an always-200 contract like the keys route.
  • A 404 from the trusts route is a 500, not a refusal. The route answers for any account (401 when it doesn't exist), so a 404 means the API doesn't serve the route at all. That is a deployment mismatch, the same for every account, so it reveals nothing about any one account.
  • 401 and 403 read the same to the caller. source.coop answers 401 for an account that doesn't exist, and also when it can't authenticate the proxy. Both are refused like "not trusted" and cached for 10 seconds. The WARN line has the account, issuer and subject, and source.coop's log says which case it was.
  • The proxy vouches for the account before it is established. To ask the question, it signs its usual on-behalf-of assertion as the account the caller names, and sends it only to that account's trusts route. This is ADR-014's design. ADR-005 describes such assertions only for callers the proxy has already authenticated (see Docs and ADRs).
  • exp is required here, and multistore is not bumped. multistore 0.7.2's verify_token checks exp only when present, the exposure ADR-004's warning says must be closed before other issuers are admitted. Upstream closes it in feat(sts)!: fail closed on empty trust fields, check token type, require exp, log successes developmentseed/multistore#146, released as 1.0.0 by chore(main): release 1.0.0 developmentseed/multistore#153, not yet merged. This path needs only that one check (platform::subject). Bumping would also bring multistore main's BucketConfig::backend_type enum (refactor(core): make BucketConfig::backend_type a typed BackendType enum developmentseed/multistore#155) into the registry for nothing this PR needs. The person path keeps 0.7.2's behaviour, which is harmless while Ory always sets exp. Drop the check when the bump happens.
  • One rate limit, renamed. Anyone can mint a GitHub token for this audience in their own workflow, so a replayed or looping token is bounded like a flood of junk keys. feat(sts): exchange opaque API keys at /.sts by hash lookup #235's KEY_EXCHANGE_LIMIT becomes STS_EXCHANGE_LIMIT, one budget of 100 a minute per client address shared by both kinds of exchange; a job exchanges once. It applies after the local checks and verification, and before the trusts lookup. The throttling message now reads "too many exchanges from this address" for both kinds. The namespace ids are unchanged.
  • CI's GitHub token now takes the platform path, as it will in production. The credentialed write tests name the account the stub says trusts this repository. In exchange, the person path loses its real-token coverage in CI, since no Ory token exists there. That path is multistore's STS route plus feat(sts): serve the FullAccess and ReadOnly roles #236's Role lookup, and neither changes here.
  • Refusal messages avoid angle brackets. multistore's STS error body carries the message unescaped. The first local run caught my arn:aws:iam::<account>:role/<role> breaking the XML.

How I did it

  • src/platform.rs (new, wasm-free): parse_issuers, unverified (the header and claims, for routing and the key id), verify (multistore-sts's find_key and verify_token), and subject (requires exp and a non-empty sub).
  • src/sts.rs: account(role_arn), the ARN's account segment, and is_service_account_id, source.coop's grammar without a regex crate.
  • src/source_api/cache.rs: get_or_fetch_trust, through cached_fetch as the account, with the 10-second refusal entry, and cache_put, extracted from cached_fetch.
  • src/lib.rs: platform_exchange and exchange_platform_token, which returns the refusal response itself. with_request_id and throttled are shared with the key exchange, and STS_EXCHANGE_LIMIT replaces KEY_EXCHANGE_LIMIT.
  • src/config.rs: PLATFORM_ISSUERS. Cargo.toml: base64, already in the lockfile through multistore-sts.
  • wrangler.toml (production and staging) and wrangler.preview.toml: PLATFORM_ISSUERS, and the rate-limit binding renamed. The binding keeps the [[ratelimits]] form ci: deploy with wrangler 4 and declare the rate limit as [[ratelimits]] #245 moved it to for wrangler 4, with the same namespace ids. README.md: the variable, the binding, and a "Platform identity providers" section with the GetCallerIdentity caveat.
  • .github/workflows/ci.yml: .dev.vars as in the table above. .github/workflows/staging.yml: the dormant federation smoke test now also needs FEDERATION_TEST_TRUST_ACCOUNT, the staging service account its token acts as. tests/test_writes.py reads it as CI_TRUST_ACCOUNT, falling back to the stub's account.
  • tests/stub_api.py: the trusts route. It says yes only for this repository's GitHub subjects on ci-tests--github-ci, and refuses an assertion made as anyone but the account. It also keeps a per-account lookup counter and records who each product was looked up as.

How to test it

  • cargo test: all suites pass. The new tests/platform.rs covers audiences kept per issuer, an issuer without an audience dropped, unparseable config trusting none, a JWT read unverified, non-JWTs (an API key among them) not read, and a verified token's subject, with a missing exp, a missing sub and an empty sub each refused. tests/sts.rs covers the account segment, and no account for a bare name, an empty account or a truncated ARN. It also covers the service-account grammar: ids accepted up to 82 characters, and refused for handles, an Ory UUID, the placeholder, uppercase, underscores, one-character halves, a triple hyphen, a second separator, edge hyphens and 83 characters.

  • cargo fmt --check, cargo clippy --target wasm32-unknown-unknown -- -D warnings and cargo check --target wasm32-unknown-unknown, through the pre-commit hook.

  • pytest tests/ --ignore=tests/test_contract.py against wrangler dev (wrangler 3.114) and the stub, with CI's new .dev.vars: 40 passed, 18 skipped. These ran without a real token:

    • A GitHub-issuer token whose RoleArn names no account is refused with InvalidParameterValue.
    • A person handle, an Ory UUID or the placeholder named as the account is refused with the untrusted AccessDenied, even for a forged token, and no trusts lookup reaches the stub.
    • A forged GitHub token naming a service account is refused as InvalidIdentityToken, with no trusts lookup: the worker fetched GitHub's real JWKS and found no such key.
  • With a real GitHub token, on ci: run the full CI for the #235 → #236 → #237 stack (do not merge) #239 (CI's integration job at ba94fbc): 54 passed, 4 skipped. That includes the three tests that need the token, and test_writes.py's credentialed tier, which now goes through this path.

    • A trusted workflow's credentials act as the account: the stub records the account, not the GitHub subject, as the product lookup's subject.
    • An account that doesn't trust the workflow refuses it with the request id, and a replay within 10 seconds costs no second lookup.
    • A yes is cached.
  • End to end, after deploy, against staging or this PR's preview, both of which accept staging's audience: give a staging service account a trust for a workflow's subject in source.coop, then run in that workflow:

    permissions: { id-token: write }
    steps:
      - run: |
          curl -sSf -H "Authorization: bearer $ACTIONS_ID_TOKEN_REQUEST_TOKEN" \
            "$ACTIONS_ID_TOKEN_REQUEST_URL&audience=https://data.staging.source.coop" | jq -r .value > "$RUNNER_TEMP/token"
      - env:
          AWS_WEB_IDENTITY_TOKEN_FILE: ${{ runner.temp }}/token
          AWS_ROLE_ARN: arn:aws:iam::<service-account-id>:role/FullAccess
          AWS_ENDPOINT_URL_STS: https://<proxy>/.sts
          AWS_ENDPOINT_URL_S3: https://<proxy>
          AWS_REGION: us-west-2
        run: aws s3 ls s3://<owner>/<product>/

    Then remove the trust, and within a minute the next exchange reads AccessDenied … (request id …).

Docs and ADRs

PR Checklist

Related Issues

Closes #222. Part of #223: this is the proxy side, and #223's "done when", a workflow in an unrelated repository writing with only its ambient token, also needs a deployment and, for the action source.coop hands out, developmentseed/multistore#126. Builds on #235 and #236, both merged. ADRs: #232 (ADR-014). Upstream: developmentseed/multistore#126, developmentseed/multistore#146, developmentseed/multistore#153. Epic: source-cooperative/source.coop#491.

🤖 Generated with Claude Code

https://claude.ai/code/session_01R1eiTse4416N6uTgAy4Ddd

@claude

claude Bot commented Sep 25, 2026 •

Copy link
Copy Markdown
Contributor

Claude finished @alukach's task in 25s —— View job


✅ No blocking issues — safe to merge.

I read the Rust changes (src/platform.rs, src/lib.rs, src/source_api/cache.rs, src/sts.rs). I did not run the tests or read the Python tests, workflows or docs.

  • Ordering: the local checks (Role, account segment, service-account grammar) run before JWKS verification. Verification runs before the rate limit, and the rate limit before the trusts lookup. No unverified token reaches the Source API.
  • Principal: minted credentials use the account, never the token's sub. The service-account grammar keeps an Ory UUID or a handle from becoming the principal.
  • exp: platform::subject requires exp and a non-empty sub, which closes the gap in multistore 0.7.2.
  • Audiences: they are per issuer, and a bad PLATFORM_ISSUERS value fails closed.
  • Cache keys: PATH_SEGMENT percent-encodes everything but -._~, so & and = in a subject can't forge a colliding key. The &refused entry can't collide either.
  • Trust answers: a 200 with trusted: false is refused and its cached entry deleted. Other errors, including a 404, are not cached as refusals.

Non-blocking notes

  • src/source_api/cache.rs:~240: the refused-key lookup and the positive lookup are separate cache reads, so a no costs two edge reads on the first miss. That's fine at this scale.
  • src/lib.rs: the rate limit runs after JWKS verification. A flood of forged tokens therefore costs a JWKS fetch each, though only the first fetch per issuer hits GitHub if jwks_cache() is shared across requests. Worth confirming that cache lives long enough in the worker.

Simplify (ponytail)

  • src/lib.rs PlatformToken: a struct used by one caller and destructured immediately. Pass sts, header, issuer and audiences as arguments, or drop header by reading kid in platform_exchange.
  • src/sts.rs account: collect::<Vec<_>>() for a six-way split. role_arn.splitn(6, ':').nth(4).filter(|a| !a.is_empty()) replaces it. It would also need the arn prefix and field-count checks the current pattern gives, so keep the current form if you want those enforced.
  • src/platform.rs parse_issuers: the logging filter could be a plain retain. The error log is worth keeping, so this is a wash.

💰 Estimated review cost: $0.22 · 0m24s · 9 turns

@github-actions

github-actions Bot commented Sep 25, 2026 •

Copy link
Copy Markdown

🚀 Latest commit deployed to https://source-data-proxy-pr-237.source-coop.workers.dev

  • Date: 2026-10-01T04:17:59Z
  • Commit: 784c3de

alukach added a commit that referenced this pull request Sep 25, 2026
## What I'm changing

`cargo audit` fails on `main`, and so the Security Audit check fails on
every open PR, including the machine-identity PRs (#232, #235). The
cause is a new advisory against rustls 0.23.42,
[RUSTSEC-2026-0285](https://rustsec.org/advisories/RUSTSEC-2026-0285):
TLS 1.3 handshake messages were accepted across encryption-level
boundaries. It is patched in 0.23.45. This bumps the lockfile to it.

## How I did it

`cargo update -p rustls --precise 0.23.45`. A plain `cargo update -p
rustls` stops at 0.23.43; the precise bump moves aws-lc-rs to 1.18.1,
aws-lc-sys to 0.45.0 and rustls-webpki to 0.103.15 with it. `Cargo.lock`
only.

rustls never reaches the Worker. `cargo tree -i rustls --target
wasm32-unknown-unknown` prints nothing; natively it comes in through
multistore → reqwest → hyper-rustls, which the native tests use.
Production was not exposed.

## How to test it

- `cargo audit`: no vulnerabilities, with the one allowed warning
(chacha20) CI already allows.
- The pre-commit hook: `cargo fmt --check`, `cargo clippy --target
wasm32-unknown-unknown -- -D warnings`, `cargo check --target
wasm32-unknown-unknown` and `cargo test`, all passing.

## PR Checklist

- [x] This PR has **no** breaking changes.
- [x] I have updated or added new tests to cover the changes in this PR.
(None apply: lockfile only.)
- [x] This PR does not affect the Source Cooperative Frontend & API.

## Related Issues

Unblocks the Security Audit check on #232, #235 and the PRs stacked on
#235 (#236, #237); their pull-request runs check out the merge with
`main`, so a re-run passes once this lands. #220 would catch the next
one on a schedule. Part of source-cooperative/source.coop#491 only in
that it clears CI for it.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

https://claude.ai/code/session_01R1eiTse4416N6uTgAy4Ddd

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
@alukach
alukach force-pushed the feat/platform-trust branch from ba94fbc to 12d784d Compare September 25, 2026 23:02
@alukach
alukach added this pull request to stack #241 September 29, 2026 20:29
@alukach
alukach force-pushed the feat/platform-trust branch from 12d784d to e1b6959 Compare September 29, 2026 23:26
@alukach
alukach force-pushed the feat/platform-trust branch from e1b6959 to f10d12a Compare September 29, 2026 23:44
Base automatically changed from feat/named-roles to main September 30, 2026 03:39
@alukach
alukach force-pushed the feat/platform-trust branch 2 times, most recently from 4234ceb to 6140cf8 Compare September 30, 2026 03:41
@alukach
alukach marked this pull request as ready for review September 30, 2026 03:41
alukach and others added 3 commits September 29, 2026 20:41
A token from a platform issuer, GitHub Actions to begin with, now acts at `/.sts` as the account its `RoleArn` names (`arn:aws:iam::<account>:role/FullAccess`), and only if that account trusts the token's issuer and subject (ADR-014). The proxy reads the token's issuer unverified to route it, checks the Role and that `RoleArn` names an account, then verifies the token against the issuer's JWKS with that issuer's own audiences and a required `exp`, since multistore checks `exp` only when the claim is present. Only then does it ask `POST /api/v1/accounts/{account}/trusts/exchanges` with the verified issuer and subject, as the account. A yes is cached for 60 seconds per account, issuer and subject. A no, the route's 403, is not cached, so a trust just added works on the next attempt. The credentials' principal is the account, never the token's subject, and every refusal of the trust reads `AccessDenied: Not authorized to perform sts:AssumeRoleWithWebIdentity (request id …)`.

Platform issuers are configured in `PLATFORM_ISSUERS`, a JSON object from issuer to the audiences its tokens must carry, so that one issuer's audience never admits another's token (ADR-009); an issuer with no audience is refused. `AUTH_ISSUER` and `AUTH_AUDIENCE` still configure the person issuer, whose tokens act as their own subject as before. Production trusts GitHub with the proxy's origin as the audience, which is how source.coop's workflow snippet mints the token; staging and previews use staging's origin.

CI now configures GitHub as a platform issuer, as production does, and the stub answers the trusts route: yes for this repository's workflows on one account, no otherwise. The credentialed write tests name that account.

`aws-actions/configure-aws-credentials` still fails after a successful exchange, on `GetCallerIdentity`, until developmentseed/multistore#126 lands.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01R1eiTse4416N6uTgAy4Ddd
ADR-009's migration planned to add platform issuers to `_default` and warned that a CI token would then act as its subject's whole account. ADR-014's trust path replaces that: a platform token acts only as an account that trusts its issuer and subject. ADR-009 gains a note saying so, and its status now reads implemented in part. ADR-004 now notes platform issuers under its trust model, and its `exp` warning says platform tokens must carry the claim, which the proxy checks itself. ADR-001's `source_identity` now says it is the account an API key or a trusted platform token names, never a platform token's subject.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01R1eiTse4416N6uTgAy4Ddd
`get_or_fetch_trust` said only a yes is cached. `cached_fetch` caches every 200, so a `200 {"trusted": false}`, which the route does not send today, would be cached too, and still refused. The comments now say that the route's yes is a 200 cached like any other and its no is a 403 that never is.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01R1eiTse4416N6uTgAy4Ddd
alukach added a commit that referenced this pull request Sep 30, 2026
## What I'm changing

ADR-013 now specifies an API key as `sck_`, 30 random base62 characters
and a six-character checksum: a fixed 40 characters matching
`^sck_[0-9A-Za-z]{36}$`, in place of `sck_` and 43 base64url characters
with no checksum. The checksum is the CRC-32 of the 30 random characters
(IEEE, as zlib computes it), written in base62 with the digits
`0-9A-Za-z`, most significant first, padded with `0` to six. This is
GitHub's own token layout.

## Why

GitHub's [secret-scanning partner
program](https://docs.github.com/en/code-security/tutorials/secret-scanning-partner-program#identify-your-secrets-and-create-regular-expressions)
recommends three things for a secret format: a unique prefix, high
entropy, and a 32-bit checksum. The ADR already had the first two. The
checksum lets a scanner, the proxy or the CLI tell a real key from a
look-alike, a truncated key or a mistyped one without asking
source.coop, and so gives a mistyped key a refusal of its own. It adds
no security, since anyone can compute it; the ADR says so. Base62 keeps
`-` out of the key, so a double-click selects all of it: about half of
the base64url keys contained one.

The random part is 178 bits, down from 256; the ADR's "no salt or KDF"
and "enumeration is infeasible" arguments still hold at that size, and
the text now says 178.

The proxy's step 2 gains the checksum check and the distinct refusal,
"API key is malformed; check that it was copied whole", which reveals
nothing because the format is public.

ADR-013 is revised in place, as it was on 2026-09-25, because nothing
implementing it has shipped: source.coop issues keys since
source-cooperative/source.coop#570 merged, but no deployed proxy can
exchange one until #235 lands.

## Implementing PRs

- #235 checks the checksum at `/.sts`
(commits d6745e0 and 30ad22f); #236 and #237 are rebased onto it.
- source-cooperative/source.coop#596 generates keys in the new format
and shows the checksum as a key's hint;
source-cooperative/source.coop#581, stacked on it, validates leaked keys
by checksum. What to send GitHub, and the equivalent steps for other
scanners, are logged on source-cooperative/source.coop#561.
- source-cooperative/source-coop-cli#20 checks a key file's format and
checksum before exchanging it.

## Docs and ADRs

Only ADR-013 states the key format; I checked the other ADRs with `git
grep sck_` and none mention it. ADR-014's amendment of ADR-013 is about
ownership, not format, and still holds.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

https://claude.ai/code/session_01R1eiTse4416N6uTgAy4Ddd

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
A platform token's `RoleArn` must now name a service account, `{owner}--{name}` as source.coop's `SERVICE_ACCOUNT_ID_REGEX` defines it (82 characters at most). Anything else is refused as an untrusting account is, before the token is verified and before the proxy signs anything as it. The minted principal is that segment as given, and source.coop resolves a principal as an Ory identity first; an Ory id fits the person and organisation handle grammar, so without this, a trust on such an account would let platform credentials act as a person. source.coop writes trusts only to service accounts today, and ADR-014 makes trusts a service account's, so this is defense in depth.

Anyone can mint a GitHub token for the proxy's audience in their own workflow, and a refusal was not cached, so replaying one valid token cost a trusts lookup per request. Two bounds follow. The per-address rate limit from the API-key exchange now also covers platform exchanges, after everything local and before the trusts lookup; the binding is renamed `STS_EXCHANGE_LIMIT`, since it now bounds every exchange that costs a Source API call, and stays at 100 a minute. And a refusal from the trusts route is cached for 10 seconds under its own key, so a replay flood from many addresses costs about one lookup per 10 seconds per account, issuer and subject, while a trust just added still works within seconds.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01R1eiTse4416N6uTgAy4Ddd
@alukach

alukach commented Sep 30, 2026

Copy link
Copy Markdown
Contributor Author

Rebased onto main after #235, #236 and #245 merged, and force-pushed: the branch is now this PR's four commits. One conflict, in wrangler.toml, wrangler.preview.toml and README.md: #245 moved the rate-limit binding from [[unsafe.bindings]] to wrangler 4's [[ratelimits]], while this PR renames it KEY_EXCHANGE_LIMIT → STS_EXCHANGE_LIMIT. Resolved as [[ratelimits]] with the new name and the same namespace ids; nothing references the old name. cargo test passes locally and all CI passes on the new head, including the preview deploy and integration tests. Description updated.

Hold a 200 trusted:false answer for REFUSED_TRUST_CACHE_SECS, as a 403
is, instead of the 60s positive TTL. Bundle the unverified token's parts
into PlatformToken so exchange_platform_token no longer needs
clippy::too_many_arguments.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

This branch was successfully deployed

1 active deployment
preview — 89ae5b11 Deployed Oct 1, 2026 by alukach via Deploy & Test / Deploy #398
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Namespace the credential subject by verified issuer

1 participant