Skip to content

feat(scopes): publish a machine-readable scope catalogue (#615) - #628

Merged
robertocarlous merged 2 commits into
Neurowealth:mainfrom
johnsmccain:feat/scope-catalog-api
Oct 19, 2026
Merged

robertocarlous merged 2 commits into
Neurowealth:mainfrom
johnsmccain:feat/scope-catalog-api

Conversation

@johnsmccain

@johnsmccain johnsmccain commented Oct 19, 2026 •

Copy link
Copy Markdown
Contributor

Closes #615
Closes #616
Closes #614
Closes #617

Context

The valid scopes were two bare string arrays (USER_SCOPES, ADMIN_SCOPES) and the meaning of each scope lived in a comment or a doc line. That left three gaps:

  • A requireScope('...') / requireAdminScope('...') call site naming a scope that was in neither list was a runtime 403 on a live route rather than a build failure.
  • A key-creation UI could not show an operator what a scope actually unlocks, or how much damage it could do.
  • Nothing connected a scope to the endpoints it gated, so the scope tables in docs/API_KEYS.md were hand-maintained and had already drifted — they were missing portfolio:write, loans:read and loans:write entirely.

Proposal

  • Export a machine-readable scope catalogue: name, description, risk level, enforcement status, implied scopes, guardrail notes, and the endpoints covered.
  • Admin API GET /api/admin/keys/scopes for the key-creation UX.
  • A CI check that every requireScope / requireAdminScope call site references a catalogue entry — no ad-hoc strings.
  • Docs listing every scope with its description.

What is in the PR

File What it does
src/auth/scopeCatalog.ts The shared entry shape — description, risk level, enforcement status, implied scopes, guardrail notes — and the Markdown renderer the doc is generated from
src/auth/scopes.ts The user key catalogue, typed as a total map over USER_SCOPES so a new scope cannot be added without describing it
src/middleware/adminAuth.ts The admin key catalogue, same shape
src/auth/scopeCoverage.ts Derives the endpoints each scope covers by reading the scope tag off each route's middleware chain, so "endpoints covered" cannot drift from the route table
src/routes/admin.ts GET /api/admin/keys/scopes — serves the catalogue for the key-creation UI
docs/API_KEYS.md The generated scope tables, the enforcement model, and the endpoint's contract
tests/unit/auth/scopeCatalog.test.ts The CI half — see below

The enforcement model

The catalogue says whether a scope actually gates anything, because a scope that gates nothing is a promise the platform does not keep:

Enforcement Meaning
enforced A route requires it. Granting it is meaningful; omitting it is restrictive.
declared-only Declared and reservable, but no route requires it today — granting it currently grants nothing extra.

super and agent also carry an implies list, and hasAdminScope is now driven by that same ADMIN_GRANTOR_SCOPES data — so the runtime behaviour and the published catalogue cannot drift apart. The check verifies the catalogue's implies list against hasAdminScope directly.

Five admin scopes turn out to be declared-only (wallet, erasure:read, risk-metadata:read, risk-metadata:write, admin:impersonate): they are declared and reservable, but no route requires them today. The catalogue says so explicitly rather than implying they restrict something.

A hole this surfaced, and closes

portfolio:read, transactions:read and vault:read were declared and granted to new keys by default, but no route required them. The portfolio, transaction and vault read endpoints used requireAuth alone — and requireAuth accepts API keys.

So a key scoped to nothing but alerts:manage could read the user's entire portfolio, transaction history, tax report and vault balance.

This PR enforces them:

  • GET /api/v1/portfolio/:userId and its suggestions / history / earnings / tax-report variants → portfolio:read
  • GET /api/v1/transactions/:userId, /detail/:txHash, /:id/events → transactions:read
  • GET /api/v1/vault/balance → vault:read

This is a behaviour change. Sessions are unaffected (authScopes is * for a session). An existing API key created without the read scopes will now get a 403 insufficient_scope on those endpoints. New keys are unaffected — the default read set is unchanged, and the key-creation UI should offer the read scopes alongside the write ones.

Acceptance criteria

  • Catalog exported — machine-readable, with name, description, risk level and the endpoints covered
  • Admin API GET /api/admin/keys/scopes for the key-creation UX
  • Every requireScope / requireAdminScope call site references a catalogue entry — enforced in CI, no ad-hoc strings
  • Docs list all scopes with descriptions — generated tables in docs/API_KEYS.md

Testing

  • New: 16 tests in tests/unit/auth/scopeCatalog.test.ts, plus 7 behavioural cases in tests/unit/middleware/apiKeyAuth.test.ts covering the newly-enforced read scopes
  • tsc --noEmit and eslint clean
  • The 9 pre-existing unit-suite failures on main are unchanged by this PR (baseline on main: 9 failed suites / 64 failed tests; on this branch: 9 failed / 57 failed, and 2618 passing vs 2564)

…urowealth#614)

The limits lived only in code: every limiter was constructed inline and
mounted by hand, so "what is the budget on POST /api/v1/deposit, and does
it apply to a session or an API key?" had no answer that was not an
archaeology exercise across three files — and no check that the answer
stayed true.

This adds the missing single source of truth and makes CI enforce it.

- src/middleware/rateLimitTiers.ts — the tier catalogue (window, burst,
  principal, auth types, env vars) and the route → tier matrix, plus the
  resolver and the Markdown renderers the doc is generated from.
- src/middleware/rateLimiter.ts — every limiter now carries the tier it
  enforces, and buildRateLimiter requires one, so a limiter the matrix
  cannot describe is a compile error rather than a silent gap.
- src/routes/registry.ts — the route table, extracted from src/index.ts so
  the check walks the routes the server actually serves instead of a
  hand-written list.
- src/middleware/rateLimiterChain.ts — the app-wide global + tiered
  chain, named so the check can assert that no route is ever unlimited.
- docs/RATE_LIMITING.md — the generated matrix, the auth-type mapping and
  the fail-open/fail-closed decision.
- tests/unit/middleware/rateLimitTierMatrix.test.ts — the CI half: an
  unmapped route, a matrix that disagrees with the mounted limiter, a
  matrix entry that no longer matches a real route, an undescribed
  limiter, or a stale doc each fail the build.

The fail-open / fail-closed split is deliberate and now stated in one
place. At runtime an unmapped route still gets the global + tiered
budgets, so it is never unlimited; in CI a gap is a hard failure, because
the matrix is the contract and the contract is enforced.

Fixes the erasure limiter's metrics label, which read "admin" and so was
indistinguishable from the general admin budget it is deliberately held
far below.

Closes Neurowealth#614
…#615)

The valid scopes were two bare string arrays and the meaning of each scope
lived in a comment or a doc line. That left three gaps:

- a `requireScope('...')` / `requireAdminScope('...')` call site naming a
  scope that was not in either list was a runtime 403 on a live route
  rather than a build failure;
- a key-creation UI could not show an operator what a scope actually
  unlocks, or how much damage it could do;
- nothing connected a scope to the endpoints it gated, so the scope tables
  in docs/API_KEYS.md were hand-maintained and had already drifted (they
  were missing `portfolio:write`, `loans:read` and `loans:write`
  entirely).

This adds the catalogue and makes CI enforce it.

- src/auth/scopeCatalog.ts — the shared entry shape: description, risk
  level, enforcement status, implied scopes, guardrail notes, and the
  Markdown renderer the doc is generated from.
- src/auth/scopes.ts — the user key catalogue. Typed as a total map over
  USER_SCOPES, so a new scope cannot be added without describing it.
- src/middleware/adminAuth.ts — the admin key catalogue, same shape.
- src/auth/scopeCoverage.ts — derives the endpoints each scope covers by
  reading the scope tag off each route's middleware chain, so the
  "endpoints covered" list cannot drift from the route table.
- GET /api/admin/keys/scopes — serves the catalogue for the key-creation
  UI, with the endpoints each scope actually unlocks.
- docs/API_KEYS.md — the generated scope tables, the enforcement model and
  the endpoint's contract.
- tests/unit/auth/scopeCatalog.test.ts — the CI half.

## The enforcement model

The catalogue says whether a scope actually gates anything, because a scope
that gates nothing is a promise the platform does not keep:

  enforced      — a route requires it. Granting it is meaningful.
  declared-only — declared and reservable, but no route requires it today.

`super` and `agent` also carry an `implies` list, and `hasAdminScope` is
now driven by that same data, so the runtime behaviour and the published
catalogue cannot drift apart. The check verifies the catalogue's `implies`
list against `hasAdminScope` directly.

## A hole this surfaced, and closes

`portfolio:read`, `transactions:read` and `vault:read` were declared and
granted to new keys by default, but NO route required them. The portfolio,
transaction and vault read endpoints used `requireAuth` alone, and
`requireAuth` accepts API keys — so a key scoped to nothing but
`alerts:manage` could read the user's entire portfolio, transaction
history, tax report and vault balance.

This PR enforces them. Sessions are unaffected (`authScopes` is `*` for a
session); only API keys that lack the read scope are now refused, with the
usual 403 `insufficient_scope`.

**This is a behaviour change.** An existing API key that was created
without the read scopes will lose read access to those endpoints. New keys
are unaffected — the default read set is unchanged and the key-creation UI
should offer the read scopes alongside the write ones.

## Acceptance criteria

- [x] Catalog exported — machine-readable, with name, description, risk level and the endpoints covered
- [x] Admin API `GET /api/admin/keys/scopes` for the key-creation UX
- [x] Every `requireScope` / `requireAdminScope` call site references a catalog entry — enforced in CI
- [x] Docs list all scopes with descriptions — generated tables in docs/API_KEYS.md

Closes Neurowealth#615
@drips-wave

drips-wave Bot commented Oct 19, 2026

Copy link
Copy Markdown

@johnsmccain Great news! 🎉 Based on an automated assessment of this PR, the linked Wave issue(s) no longer count against your application limits.

You can now already apply to more issues while waiting for a review of this PR. Keep up the great work! 🚀

Learn more about application limits

@robertocarlous
robertocarlous merged commit 14d2655 into Neurowealth:main Oct 19, 2026
3 of 7 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

2 participants