Skip to content

feat: Management API v2, /api/meta, v1 deprecation and the locked-down entity proxy (ADR-052, ADR-053) - #671

Draft
seanspeaks wants to merge 13 commits into
nextfrom
feat/management-api-v2
Draft

seanspeaks wants to merge 13 commits into
nextfrom
feat/management-api-v2

Conversation

@seanspeaks

@seanspeaks seanspeaks commented Oct 3, 2026 •

Copy link
Copy Markdown
Contributor

Summary

Management API v2 for 2.0.1, built to the accepted versioning model (ADR-053) and the locked-down entity proxy (ADR-052), both in #668.

  • One route registry (packages/core/management-api/route-registry.js) drives the Express routers, the OpenAPI documents and the API Gateway routes devtools generates. Gateway routes are per resource (/api/v2/{integrations,entities,credentials,authorize}); there is no ANY /api/v2/{proxy+}, and the reports segment is reserved so /api/v2/reports (ADR-010) is untouched.
  • GET /api/meta (no auth, no database, on the health Lambda): supported majors with status and OpenAPI links, capability flags (credentials, multiStepAuthorize, entityProxy only when enabled), core version for admin callers only. OpenAPI per major at /api/meta/openapi/v{1,2}.json.
  • v1 is frozen: same handlers and bodies, plus Deprecation (RFC 9745) and Link: rel="deprecation" headers, a frigg.api.deprecated_route log record and a frigg.api.v1.requests counter per call. managementApi.v1: false drops v1 from the router (410) and from the gateway routes; the OAuth redirect keeps working.
  • v2: integrations (list returns { integrations } only), entities, entity types and requirements, credentials (always masked), multi-step authorize with an AuthorizationSession model (Postgres, MongoDB, DocumentDB; migration 20261003000000_add_authorization_session; stepData encrypted at rest), credential re-authorization, and the entity proxy. Every v2 route authenticates. Missing and foreign resources are the same 404. Errors are { error: { code, message, details? } }; responses carry Frigg-API-Version: 2.
  • Entity proxy (beta, off by default): Requester._proxyRequest is the one path-checked entry point, so refresh, credential invalidation, timeouts, telemetry and redaction apply for OAuth2, API-key and basic modules. Path-only input, same-origin/under-base checks, manual redirects (same API only, 3 hops, re-checked), deny-by-default module allow-lists with an app override, header allow-list, size and time limits, status mapping per ADR-052 §10, an audit record and a usage counter per call.
  • Error boundary: a body-parser 400/413 is no longer turned into a 500 (only errors that mark a 4xx safe to expose keep their status; an upstream FetchError.statusCode is still a 500).
  • @friggframework/ui: new API(baseUrl, jwt, { apiVersion: 'v1' | 'v2' }), default 'v1'. v2 mode checks /api/meta first and throws FriggApiVersionError ("needs @friggframework/core >= 2.0.1"); component-facing methods keep their v1 return shapes; v2-only methods for the routes that exist.
  • Docs: docs/reference/api-reference.md (versions, v2 endpoints, multi-step authorize, proxy, v1 → v2 migration table), the frigg-management-api and frigg-user-actions skills (v2 first), CLAUDE.md.

The old work on claude/split-522-03-core was used as a reference only (it is ~458 commits behind and several pieces did not run: api.request() does not exist, the router called its own use cases with the wrong signatures, re-authorize called a non-existent repository method). Use cases and tests here are new.

Merge dependencies

Checklist

  • Route registry + GET /api/meta
  • v1 deprecation headers, usage log/counter, managementApi.v1: false
  • Error boundary: v2 error shape; body-parser 4xx kept
  • v2 integrations, entities, entity types
  • v2 credentials (masked), repositories findCredentialsByUserId for all three databases
  • Multi-step authorize + AuthorizationSession (model, 3 repositories, migration, encryption) + credential re-authorization
  • Entity proxy per ADR-052
  • OpenAPI v1/v2 + contract test (registry == router == OpenAPI) + schema parity test
  • Gateway routes from the registry (devtools)
  • ui adapter apiVersion
  • Docs and skills
  • Not done: per-component apiVersion prop in the ui React components (they construct API with the default 'v1'); ui 2.x switching to v2 by default
  • Not done: Scalar UI at /api/meta/docs, CI oasdiff breaking gate (ADR-053 §5, deferrable per the ADR)
  • Not done: Sunset header (no removal release scheduled yet; supported once sunset is set in the registry)
  • Not done: POST /api/v2/entities and GET /api/v2/entities/options/:credentialId (in the ADR-053 sketch table; left out because v1's versions of both are broken and authorize already creates entities)

Test plan

  • packages/core: new suites for registry, meta, config, deprecation, error format, v2 router (fake dependencies), every new use case, repositories (stubbed Prisma / DocumentDB commands), proxy path/policy/requester/use case, OpenAPI contract and schema parity. Full core suite: same 16 failing suites / 64 failing tests as the base branch (pre-existing: AWS credentials, embedded Mongo, encryption fixtures), +326 passing tests.
  • packages/schemas: 141 passing.
  • packages/devtools: base-definition-factory and gateway route tests pass. The full suite fails the same suites as the base branch; the failing-test count is load-dependent on both (base 133 and 171 in two runs, this branch 171 and 104).
  • packages/ui: npm test (node --test), 12 passing.
  • End to end against real databases (embedded PostgreSQL 18 after prisma migrate deploy, and a MongoDB 7 replica set after prisma db push), with AES field encryption on and an HTTPS fake upstream: meta and OpenAPI, auth, entity types, single-step authorize, multi-step authorize (wrong code, foreign user, skipped step, reuse after completion, stepData not readable in the raw row), masked credentials, ownership 404s, integration create/action/delete, v1 deprecation header, proxy allow/deny/SSRF/header refusal/ownership/same-origin redirect followed/cross-host redirect not followed/429 with Retry-After/5xx → 502, delete ordering (409 while in use). All steps pass on both databases. prisma migrate diff shows no drift between the migration and the schema.

Open questions

  1. deprecatedAt is 2026-11-01, taken from the ADR example. Confirm the date 2.0.1 ships.
  2. /user/* is listed as v1 in ADR-053 but has no v2 replacement, so it gets no deprecation headers here and managementApi.v1: false leaves it alone. Should it?
  3. v1 GET /api/entities/:entityId (and test-auth, options, options/refresh) call Module.getEntityModelFromDefinition, which does not exist, so they 500 on next. v1 is frozen, so this PR does not fix them; v2 does not reuse those use cases. Fix in v1 or leave?
  4. Proxy query arrays are sent comma-separated (the Requester sends one value per key). Repeated keys would need a Requester change that affects existing modules.
  5. The proxy refuses (400) a request that sends a credential/Host/forwarding header rather than dropping it silently, so misuse is visible. ADR-052 §6 can be read either way.

🤖 Generated with Claude Code

📦 Published PR as canary version: 2.0.0--canary.671.e50fe9c.0

✨ Test out this PR locally via:

npm install @friggframework/admin-scripts@2.0.0--canary.671.e50fe9c.0
npm install @friggframework/core@2.0.0--canary.671.e50fe9c.0
npm install @friggframework/devtools@2.0.0--canary.671.e50fe9c.0
npm install @friggframework/eslint-config@2.0.0--canary.671.e50fe9c.0
npm install @friggframework/prettier-config@2.0.0--canary.671.e50fe9c.0
npm install @friggframework/schemas@2.0.0--canary.671.e50fe9c.0
npm install @friggframework/serverless-plugin@2.0.0--canary.671.e50fe9c.0
npm install @friggframework/test@2.0.0--canary.671.e50fe9c.0
npm install @friggframework/ui@2.0.0--canary.671.e50fe9c.0
# or 
yarn add @friggframework/admin-scripts@2.0.0--canary.671.e50fe9c.0
yarn add @friggframework/core@2.0.0--canary.671.e50fe9c.0
yarn add @friggframework/devtools@2.0.0--canary.671.e50fe9c.0
yarn add @friggframework/eslint-config@2.0.0--canary.671.e50fe9c.0
yarn add @friggframework/prettier-config@2.0.0--canary.671.e50fe9c.0
yarn add @friggframework/schemas@2.0.0--canary.671.e50fe9c.0
yarn add @friggframework/serverless-plugin@2.0.0--canary.671.e50fe9c.0
yarn add @friggframework/test@2.0.0--canary.671.e50fe9c.0
yarn add @friggframework/ui@2.0.0--canary.671.e50fe9c.0

seanspeaks and others added 12 commits October 3, 2026 15:00
… router

The auth Lambda serves core's integration router, which registers
POST /api/entity, GET /api/entity/options/:credentialId and the
/api/entities/:entityId family (get, test-auth, options, options/refresh).
The generated HTTP API only declared /api/integrations,
/api/integrations/{proxy+} and /api/authorize, and HTTP APIs have no
implicit catch-all, so every entity route 404'd at the gateway in a
deployed app (and under serverless-offline) before reaching Express.

Add /api/entity, /api/entity/{proxy+}, /api/entities and
/api/entities/{proxy+} (ANY) to the auth function, and add a test that
reads the routes off a live createIntegrationRouter(), composes a full
app definition, and asserts each one selects an API Gateway route on the
auth function, so a route added to core later fails until the gateway
is updated too. It also pins the neighbouring report, admin-script,
webhook, extension and integration-defined routes to their own
functions.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Adds packages/core/management-api with the single route registry that the
Express routers, the OpenAPI documents and the devtools API Gateway routes
will all read:

- V1_ROUTES mirrors the frozen unprefixed routes of createIntegrationRouter.
- V2_ROUTES declares every /api/v2 route with an operationId, tag,
  capability and stability (the entity proxy is beta and only listed when
  the app enables it, ADR-052).
- META_ROUTES are version-neutral and served by the DB-free health Lambda.
- The reports segment under /api/v2 is reserved for ADR-010's report
  router and never assigned.
- getGatewayRoutes() derives per-resource HTTP API routes and never emits
  ANY /api/v2/{proxy+}.

GET /api/meta answers without auth or database access: the supported
majors with status and OpenAPI links, the capability flags derived from
the mounted routes, and the core version only for callers with the admin
API key. It is mounted on the health router ahead of the health key check.

resolveManagementApiConfig() reads the new managementApi app-definition
key (v1 switch, proxy enable, ADR-052 limits and per-module overrides).
loadAppDefinition() returns it and app-definition.schema.json accepts it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…ntApi.v1 switch

The auth Lambda now serves createManagementApiRouter(): the v2 router under
/api/v2 and the frozen v1 router at the unprefixed paths (ADR-053 §6).

- Every v1 response carries `Deprecation: @<epoch>` (RFC 9745) and
  `Link: <migration guide>; rel="deprecation"`. Bodies and statuses are
  unchanged.
- Each v1 call writes one frigg.api.deprecated_route record (route template,
  method, Frigg-Client header; never the concrete path) and increments the
  frigg.api.v1.requests counter.
- managementApi.v1: false never builds the v1 handlers and answers the v1
  paths with 410 API_VERSION_DISABLED plus the migration link. The OAuth
  redirect route is unversioned and keeps working either way.
- Unknown /api/v2 paths answer a v2 404, and every /api/v2 response has
  Frigg-API-Version: 2.

The HTTP error boundary no longer turns an exposed client error into a 500:
a body-parser 400/413 stays a 400/413. Errors that carry a status without
marking it safe (an upstream FetchError's statusCode) are still 500s. On
/api/v2 and /api/meta the boundary answers { error: { code, message,
details? } }; a 5xx hides its message unless the error marks it safe. v1
keeps its { error: message } body.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
createManagementApiV2Router() mounts the registry's v2 routes in registry
order and runs the handler for each operationId after AuthenticateUser
(bearer token, x-frigg headers or adopter JWT). Every v2 route
authenticates, integration options included. Handlers only call use cases;
dependencies are built in v2/dependencies.js or injected for tests.

Integrations (/api/v2/integrations): list returns { integrations } only,
options, create, get, update, delete (204), config options and refresh,
user actions, action options and refresh, run action, test-auth. New use
cases in integrations/use-cases/integration-operations-for-user.js put an
ownership check (missing or foreign = 404 INTEGRATION_NOT_FOUND) in front of
the existing ones and pass the record owner on. Creating an integration now
checks that the caller owns every entity and that config.type is a known
integration. Running an action only accepts action ids the integration
lists as user actions, so callers cannot fire internal events.

Entities (/api/v2/entities): list, get, delete (409 ENTITY_IN_USE while
integrations use it), test-auth, options and options refresh, and entity
types: /types, /types/:entityType, /types/:entityType/requirements. The
entity DTO carries the credential id and auth state, never credential
fields. GetEntityModuleForUser is the one ownership check (User.ownsUserId,
so organisation users work) and builds the module from the persisted
entity. The v1 entity use cases call Module.getEntityModelFromDefinition,
which does not exist, so v2 does not reuse them.

Entity types describe a module from its definition only (display name,
auth type, multi-step step count). GetAuthorizationRequirements returns
{ type, data, step, totalSteps, isMultiStep } for single-step and
multi-step modules (getAuthStepCount / getAuthRequirementsForStep).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
GET /api/v2/credentials, GET /api/v2/credentials/:credentialId and
DELETE /api/v2/credentials/:credentialId.

- Repositories gain findCredentialsByUserId() for PostgreSQL, MongoDB and
  DocumentDB (decrypting each document), newest first. Stored data fields
  cannot override the id, userId or auth state of the record.
- A credential's type and the entities using it come from the caller's
  entities. Missing or foreign credentials are 404 CREDENTIAL_NOT_FOUND.
- Responses mask every provider field: strings keep at most their last
  four characters (only when 16+ long), objects are hidden whole, numbers
  and booleans (expiry times, flags) pass through. Field names stay
  visible.
- Deleting a credential first unsets it on the entities that used it, so
  they show as needing re-authorization instead of vanishing.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…agement API v2)

GET/POST /api/v2/authorize and GET/POST
/api/v2/credentials/:credentialId/reauthorize.

- AuthorizationSession domain object: unguessable id, bound to the user,
  the entity type and (for re-authorization) the credential; expires after
  AUTH_SESSION_EXPIRY_MINUTES (default 15). Steps advance one at a time;
  step 1 may restart a flow.
- Repositories: one Prisma adapter for PostgreSQL and MongoDB, a raw-command
  adapter for DocumentDB, and a factory on DB_TYPE. Lookups never return an
  expired session; expired rows are removed opportunistically when a new
  session starts.
- Prisma model AuthorizationSession in both schemas and the PostgreSQL
  migration 20261003000000_add_authorization_session.
- stepData is encrypted at rest: AuthorizationSession.stepData joins the
  core encryption schema (Prisma extension; DocumentDBEncryptionService for
  DocumentDB).
- Single-step modules complete at once through ProcessAuthorizationCallback
  (the v1 authorize logic). Multi-step modules use the documented
  getAuthStepCount / getAuthRequirementsForStep / processAuthorizationStep
  contract; the final authData goes through the same callback.
- POST answers { status: 'complete', entity, credential } (credential
  masked) or { status: 'pending', step, totalSteps, sessionId, requirements,
  message? }. A session belonging to someone else, another entity type or
  another credential is 404 AUTHORIZATION_SESSION_NOT_FOUND; a skipped step
  is 409 STEP_OUT_OF_ORDER; step 2+ without a session is 400
  SESSION_REQUIRED. Module step errors are 400 AUTHORIZATION_FAILED with the
  module's message; provider HTTP failures are 502 UPSTREAM_ERROR.
- Re-authorizing a credential runs the flow for its entity type and reports
  previousCredentialId when the provider account changed.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…/proxy (ADR-052)

Off by default: the route is mounted, and the entityProxy capability
advertised, only with managementApi.proxy.enable. It is marked beta.

Requester._proxyRequest is the one generic, path-checked entry point
(requester/proxy-request.js). It runs through the requester's own
_request, so auth headers, single-flight token refresh, credential
invalidation, the per-attempt timeout, telemetry and log redaction apply
unchanged, for OAuth2, API-key and basic-auth modules alike. It adds:

- the URL is always the module base URL (declared proxy.baseUrl, else the
  Api's baseUrl/baseURL built from the persisted credential) plus a path;
- path rules: single leading /, no //, \, ?, #, dot segments, encoded
  separators, double encoding, control or whitespace characters; after the
  join the URL must be https, same origin, and under the base path;
- no backoff retries; upstream errors come back instead of throwing;
- manual redirects: same origin and under the base path, at most three
  hops, each re-checked against the allow-list; anything else is returned
  with its Location removed;
- one deadline for the whole call and a response size cap.

_rawRequest gains three opt-in options for this (timeoutMs, noRetry,
returnErrorResponses); existing callers are unaffected.

ExecuteEntityProxyRequest enforces the rest: the caller must own the entity
(404 otherwise); the module's proxy.allow list is deny-by-default (403
PROXY_NOT_ALLOWED), a module cannot grant itself a wildcard, and the app
definition may replace a module's list (managementApi.proxy.modules); the
entity needs a usable credential (401 INVALID_CREDENTIALS); request
headers are an allow-list and credential, Host and forwarding headers are
refused; response credential and hop-by-hop headers are stripped; request
and response size limits (413, 502 RESPONSE_TOO_LARGE); binary bodies only
when the module allows them (415 otherwise). Upstream 4xx are relayed with
their status, 429 keeps Retry-After, 5xx become 502 UPSTREAM_ERROR,
timeouts 504. Every call writes one frigg.api.proxy.request audit record
(no path, query, headers or bodies) and counts frigg.api.proxy.requests by
module, method and outcome.

api-proxy.schema.json gains the ADR-052 error codes, the path and header
rules and relayed 4xx; api-module-definition.schema.json gains proxy.

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

GET /api/meta/openapi/v1.json and /api/meta/openapi/v2.json serve OpenAPI
3.0.3 documents built from the route registry (ADR-053 §5): paths and
methods come from the registry, per-operation docs are keyed by
operationId, path parameters are derived from the Express path, and
x-frigg-stability marks the beta entity proxy (documented only when the app
enables it). v1 lists every frozen route as deprecated with its v2
replacement. info.version is the core version; x-frigg-api-version is the
major. Other /api/meta paths answer a v2-shaped 404.

Components are reconciled with packages/schemas; a parity test checks that
required fields agree both ways. The schemas move their descriptions to the
/api/v2 paths (ADR-053's ADR-006 amendment), mark the routes v2 does not
serve, add authorizeComplete / authorizePending / authorizeV2Response, and
accept module-defined step types (email, otp) in requirements.

contract.test.js asserts registry == router == OpenAPI: every v2
operationId has a handler, the v2 router mounts exactly the registry
routes in order (with and without the proxy), the frozen v1 router serves
exactly the registered v1 routes, the meta router serves the meta routes,
operationIds are unique and every $ref resolves.

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

The auth function's HTTP API events now come from core's
getGatewayRoutes() (ADR-053 §5), the same registry that mounts the Express
routers and builds the OpenAPI documents:

- v1 routes as before (minus the exact ANY /api/entities route, which no
  router serves), dropped when managementApi.v1 is false; the OAuth
  redirect then gets its own exact route;
- v2 per resource: /api/v2/{integrations,entities,credentials} and their
  {proxy+}, and /api/v2/authorize. Never ANY /api/v2/{proxy+}, which would
  catch methods /api/v2/reports does not declare and send them to the
  wrong Lambda;
- GET /api/meta and /api/meta/{proxy+} on the DB-free health function.

The gateway route test now reads the live auth and health routers
(nested routers included, entity proxy enabled) and checks that every v1,
v2 and meta route reaches its function, that /api/v2/reports still reaches
the report router, and that no /api/v2 catch-all exists.

Builds on #667 (fix/v1-entity-gateway-routes).

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

new API(baseUrl, jwt, { apiVersion: 'v1' | 'v2' }); the default stays 'v1'
in 2.0.1.

- In v2 mode the client reads GET /api/meta once before its first /api/v2
  call and throws FriggApiVersionError ("needs @friggframework/core >=
  2.0.1; this server serves: v1") when the backend does not serve v2.
- The methods the bundled components call keep their v1 return shapes in
  v2 mode: listIntegrations assembles { entities: { options, authorized },
  integrations } from three v2 calls, getAuthorizeRequirements copies the
  OAuth url to the top level, authorize adds entity_id / credential_id /
  type to a completed flow, and getUserActions uses GET with ?actionType.
- v2-only methods for the routes core serves: entities, entity types and
  requirements, multi-step authorize (getAuthorizationStep,
  submitAuthorizationStep), credentials and re-authorization, integration
  get and test-auth, the entity proxy, getMeta and hasCapability.
- Every request sends Frigg-Client: @friggframework/ui for the backend's
  v1 usage log. API and FriggApiVersionError are exported from the package.
- npm test runs the client tests with node --test (no new dependencies).

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

- docs/reference/api-reference.md: versions, GET /api/meta, v2 conventions
  and endpoint list, multi-step authorization, the entity proxy and its
  app/module configuration, the v1 → v2 migration table, deprecation
  signals, managementApi.v1: false, and the ui apiVersion option. The v1
  swagger embeds move under "v1 reference (deprecated)".
- frigg-management-api skill: start with /api/meta, document v2 first, v1
  as a legacy table.
- frigg-user-actions skill: the provisioning runbook on v2 paths, with the
  v1 differences listed.
- CLAUDE.md: where the Management API lives and how to add a route.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Found by the end-to-end run: an API-key module that declares
requiredAuthMethods.getAuthorizationRequirements(api) (the form convention
frigg auth uses) made GET /api/v2/authorize fail, because Module copies
requiredAuthMethods onto itself and the Api had no such method.

GetAuthorizationRequirements now asks, in order, the module's
requiredAuthMethods.getAuthorizationRequirements(api), then the Api's own
getAuthorizationRequirements() (OAuth2Requester's authorize URL). A module
with neither answers 501 REQUIREMENTS_NOT_DECLARED with a message saying
what to add, instead of a 500.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@seanspeaks seanspeaks added release Create a release when this pr is merged prerelease This change is available in a prerelease. labels Oct 3, 2026
@netlify

netlify Bot commented Oct 3, 2026 •

Copy link
Copy Markdown

✅ Deploy Preview for friggframework-org canceled.

Name Link
🔨 Latest commit e50fe9c
🔍 Latest deploy log https://app.netlify.com/projects/friggframework-org/deploys/6ac168e435c41b0008c8d86c

@sonarqubecloud

sonarqubecloud Bot commented Oct 3, 2026

Copy link
Copy Markdown

Quality Gate Failed Quality Gate failed

Failed conditions
D Reliability Rating on New Code (required ≥ A)

See analysis details on SonarQube Cloud

Catch issues before they fail your Quality Gate with our IDE extension SonarQube for IDE

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

prerelease This change is available in a prerelease. release Create a release when this pr is merged

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant