diff --git a/.changeset/adapter-manifest.md b/.changeset/adapter-manifest.md new file mode 100644 index 00000000..7e905c37 --- /dev/null +++ b/.changeset/adapter-manifest.md @@ -0,0 +1,9 @@ +--- +'seamless-auth-api': minor +--- + +Publish an adapter manifest so server adapters can be driven by data instead of a hand-maintained route list (#372). + +- `GET /.well-known/seamless-adapter.json` describes every route a server adapter exposes: which held token it sends (`none`, `preAuth`, `registration`, `access`, `refresh`), which tokens a response issues or clears, which body fields reach the browser under the adapter's cookie transport, and which routes support external message delivery. The same document is committed as `adapter-manifest.json` and regenerated by `npm run generate:api`. +- Routes declare this with a new `adapter` option on `defineRoute`. Registration fails when a public route does not say whether adapters expose it, when an ephemeral route does not say which token it takes, or when a route that always returns a token does not say what it issues. +- The four OTP send routes and the magic-link request now accept `POST`. They send a message, and a `GET` can be triggered cross-site without a CORS preflight. The `GET` forms still work and are marked deprecated. diff --git a/AGENTS.md b/AGENTS.md index cd6ae9a2..5099f993 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -126,6 +126,7 @@ Direct provider wiring currently lives in [src/config/directMessaging.ts](/Users - Prefer changing controllers/services over adding logic directly in routes. - When adding or updating routes, use `schemas`, not `schema`, so request parsing and OpenAPI generation both work. +- Public, ephemeral, and token-issuing routes must declare `adapter` in `defineRoute` (see [src/lib/adapterManifest.ts](/Users/brandoncorbett/git/seamless-auth-api/src/lib/adapterManifest.ts)). It feeds `/.well-known/seamless-adapter.json`, which server adapters follow instead of a hard-coded route list. Run `npm run generate:api` after route changes so `openapi.json` and `adapter-manifest.json` stay committed in step. - If a route requires auth, prefer the `auth` option in `createRouter` definitions. That keeps middleware wiring and OpenAPI security metadata aligned. - If a route also needs admin checks or rate limiting, combine `auth` with extra `middleware`. - Be careful around token response shapes and bearer auth. Browser-cookie auth mode has been removed. diff --git a/adapter-manifest.json b/adapter-manifest.json new file mode 100644 index 00000000..3025d0ea --- /dev/null +++ b/adapter-manifest.json @@ -0,0 +1,232 @@ +{ + "schemaVersion": 1, + "apiVersion": "0.17.0", + "session": { + "subject": "sub", + "token": "token", + "refreshToken": "refreshToken", + "ttl": "ttl", + "refreshTtl": "refreshTtl" + }, + "routes": [ + { "method": "GET", "path": "/admin/auth-events", "credential": "access" }, + { "method": "GET", "path": "/admin/auth-events/export", "credential": "access" }, + { "method": "GET", "path": "/admin/auth-events/integrity", "credential": "access" }, + { "method": "GET", "path": "/admin/credential-count", "credential": "access" }, + { "method": "GET", "path": "/admin/enrollment", "credential": "access" }, + { "method": "POST", "path": "/admin/enrollment/invites", "credential": "access" }, + { "method": "GET", "path": "/admin/organizations", "credential": "access" }, + { "method": "POST", "path": "/admin/organizations", "credential": "access" }, + { "method": "DELETE", "path": "/admin/organizations/{organizationId}", "credential": "access" }, + { "method": "GET", "path": "/admin/organizations/{organizationId}", "credential": "access" }, + { "method": "PATCH", "path": "/admin/organizations/{organizationId}", "credential": "access" }, + { + "method": "GET", + "path": "/admin/organizations/{organizationId}/members", + "credential": "access" + }, + { + "method": "POST", + "path": "/admin/organizations/{organizationId}/members", + "credential": "access" + }, + { + "method": "DELETE", + "path": "/admin/organizations/{organizationId}/members/{userId}", + "credential": "access" + }, + { + "method": "PATCH", + "path": "/admin/organizations/{organizationId}/members/{userId}", + "credential": "access" + }, + { + "method": "DELETE", + "path": "/admin/organizations/{organizationId}/oauth-providers/{providerId}/retirement", + "credential": "access" + }, + { + "method": "PUT", + "path": "/admin/organizations/{organizationId}/oauth-providers/{providerId}/retirement", + "credential": "access" + }, + { "method": "GET", "path": "/admin/reports/authentication-coverage", "credential": "access" }, + { "method": "GET", "path": "/admin/review-accounts", "credential": "access" }, + { "method": "GET", "path": "/admin/sessions", "credential": "access" }, + { "method": "GET", "path": "/admin/sessions/{userId}", "credential": "access" }, + { "method": "DELETE", "path": "/admin/sessions/{userId}/revoke-all", "credential": "access" }, + { "method": "DELETE", "path": "/admin/sessions/by-id/{id}", "credential": "access" }, + { "method": "DELETE", "path": "/admin/users", "credential": "access" }, + { "method": "GET", "path": "/admin/users", "credential": "access" }, + { "method": "POST", "path": "/admin/users", "credential": "access" }, + { "method": "GET", "path": "/admin/users/{userId}", "credential": "access" }, + { "method": "PATCH", "path": "/admin/users/{userId}", "credential": "access" }, + { "method": "GET", "path": "/admin/users/{userId}/anomalies", "credential": "access" }, + { + "method": "POST", + "path": "/admin/users/{userId}/recovery/device-replacement", + "credential": "access" + }, + { "method": "POST", "path": "/admin/users/import", "credential": "access" }, + { "method": "GET", "path": "/internal/auth-events/grouped", "credential": "access" }, + { "method": "GET", "path": "/internal/auth-events/login-stats", "credential": "access" }, + { "method": "GET", "path": "/internal/auth-events/summary", "credential": "access" }, + { "method": "GET", "path": "/internal/auth-events/timeseries", "credential": "access" }, + { "method": "GET", "path": "/internal/metrics/dashboard", "credential": "access" }, + { "method": "GET", "path": "/internal/metrics/funnel", "credential": "access" }, + { "method": "GET", "path": "/internal/metrics/sign-ins", "credential": "access" }, + { "method": "GET", "path": "/internal/security/anomalies", "credential": "access" }, + { + "method": "POST", + "path": "/login", + "credential": "none", + "issues": "preAuth", + "body": { "pick": ["message", "identifierType", "loginMethods"] } + }, + { + "method": "DELETE", + "path": "/logout", + "credential": "access", + "clears": ["access", "registration", "refresh"] + }, + { + "method": "DELETE", + "path": "/logout/all", + "credential": "access", + "clears": ["access", "registration", "refresh"] + }, + { "method": "POST", "path": "/magic-link", "credential": "preAuth", "delivery": true }, + { "method": "GET", "path": "/magic-link/check", "credential": "preAuth", "issues": "session" }, + { "method": "GET", "path": "/magic-link/verify/{token}", "credential": "none" }, + { + "method": "POST", + "path": "/oauth/{providerId}/callback", + "credential": "none", + "issues": "session" + }, + { "method": "POST", "path": "/oauth/{providerId}/start", "credential": "none" }, + { "method": "GET", "path": "/oauth/providers", "credential": "none" }, + { "method": "GET", "path": "/organizations", "credential": "access" }, + { "method": "POST", "path": "/organizations", "credential": "access" }, + { "method": "GET", "path": "/organizations/{organizationId}", "credential": "access" }, + { "method": "PATCH", "path": "/organizations/{organizationId}", "credential": "access" }, + { "method": "GET", "path": "/organizations/{organizationId}/members", "credential": "access" }, + { "method": "POST", "path": "/organizations/{organizationId}/members", "credential": "access" }, + { + "method": "DELETE", + "path": "/organizations/{organizationId}/members/{userId}", + "credential": "access" + }, + { + "method": "PATCH", + "path": "/organizations/{organizationId}/members/{userId}", + "credential": "access" + }, + { + "method": "POST", + "path": "/organizations/{organizationId}/switch", + "credential": "access", + "issues": "access" + }, + { + "method": "POST", + "path": "/otp/generate-email-otp", + "credential": "registration", + "delivery": true + }, + { + "method": "POST", + "path": "/otp/generate-login-email-otp", + "credential": "preAuth", + "delivery": true + }, + { + "method": "POST", + "path": "/otp/generate-login-phone-otp", + "credential": "preAuth", + "delivery": true + }, + { + "method": "POST", + "path": "/otp/generate-phone-otp", + "credential": "registration", + "delivery": true + }, + { + "method": "POST", + "path": "/otp/verify-email-otp", + "credential": "registration", + "issues": "session" + }, + { + "method": "POST", + "path": "/otp/verify-login-email-otp", + "credential": "preAuth", + "issues": "session" + }, + { + "method": "POST", + "path": "/otp/verify-login-phone-otp", + "credential": "preAuth", + "issues": "session" + }, + { + "method": "POST", + "path": "/otp/verify-phone-otp", + "credential": "registration", + "issues": "session" + }, + { "method": "POST", "path": "/refresh", "credential": "refresh", "issues": "session" }, + { "method": "POST", "path": "/registration/phone", "credential": "access" }, + { "method": "POST", "path": "/registration/phone/verify", "credential": "access" }, + { + "method": "POST", + "path": "/registration/register", + "credential": "none", + "issues": "registration" + }, + { "method": "DELETE", "path": "/sessions", "credential": "access" }, + { "method": "GET", "path": "/sessions", "credential": "access" }, + { "method": "DELETE", "path": "/sessions/{id}", "credential": "access" }, + { "method": "GET", "path": "/step-up/status", "credential": "access" }, + { "method": "POST", "path": "/step-up/webauthn/finish", "credential": "access" }, + { "method": "POST", "path": "/step-up/webauthn/start", "credential": "access" }, + { "method": "GET", "path": "/system-config/admin", "credential": "access" }, + { "method": "PATCH", "path": "/system-config/admin", "credential": "access" }, + { "method": "GET", "path": "/system-config/oauth-providers", "credential": "access" }, + { "method": "POST", "path": "/system-config/oauth-providers", "credential": "access" }, + { "method": "DELETE", "path": "/system-config/oauth-providers/{id}", "credential": "access" }, + { "method": "PATCH", "path": "/system-config/oauth-providers/{id}", "credential": "access" }, + { "method": "GET", "path": "/system-config/public", "credential": "none" }, + { "method": "GET", "path": "/system-config/roles", "credential": "access" }, + { "method": "POST", "path": "/totp/disable", "credential": "access" }, + { "method": "POST", "path": "/totp/enroll/start", "credential": "access" }, + { "method": "POST", "path": "/totp/enroll/verify", "credential": "access" }, + { "method": "GET", "path": "/totp/status", "credential": "access" }, + { + "method": "POST", + "path": "/totp/verify-login", + "credential": "preAuth", + "issues": "session" + }, + { "method": "POST", "path": "/totp/verify-mfa", "credential": "access" }, + { "method": "DELETE", "path": "/users/credentials", "credential": "access" }, + { "method": "POST", "path": "/users/credentials", "credential": "access" }, + { + "method": "DELETE", + "path": "/users/delete", + "credential": "access", + "clears": ["access", "registration", "refresh"] + }, + { "method": "GET", "path": "/users/me", "credential": "access", "clears": ["preAuth"] }, + { + "method": "POST", + "path": "/webauthn/login/finish", + "credential": "preAuth", + "issues": "session" + }, + { "method": "POST", "path": "/webauthn/login/start", "credential": "preAuth" }, + { "method": "POST", "path": "/webauthn/register/finish", "credential": "access" }, + { "method": "GET", "path": "/webauthn/register/start", "credential": "access" } + ] +} diff --git a/openapi.json b/openapi.json index 909a1447..5740e16c 100644 --- a/openapi.json +++ b/openapi.json @@ -1,6 +1,6 @@ { "openapi": "3.0.3", - "info": { "title": "Seamless Auth API", "version": "0.16.0" }, + "info": { "title": "Seamless Auth API", "version": "0.17.0" }, "components": { "schemas": {}, "parameters": {}, @@ -9,6 +9,116 @@ } }, "paths": { + "/.well-known/seamless-adapter.json": { + "get": { + "summary": "Routes a server adapter exposes and what it does with each", + "description": "Server adapters load this at startup instead of hard-coding routes. It describes which token each route takes and which tokens a response issues or clears.", + "tags": ["Adapters"], + "responses": { + "200": { + "description": "HTTP 200", + "content": { + "application/json": { + "example": { + "schemaVersion": null, + "apiVersion": "string", + "session": { + "subject": null, + "token": null, + "refreshToken": null, + "ttl": null, + "refreshTtl": null + }, + "routes": [null] + }, + "schema": { + "type": "object", + "properties": { + "schemaVersion": { "type": "number", "enum": [1] }, + "apiVersion": { "type": "string" }, + "session": { + "type": "object", + "properties": { + "subject": { "type": "string", "enum": ["sub"] }, + "token": { "type": "string", "enum": ["token"] }, + "refreshToken": { "type": "string", "enum": ["refreshToken"] }, + "ttl": { "type": "string", "enum": ["ttl"] }, + "refreshTtl": { "type": "string", "enum": ["refreshTtl"] } + }, + "required": ["subject", "token", "refreshToken", "ttl", "refreshTtl"] + }, + "routes": { + "type": "array", + "items": { + "type": "object", + "properties": { + "method": { + "type": "string", + "enum": ["GET", "POST", "PUT", "PATCH", "DELETE"] + }, + "path": { "type": "string" }, + "credential": { + "type": "string", + "enum": ["none", "preAuth", "registration", "access", "refresh"] + }, + "issues": { + "type": "string", + "enum": ["preAuth", "registration", "session", "access"] + }, + "clears": { + "type": "array", + "items": { + "type": "string", + "enum": ["preAuth", "registration", "access", "refresh"] + } + }, + "body": { + "type": "object", + "properties": { + "pick": { "type": "array", "items": { "type": "string" } } + }, + "required": ["pick"] + }, + "delivery": { "type": "boolean", "enum": [true] } + }, + "required": ["method", "path", "credential"] + } + } + }, + "required": ["schemaVersion", "apiVersion", "session", "routes"] + } + } + } + }, + "429": { + "description": "HTTP 429", + "content": { + "application/json": { + "example": { "message": "string", "error": "string" }, + "schema": { + "type": "object", + "properties": { "message": { "type": "string" }, "error": { "type": "string" } }, + "required": ["error"] + } + } + } + }, + "500": { + "description": "HTTP 500", + "content": { + "application/json": { + "example": { "message": "string", "error": "string" }, + "schema": { + "type": "object", + "properties": { "message": { "type": "string" }, "error": { "type": "string" } }, + "required": ["error"] + } + } + } + } + } + } + }, "/admin/organizations": { "get": { "summary": "List organizations", @@ -6902,9 +7012,163 @@ } }, "/magic-link": { + "post": { + "summary": "Request a magic login link", + "tags": ["MagicLinks"], + "security": [{ "bearerAuth": [] }], + "parameters": [ + { + "schema": { "type": "string", "format": "uri" }, + "required": false, + "name": "redirectUri", + "in": "query" + } + ], + "responses": { + "200": { + "description": "HTTP 200", + "content": { + "application/json": { + "example": { "message": "string", "token": "string", "delivery": null }, + "schema": { + "type": "object", + "properties": { + "message": { "type": "string" }, + "token": { "type": "string" }, + "delivery": { + "oneOf": [ + { + "type": "object", + "properties": { + "kind": { "type": "string", "enum": ["otp_email"] }, + "to": { "type": "string" }, + "token": { "type": "string" } + }, + "required": ["kind", "to", "token"] + }, + { + "type": "object", + "properties": { + "kind": { "type": "string", "enum": ["otp_sms"] }, + "to": { "type": "string" }, + "token": { "anyOf": [{ "type": "string" }, { "type": "number" }] } + }, + "required": ["kind", "to", "token"] + }, + { + "type": "object", + "properties": { + "kind": { "type": "string", "enum": ["magic_link_email"] }, + "to": { "type": "string" }, + "token": { "type": "string" }, + "magicLinkUrl": { "type": "string" } + }, + "required": ["kind", "to", "magicLinkUrl"] + }, + { + "type": "object", + "properties": { + "kind": { "type": "string", "enum": ["enrollment_invite_email"] }, + "to": { "type": "string" }, + "signInUrl": { "type": "string" } + }, + "required": ["kind", "to", "signInUrl"] + } + ] + } + }, + "required": ["message"] + } + } + } + }, + "400": { + "description": "HTTP 400", + "content": { + "application/json": { + "example": { + "error": "string", + "message": "string", + "details": { "issues": [null] } + }, + "schema": { + "type": "object", + "properties": { + "error": { "type": "string" }, + "message": { "type": "string" }, + "details": { + "type": "object", + "properties": { + "issues": { + "type": "array", + "items": { + "type": "object", + "properties": { + "path": { + "type": "array", + "items": { "anyOf": [{ "type": "string" }, { "type": "number" }] } + }, + "code": { "type": "string" }, + "message": { "type": "string" } + }, + "required": ["path", "code", "message"] + } + } + }, + "required": ["issues"] + } + }, + "required": ["error"] + } + } + } + }, + "403": { + "description": "HTTP 403", + "content": { + "application/json": { + "example": { "message": "string", "error": "string" }, + "schema": { + "type": "object", + "properties": { "message": { "type": "string" }, "error": { "type": "string" } }, + "required": ["error"] + } + } + } + }, + "429": { + "description": "HTTP 429", + "content": { + "application/json": { + "example": { "message": "string", "error": "string" }, + "schema": { + "type": "object", + "properties": { "message": { "type": "string" }, "error": { "type": "string" } }, + "required": ["error"] + } + } + } + }, + "500": { + "description": "HTTP 500", + "content": { + "application/json": { + "example": { "message": "string", "error": "string" }, + "schema": { + "type": "object", + "properties": { "message": { "type": "string" }, "error": { "type": "string" } }, + "required": ["error"] + } + } + } + } + } + }, "get": { "summary": "Request a magic login link", + "description": "Use POST. A GET that sends a message can be triggered cross-site without a CORS preflight.", "tags": ["MagicLinks"], + "deprecated": true, "security": [{ "bearerAuth": [] }], "parameters": [ { @@ -9391,7 +9655,7 @@ } }, "/otp/generate-email-otp": { - "get": { + "post": { "summary": "Generate email OTP", "tags": ["OTP"], "security": [{ "bearerAuth": [] }], @@ -9493,12 +9757,12 @@ } } } - } - }, - "/otp/generate-phone-otp": { + }, "get": { - "summary": "Generate phone OTP", + "summary": "Generate email OTP", + "description": "Use POST. A GET that sends a message can be triggered cross-site without a CORS preflight.", "tags": ["OTP"], + "deprecated": true, "security": [{ "bearerAuth": [] }], "responses": { "200": { @@ -9600,9 +9864,9 @@ } } }, - "/otp/generate-login-email-otp": { - "get": { - "summary": "Generate login email OTP", + "/otp/generate-phone-otp": { + "post": { + "summary": "Generate phone OTP", "tags": ["OTP"], "security": [{ "bearerAuth": [] }], "responses": { @@ -9663,8 +9927,8 @@ } } }, - "403": { - "description": "HTTP 403", + "400": { + "description": "HTTP 400", "content": { "application/json": { "example": { "message": "string", "error": "string" }, @@ -9703,12 +9967,432 @@ } } } - } - }, - "/otp/generate-login-phone-otp": { + }, + "get": { + "summary": "Generate phone OTP", + "description": "Use POST. A GET that sends a message can be triggered cross-site without a CORS preflight.", + "tags": ["OTP"], + "deprecated": true, + "security": [{ "bearerAuth": [] }], + "responses": { + "200": { + "description": "HTTP 200", + "content": { + "application/json": { + "example": { "message": "string", "token": "string", "delivery": null }, + "schema": { + "type": "object", + "properties": { + "message": { "type": "string" }, + "token": { "type": "string" }, + "delivery": { + "oneOf": [ + { + "type": "object", + "properties": { + "kind": { "type": "string", "enum": ["otp_email"] }, + "to": { "type": "string" }, + "token": { "type": "string" } + }, + "required": ["kind", "to", "token"] + }, + { + "type": "object", + "properties": { + "kind": { "type": "string", "enum": ["otp_sms"] }, + "to": { "type": "string" }, + "token": { "anyOf": [{ "type": "string" }, { "type": "number" }] } + }, + "required": ["kind", "to", "token"] + }, + { + "type": "object", + "properties": { + "kind": { "type": "string", "enum": ["magic_link_email"] }, + "to": { "type": "string" }, + "token": { "type": "string" }, + "magicLinkUrl": { "type": "string" } + }, + "required": ["kind", "to", "magicLinkUrl"] + }, + { + "type": "object", + "properties": { + "kind": { "type": "string", "enum": ["enrollment_invite_email"] }, + "to": { "type": "string" }, + "signInUrl": { "type": "string" } + }, + "required": ["kind", "to", "signInUrl"] + } + ] + } + }, + "required": ["message"] + } + } + } + }, + "400": { + "description": "HTTP 400", + "content": { + "application/json": { + "example": { "message": "string", "error": "string" }, + "schema": { + "type": "object", + "properties": { "message": { "type": "string" }, "error": { "type": "string" } }, + "required": ["error"] + } + } + } + }, + "429": { + "description": "HTTP 429", + "content": { + "application/json": { + "example": { "message": "string", "error": "string" }, + "schema": { + "type": "object", + "properties": { "message": { "type": "string" }, "error": { "type": "string" } }, + "required": ["error"] + } + } + } + }, + "500": { + "description": "HTTP 500", + "content": { + "application/json": { + "example": { "message": "string", "error": "string" }, + "schema": { + "type": "object", + "properties": { "message": { "type": "string" }, "error": { "type": "string" } }, + "required": ["error"] + } + } + } + } + } + } + }, + "/otp/generate-login-email-otp": { + "post": { + "summary": "Generate login email OTP", + "tags": ["OTP"], + "security": [{ "bearerAuth": [] }], + "responses": { + "200": { + "description": "HTTP 200", + "content": { + "application/json": { + "example": { "message": "string", "token": "string", "delivery": null }, + "schema": { + "type": "object", + "properties": { + "message": { "type": "string" }, + "token": { "type": "string" }, + "delivery": { + "oneOf": [ + { + "type": "object", + "properties": { + "kind": { "type": "string", "enum": ["otp_email"] }, + "to": { "type": "string" }, + "token": { "type": "string" } + }, + "required": ["kind", "to", "token"] + }, + { + "type": "object", + "properties": { + "kind": { "type": "string", "enum": ["otp_sms"] }, + "to": { "type": "string" }, + "token": { "anyOf": [{ "type": "string" }, { "type": "number" }] } + }, + "required": ["kind", "to", "token"] + }, + { + "type": "object", + "properties": { + "kind": { "type": "string", "enum": ["magic_link_email"] }, + "to": { "type": "string" }, + "token": { "type": "string" }, + "magicLinkUrl": { "type": "string" } + }, + "required": ["kind", "to", "magicLinkUrl"] + }, + { + "type": "object", + "properties": { + "kind": { "type": "string", "enum": ["enrollment_invite_email"] }, + "to": { "type": "string" }, + "signInUrl": { "type": "string" } + }, + "required": ["kind", "to", "signInUrl"] + } + ] + } + }, + "required": ["message"] + } + } + } + }, + "403": { + "description": "HTTP 403", + "content": { + "application/json": { + "example": { "message": "string", "error": "string" }, + "schema": { + "type": "object", + "properties": { "message": { "type": "string" }, "error": { "type": "string" } }, + "required": ["error"] + } + } + } + }, + "429": { + "description": "HTTP 429", + "content": { + "application/json": { + "example": { "message": "string", "error": "string" }, + "schema": { + "type": "object", + "properties": { "message": { "type": "string" }, "error": { "type": "string" } }, + "required": ["error"] + } + } + } + }, + "500": { + "description": "HTTP 500", + "content": { + "application/json": { + "example": { "message": "string", "error": "string" }, + "schema": { + "type": "object", + "properties": { "message": { "type": "string" }, "error": { "type": "string" } }, + "required": ["error"] + } + } + } + } + } + }, + "get": { + "summary": "Generate login email OTP", + "description": "Use POST. A GET that sends a message can be triggered cross-site without a CORS preflight.", + "tags": ["OTP"], + "deprecated": true, + "security": [{ "bearerAuth": [] }], + "responses": { + "200": { + "description": "HTTP 200", + "content": { + "application/json": { + "example": { "message": "string", "token": "string", "delivery": null }, + "schema": { + "type": "object", + "properties": { + "message": { "type": "string" }, + "token": { "type": "string" }, + "delivery": { + "oneOf": [ + { + "type": "object", + "properties": { + "kind": { "type": "string", "enum": ["otp_email"] }, + "to": { "type": "string" }, + "token": { "type": "string" } + }, + "required": ["kind", "to", "token"] + }, + { + "type": "object", + "properties": { + "kind": { "type": "string", "enum": ["otp_sms"] }, + "to": { "type": "string" }, + "token": { "anyOf": [{ "type": "string" }, { "type": "number" }] } + }, + "required": ["kind", "to", "token"] + }, + { + "type": "object", + "properties": { + "kind": { "type": "string", "enum": ["magic_link_email"] }, + "to": { "type": "string" }, + "token": { "type": "string" }, + "magicLinkUrl": { "type": "string" } + }, + "required": ["kind", "to", "magicLinkUrl"] + }, + { + "type": "object", + "properties": { + "kind": { "type": "string", "enum": ["enrollment_invite_email"] }, + "to": { "type": "string" }, + "signInUrl": { "type": "string" } + }, + "required": ["kind", "to", "signInUrl"] + } + ] + } + }, + "required": ["message"] + } + } + } + }, + "403": { + "description": "HTTP 403", + "content": { + "application/json": { + "example": { "message": "string", "error": "string" }, + "schema": { + "type": "object", + "properties": { "message": { "type": "string" }, "error": { "type": "string" } }, + "required": ["error"] + } + } + } + }, + "429": { + "description": "HTTP 429", + "content": { + "application/json": { + "example": { "message": "string", "error": "string" }, + "schema": { + "type": "object", + "properties": { "message": { "type": "string" }, "error": { "type": "string" } }, + "required": ["error"] + } + } + } + }, + "500": { + "description": "HTTP 500", + "content": { + "application/json": { + "example": { "message": "string", "error": "string" }, + "schema": { + "type": "object", + "properties": { "message": { "type": "string" }, "error": { "type": "string" } }, + "required": ["error"] + } + } + } + } + } + } + }, + "/otp/generate-login-phone-otp": { + "post": { + "summary": "Generate login phone OTP", + "tags": ["OTP"], + "security": [{ "bearerAuth": [] }], + "responses": { + "200": { + "description": "HTTP 200", + "content": { + "application/json": { + "example": { "message": "string", "token": "string", "delivery": null }, + "schema": { + "type": "object", + "properties": { + "message": { "type": "string" }, + "token": { "type": "string" }, + "delivery": { + "oneOf": [ + { + "type": "object", + "properties": { + "kind": { "type": "string", "enum": ["otp_email"] }, + "to": { "type": "string" }, + "token": { "type": "string" } + }, + "required": ["kind", "to", "token"] + }, + { + "type": "object", + "properties": { + "kind": { "type": "string", "enum": ["otp_sms"] }, + "to": { "type": "string" }, + "token": { "anyOf": [{ "type": "string" }, { "type": "number" }] } + }, + "required": ["kind", "to", "token"] + }, + { + "type": "object", + "properties": { + "kind": { "type": "string", "enum": ["magic_link_email"] }, + "to": { "type": "string" }, + "token": { "type": "string" }, + "magicLinkUrl": { "type": "string" } + }, + "required": ["kind", "to", "magicLinkUrl"] + }, + { + "type": "object", + "properties": { + "kind": { "type": "string", "enum": ["enrollment_invite_email"] }, + "to": { "type": "string" }, + "signInUrl": { "type": "string" } + }, + "required": ["kind", "to", "signInUrl"] + } + ] + } + }, + "required": ["message"] + } + } + } + }, + "403": { + "description": "HTTP 403", + "content": { + "application/json": { + "example": { "message": "string", "error": "string" }, + "schema": { + "type": "object", + "properties": { "message": { "type": "string" }, "error": { "type": "string" } }, + "required": ["error"] + } + } + } + }, + "429": { + "description": "HTTP 429", + "content": { + "application/json": { + "example": { "message": "string", "error": "string" }, + "schema": { + "type": "object", + "properties": { "message": { "type": "string" }, "error": { "type": "string" } }, + "required": ["error"] + } + } + } + }, + "500": { + "description": "HTTP 500", + "content": { + "application/json": { + "example": { "message": "string", "error": "string" }, + "schema": { + "type": "object", + "properties": { "message": { "type": "string" }, "error": { "type": "string" } }, + "required": ["error"] + } + } + } + } + } + }, "get": { "summary": "Generate login phone OTP", + "description": "Use POST. A GET that sends a message can be triggered cross-site without a CORS preflight.", "tags": ["OTP"], + "deprecated": true, "security": [{ "bearerAuth": [] }], "responses": { "200": { diff --git a/resources/coverage-badge.svg b/resources/coverage-badge.svg index c931b02b..322cf29d 100644 --- a/resources/coverage-badge.svg +++ b/resources/coverage-badge.svg @@ -1,5 +1,5 @@ - - coverage: 99.2% + + coverage: 99.1% @@ -17,7 +17,7 @@ coverage coverage - 99.2% - 99.2% + 99.1% + 99.1% diff --git a/src/controllers/adapterManifest.ts b/src/controllers/adapterManifest.ts new file mode 100644 index 00000000..f36a6fd3 --- /dev/null +++ b/src/controllers/adapterManifest.ts @@ -0,0 +1,13 @@ +/* + * Copyright © 2026 Fells Code, LLC + * Licensed under the Apache License, Version 2.0 + * See LICENSE file in the project root for full license information + */ + +import { Request, Response } from 'express'; + +import { getAdapterManifest } from '../lib/adapterManifest.js'; + +export function adapterManifestHandler(_req: Request, res: Response) { + res.json(getAdapterManifest()); +} diff --git a/src/generated/api.ts b/src/generated/api.ts index 592d505f..0484acef 100644 --- a/src/generated/api.ts +++ b/src/generated/api.ts @@ -12,6 +12,128 @@ */ export interface paths { + '/.well-known/seamless-adapter.json': { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** + * Routes a server adapter exposes and what it does with each + * @description Server adapters load this at startup instead of hard-coding routes. It describes which token each route takes and which tokens a response issues or clears. + */ + get: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description HTTP 200 */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + /** + * @example { + * "schemaVersion": null, + * "apiVersion": "string", + * "session": { + * "subject": null, + * "token": null, + * "refreshToken": null, + * "ttl": null, + * "refreshTtl": null + * }, + * "routes": [ + * null + * ] + * } + */ + 'application/json': { + /** @enum {number} */ + schemaVersion: 1; + apiVersion: string; + session: { + /** @enum {string} */ + subject: 'sub'; + /** @enum {string} */ + token: 'token'; + /** @enum {string} */ + refreshToken: 'refreshToken'; + /** @enum {string} */ + ttl: 'ttl'; + /** @enum {string} */ + refreshTtl: 'refreshTtl'; + }; + routes: { + /** @enum {string} */ + method: 'GET' | 'POST' | 'PUT' | 'PATCH' | 'DELETE'; + path: string; + /** @enum {string} */ + credential: 'none' | 'preAuth' | 'registration' | 'access' | 'refresh'; + /** @enum {string} */ + issues?: 'preAuth' | 'registration' | 'session' | 'access'; + clears?: ('preAuth' | 'registration' | 'access' | 'refresh')[]; + body?: { + pick: string[]; + }; + /** @enum {boolean} */ + delivery?: true; + }[]; + }; + }; + }; + /** @description HTTP 429 */ + 429: { + headers: { + [name: string]: unknown; + }; + content: { + /** + * @example { + * "message": "string", + * "error": "string" + * } + */ + 'application/json': { + message?: string; + error: string; + }; + }; + }; + /** @description HTTP 500 */ + 500: { + headers: { + [name: string]: unknown; + }; + content: { + /** + * @example { + * "message": "string", + * "error": "string" + * } + */ + 'application/json': { + message?: string; + error: string; + }; + }; + }; + }; + }; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; '/admin/organizations': { parameters: { query?: never; @@ -6818,7 +6940,11 @@ export interface paths { path?: never; cookie?: never; }; - /** Request a magic login link */ + /** + * Request a magic login link + * @deprecated + * @description Use POST. A GET that sends a message can be triggered cross-site without a CORS preflight. + */ get: { parameters: { query?: { @@ -6962,7 +7088,149 @@ export interface paths { }; }; put?: never; - post?: never; + /** Request a magic login link */ + post: { + parameters: { + query?: { + redirectUri?: string; + }; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description HTTP 200 */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + /** + * @example { + * "message": "string", + * "token": "string", + * "delivery": null + * } + */ + 'application/json': { + message: string; + token?: string; + delivery?: + | { + /** @enum {string} */ + kind: 'otp_email'; + to: string; + token: string; + } + | { + /** @enum {string} */ + kind: 'otp_sms'; + to: string; + token: string | number; + } + | { + /** @enum {string} */ + kind: 'magic_link_email'; + to: string; + token?: string; + magicLinkUrl: string; + } + | { + /** @enum {string} */ + kind: 'enrollment_invite_email'; + to: string; + signInUrl: string; + }; + }; + }; + }; + /** @description HTTP 400 */ + 400: { + headers: { + [name: string]: unknown; + }; + content: { + /** + * @example { + * "error": "string", + * "message": "string", + * "details": { + * "issues": [ + * null + * ] + * } + * } + */ + 'application/json': { + error: string; + message?: string; + details?: { + issues: { + path: (string | number)[]; + code: string; + message: string; + }[]; + }; + }; + }; + }; + /** @description HTTP 403 */ + 403: { + headers: { + [name: string]: unknown; + }; + content: { + /** + * @example { + * "message": "string", + * "error": "string" + * } + */ + 'application/json': { + message?: string; + error: string; + }; + }; + }; + /** @description HTTP 429 */ + 429: { + headers: { + [name: string]: unknown; + }; + content: { + /** + * @example { + * "message": "string", + * "error": "string" + * } + */ + 'application/json': { + message?: string; + error: string; + }; + }; + }; + /** @description HTTP 500 */ + 500: { + headers: { + [name: string]: unknown; + }; + content: { + /** + * @example { + * "message": "string", + * "error": "string" + * } + */ + 'application/json': { + message?: string; + error: string; + }; + }; + }; + }; + }; delete?: never; options?: never; head?: never; @@ -9257,7 +9525,11 @@ export interface paths { path?: never; cookie?: never; }; - /** Generate email OTP */ + /** + * Generate email OTP + * @deprecated + * @description Use POST. A GET that sends a message can be triggered cross-site without a CORS preflight. + */ get: { parameters: { query?: never; @@ -9369,21 +9641,135 @@ export interface paths { }; }; put?: never; - post?: never; - delete?: never; - options?: never; - head?: never; - patch?: never; - trace?: never; - }; - '/otp/generate-phone-otp': { - parameters: { - query?: never; - header?: never; - path?: never; - cookie?: never; - }; - /** Generate phone OTP */ + /** Generate email OTP */ + post: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description HTTP 200 */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + /** + * @example { + * "message": "string", + * "token": "string", + * "delivery": null + * } + */ + 'application/json': { + message: string; + token?: string; + delivery?: + | { + /** @enum {string} */ + kind: 'otp_email'; + to: string; + token: string; + } + | { + /** @enum {string} */ + kind: 'otp_sms'; + to: string; + token: string | number; + } + | { + /** @enum {string} */ + kind: 'magic_link_email'; + to: string; + token?: string; + magicLinkUrl: string; + } + | { + /** @enum {string} */ + kind: 'enrollment_invite_email'; + to: string; + signInUrl: string; + }; + }; + }; + }; + /** @description HTTP 400 */ + 400: { + headers: { + [name: string]: unknown; + }; + content: { + /** + * @example { + * "message": "string", + * "error": "string" + * } + */ + 'application/json': { + message?: string; + error: string; + }; + }; + }; + /** @description HTTP 429 */ + 429: { + headers: { + [name: string]: unknown; + }; + content: { + /** + * @example { + * "message": "string", + * "error": "string" + * } + */ + 'application/json': { + message?: string; + error: string; + }; + }; + }; + /** @description HTTP 500 */ + 500: { + headers: { + [name: string]: unknown; + }; + content: { + /** + * @example { + * "message": "string", + * "error": "string" + * } + */ + 'application/json': { + message?: string; + error: string; + }; + }; + }; + }; + }; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + '/otp/generate-phone-otp': { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** + * Generate phone OTP + * @deprecated + * @description Use POST. A GET that sends a message can be triggered cross-site without a CORS preflight. + */ get: { parameters: { query?: never; @@ -9495,7 +9881,117 @@ export interface paths { }; }; put?: never; - post?: never; + /** Generate phone OTP */ + post: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description HTTP 200 */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + /** + * @example { + * "message": "string", + * "token": "string", + * "delivery": null + * } + */ + 'application/json': { + message: string; + token?: string; + delivery?: + | { + /** @enum {string} */ + kind: 'otp_email'; + to: string; + token: string; + } + | { + /** @enum {string} */ + kind: 'otp_sms'; + to: string; + token: string | number; + } + | { + /** @enum {string} */ + kind: 'magic_link_email'; + to: string; + token?: string; + magicLinkUrl: string; + } + | { + /** @enum {string} */ + kind: 'enrollment_invite_email'; + to: string; + signInUrl: string; + }; + }; + }; + }; + /** @description HTTP 400 */ + 400: { + headers: { + [name: string]: unknown; + }; + content: { + /** + * @example { + * "message": "string", + * "error": "string" + * } + */ + 'application/json': { + message?: string; + error: string; + }; + }; + }; + /** @description HTTP 429 */ + 429: { + headers: { + [name: string]: unknown; + }; + content: { + /** + * @example { + * "message": "string", + * "error": "string" + * } + */ + 'application/json': { + message?: string; + error: string; + }; + }; + }; + /** @description HTTP 500 */ + 500: { + headers: { + [name: string]: unknown; + }; + content: { + /** + * @example { + * "message": "string", + * "error": "string" + * } + */ + 'application/json': { + message?: string; + error: string; + }; + }; + }; + }; + }; delete?: never; options?: never; head?: never; @@ -9509,7 +10005,11 @@ export interface paths { path?: never; cookie?: never; }; - /** Generate login email OTP */ + /** + * Generate login email OTP + * @deprecated + * @description Use POST. A GET that sends a message can be triggered cross-site without a CORS preflight. + */ get: { parameters: { query?: never; @@ -9621,7 +10121,117 @@ export interface paths { }; }; put?: never; - post?: never; + /** Generate login email OTP */ + post: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description HTTP 200 */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + /** + * @example { + * "message": "string", + * "token": "string", + * "delivery": null + * } + */ + 'application/json': { + message: string; + token?: string; + delivery?: + | { + /** @enum {string} */ + kind: 'otp_email'; + to: string; + token: string; + } + | { + /** @enum {string} */ + kind: 'otp_sms'; + to: string; + token: string | number; + } + | { + /** @enum {string} */ + kind: 'magic_link_email'; + to: string; + token?: string; + magicLinkUrl: string; + } + | { + /** @enum {string} */ + kind: 'enrollment_invite_email'; + to: string; + signInUrl: string; + }; + }; + }; + }; + /** @description HTTP 403 */ + 403: { + headers: { + [name: string]: unknown; + }; + content: { + /** + * @example { + * "message": "string", + * "error": "string" + * } + */ + 'application/json': { + message?: string; + error: string; + }; + }; + }; + /** @description HTTP 429 */ + 429: { + headers: { + [name: string]: unknown; + }; + content: { + /** + * @example { + * "message": "string", + * "error": "string" + * } + */ + 'application/json': { + message?: string; + error: string; + }; + }; + }; + /** @description HTTP 500 */ + 500: { + headers: { + [name: string]: unknown; + }; + content: { + /** + * @example { + * "message": "string", + * "error": "string" + * } + */ + 'application/json': { + message?: string; + error: string; + }; + }; + }; + }; + }; delete?: never; options?: never; head?: never; @@ -9635,7 +10245,11 @@ export interface paths { path?: never; cookie?: never; }; - /** Generate login phone OTP */ + /** + * Generate login phone OTP + * @deprecated + * @description Use POST. A GET that sends a message can be triggered cross-site without a CORS preflight. + */ get: { parameters: { query?: never; @@ -9747,7 +10361,117 @@ export interface paths { }; }; put?: never; - post?: never; + /** Generate login phone OTP */ + post: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description HTTP 200 */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + /** + * @example { + * "message": "string", + * "token": "string", + * "delivery": null + * } + */ + 'application/json': { + message: string; + token?: string; + delivery?: + | { + /** @enum {string} */ + kind: 'otp_email'; + to: string; + token: string; + } + | { + /** @enum {string} */ + kind: 'otp_sms'; + to: string; + token: string | number; + } + | { + /** @enum {string} */ + kind: 'magic_link_email'; + to: string; + token?: string; + magicLinkUrl: string; + } + | { + /** @enum {string} */ + kind: 'enrollment_invite_email'; + to: string; + signInUrl: string; + }; + }; + }; + }; + /** @description HTTP 403 */ + 403: { + headers: { + [name: string]: unknown; + }; + content: { + /** + * @example { + * "message": "string", + * "error": "string" + * } + */ + 'application/json': { + message?: string; + error: string; + }; + }; + }; + /** @description HTTP 429 */ + 429: { + headers: { + [name: string]: unknown; + }; + content: { + /** + * @example { + * "message": "string", + * "error": "string" + * } + */ + 'application/json': { + message?: string; + error: string; + }; + }; + }; + /** @description HTTP 500 */ + 500: { + headers: { + [name: string]: unknown; + }; + content: { + /** + * @example { + * "message": "string", + * "error": "string" + * } + */ + 'application/json': { + message?: string; + error: string; + }; + }; + }; + }; + }; delete?: never; options?: never; head?: never; diff --git a/src/lib/adapterManifest.ts b/src/lib/adapterManifest.ts new file mode 100644 index 00000000..d657f7b7 --- /dev/null +++ b/src/lib/adapterManifest.ts @@ -0,0 +1,226 @@ +/* + * Copyright © 2026 Fells Code, LLC + * Licensed under the Apache License, Version 2.0 + * See LICENSE file in the project root for full license information + */ + +import { z, ZodTypeAny } from 'zod'; + +import { getPackageVersion } from '../openapi/document.js'; +import { + AdapterManifestRouteSchema, + AdapterManifestSchema, +} from '../schemas/adapterManifest.responses.js'; +import { AuthTokenType } from '../services/sessionService.js'; +import { expressToOpenAPI } from './convertPath.js'; + +export type AdapterManifestRoute = z.infer; +export type AdapterManifest = z.infer; +export type AdapterCredential = AdapterManifestRoute['credential']; + +/** + * What a route declares in `defineRoute`. `issues: 'session'` stores access and + * refresh; `issues: 'access'` reissues access without rotating refresh. `body.pick` + * applies to the adapter's cookie transport only. + */ +export type AdapterRouteDeclaration = + | false + | (Partial> & + Omit); + +/** + * The tokens a server adapter holds on the caller's behalf. The API only knows + * `ephemeral` and `access`, so which slot an ephemeral route reads is something only + * the route can say: signup and sign-in both run on ephemeral tokens, and an adapter + * that mixed them up would hand a registration token to a login endpoint. + */ +const ALLOWED_CREDENTIALS: Record = { + access: ['access'], + ephemeral: ['preAuth', 'registration'], + public: ['none', 'refresh'], +}; + +const DEFAULT_CREDENTIALS: Record = { + access: 'access', + ephemeral: undefined, + public: 'none', +}; + +const routes = new Map(); + +function unwrap(schema: ZodTypeAny): ZodTypeAny[] { + if (schema instanceof z.ZodObject) { + return [schema]; + } + + if (schema instanceof z.ZodUnion) { + return (schema.options as ZodTypeAny[]).flatMap(unwrap); + } + + if (schema instanceof z.ZodIntersection) { + return [...unwrap(schema.def.left as ZodTypeAny), ...unwrap(schema.def.right as ZodTypeAny)]; + } + + if (schema instanceof z.ZodPipe) { + return unwrap(schema.def.in as ZodTypeAny); + } + + if ( + schema instanceof z.ZodOptional || + schema instanceof z.ZodNullable || + schema instanceof z.ZodDefault + ) { + return unwrap(schema.unwrap() as ZodTypeAny); + } + + return []; +} + +function successSchemas(response: ZodTypeAny | Record | undefined) { + if (!response) { + return []; + } + + if (response instanceof z.ZodType) { + return [response]; + } + + return Object.entries(response) + .filter(([status]) => Number(status) >= 200 && Number(status) < 300) + .map(([, schema]) => schema); +} + +/** + * Whether a success response carries a token, and whether it always does. + * + * Only a required token makes `issues` mandatory. The shared `MessageSchema` has an + * optional `token` for the OTP and magic-link flows that re-mint the ephemeral token + * they arrived on, which an adapter already holds and does not need to store again. + */ +export function tokenInResponse( + response: ZodTypeAny | Record | undefined, +): 'required' | 'optional' | 'none' { + const fields = successSchemas(response) + .flatMap(unwrap) + .map((schema) => (schema as z.ZodObject).shape.token as ZodTypeAny | undefined) + .filter((field): field is ZodTypeAny => field !== undefined); + + if (fields.some((field) => !(field instanceof z.ZodOptional))) { + return 'required'; + } + + return fields.length > 0 ? 'optional' : 'none'; +} + +function normalizePath(path: string) { + const openApiPath = expressToOpenAPI(path); + + return openApiPath.length > 1 ? openApiPath.replace(/\/+$/, '') : openApiPath; +} + +interface RegisterAdapterRouteInput { + method: string; + path: string; + auth: AuthTokenType | undefined; + adapter: AdapterRouteDeclaration | undefined; + response: ZodTypeAny | Record | undefined; +} + +/** + * Validates a route's adapter declaration and records it for the manifest. + * + * Throws at registration, the way a missing decoy does. Every rule here guards a + * failure that is silent in an adapter: a public route exposed by accident, an + * ephemeral route reading the wrong token, or a route that issues a session the + * adapter never stores. + */ +export function registerAdapterRoute({ + method, + path, + auth, + adapter, + response, +}: RegisterAdapterRouteInput): void { + const label = `Route ${method.toUpperCase()} ${path}`; + const key = `${method.toUpperCase()} ${normalizePath(path)}`; + + if (adapter === false) { + routes.delete(key); + return; + } + + if (!auth && adapter === undefined) { + throw new Error( + `${label} takes no token but declares no adapter behaviour. Public routes must say ` + + 'whether server adapters expose them (`adapter: false` or an adapter object).', + ); + } + + const options = adapter ?? {}; + const scope = auth ?? 'public'; + const credential = options.credential ?? DEFAULT_CREDENTIALS[scope]; + + if (!credential) { + throw new Error( + `${label} accepts an ephemeral token but declares no adapter credential. ` + + "Say which token the adapter sends: 'preAuth' or 'registration'.", + ); + } + + if (!ALLOWED_CREDENTIALS[scope].includes(credential)) { + throw new Error( + `${label} is a ${scope} route and cannot use adapter credential '${credential}'. ` + + `Allowed: ${ALLOWED_CREDENTIALS[scope].join(', ')}.`, + ); + } + + const token = tokenInResponse(response); + + if (token === 'required' && !options.issues) { + throw new Error( + `${label} returns a token but declares no adapter \`issues\`. An adapter would ` + + 'pass the token through instead of storing it.', + ); + } + + if (options.issues && token === 'none') { + throw new Error( + `${label} declares adapter \`issues\` but no success response schema has a token.`, + ); + } + + if (options.body && !options.issues) { + throw new Error(`${label} declares an adapter body \`pick\` but issues nothing.`); + } + + routes.set(key, { + method: method.toUpperCase() as AdapterManifestRoute['method'], + path: normalizePath(path), + credential, + ...(options.issues ? { issues: options.issues } : {}), + ...(options.clears ? { clears: options.clears } : {}), + ...(options.body ? { body: options.body } : {}), + ...(options.delivery ? { delivery: options.delivery } : {}), + }); +} + +export function getAdapterManifest(): AdapterManifest { + return { + schemaVersion: 1, + apiVersion: getPackageVersion(), + session: { + subject: 'sub', + token: 'token', + refreshToken: 'refreshToken', + ttl: 'ttl', + refreshTtl: 'refreshTtl', + }, + routes: [...routes.values()].sort( + (a, b) => a.path.localeCompare(b.path) || a.method.localeCompare(b.method), + ), + }; +} + +export function resetAdapterManifest() { + routes.clear(); +} diff --git a/src/lib/defineRoute.ts b/src/lib/defineRoute.ts index f7eed8c3..af521fad 100644 --- a/src/lib/defineRoute.ts +++ b/src/lib/defineRoute.ts @@ -17,6 +17,7 @@ import { ErrorSchema, ValidationErrorSchema } from '../schemas/generic.responses import { AuthTokenType } from '../services/sessionService.js'; import { AuthenticatedRequest } from '../types/types.js'; import getLogger from '../utils/logger.js'; +import { AdapterRouteDeclaration, registerAdapterRoute } from './adapterManifest.js'; import { expressToOpenAPI } from './convertPath.js'; import { InferRequest, RouteSchemas } from './routeTypes.js'; import { generateExample } from './zodExample.js'; @@ -36,6 +37,12 @@ interface DefineRouteOptions { auth?: AuthTokenType | undefined; + /** + * What a server adapter does with this route. Required on public and ephemeral + * routes; access routes default to an exposed passthrough. See `adapterManifest.ts`. + */ + adapter?: AdapterRouteDeclaration; + schemas?: S; handler: (req: InferRequest, res: Response, next: NextFunction) => Promise | void; @@ -261,6 +268,14 @@ export function defineRoute( // eslint-disable-next-line @typescript-eslint/no-explicit-any const basePath = (router as any).__basePath ?? ''; + registerAdapterRoute({ + method, + path: `${basePath}${path}`, + auth: authType, + adapter: options.adapter, + response, + }); + registry.registerPath({ method, path: expressToOpenAPI(`${basePath}${path}`), diff --git a/src/routes/adapterManifest.routes.ts b/src/routes/adapterManifest.routes.ts new file mode 100644 index 00000000..ab5f1f40 --- /dev/null +++ b/src/routes/adapterManifest.routes.ts @@ -0,0 +1,32 @@ +/* + * Copyright © 2026 Fells Code, LLC + * Licensed under the Apache License, Version 2.0 + * See LICENSE file in the project root for full license information + */ + +import { adapterManifestHandler } from '../controllers/adapterManifest.js'; +import { createRouter } from '../lib/createRouter.js'; +import { AdapterManifestSchema } from '../schemas/adapterManifest.responses.js'; + +const adapterManifestRouter = createRouter(''); + +adapterManifestRouter.get( + '/.well-known/seamless-adapter.json', + { + adapter: false, + summary: 'Routes a server adapter exposes and what it does with each', + description: + 'Server adapters load this at startup instead of hard-coding routes. It describes which ' + + 'token each route takes and which tokens a response issues or clears.', + tags: ['Adapters'], + + schemas: { + response: { + 200: AdapterManifestSchema, + }, + }, + }, + adapterManifestHandler, +); + +export default adapterManifestRouter.router; diff --git a/src/routes/auth.routes.ts b/src/routes/auth.routes.ts index 51940792..8e117d80 100644 --- a/src/routes/auth.routes.ts +++ b/src/routes/auth.routes.ts @@ -23,6 +23,10 @@ const authRouter = createRouter(''); authRouter.post( '/login', { + adapter: { + issues: 'preAuth', + body: { pick: ['message', 'identifierType', 'loginMethods'] }, + }, summary: 'Login using identifier (email or phone)', tags: ['Authentication'], @@ -46,6 +50,7 @@ authRouter.post( authRouter.delete( '/logout', { + adapter: { clears: ['access', 'registration', 'refresh'] }, auth: 'access', summary: 'Logout current session', tags: ['Authentication'], @@ -63,6 +68,7 @@ authRouter.delete( authRouter.delete( '/logout/all', { + adapter: { clears: ['access', 'registration', 'refresh'] }, auth: 'access', summary: 'Logout all sessions for the current user', tags: ['Authentication'], @@ -79,6 +85,7 @@ authRouter.delete( authRouter.post( '/refresh', { + adapter: { credential: 'refresh', issues: 'session' }, summary: 'Refresh access token', tags: ['Authentication'], diff --git a/src/routes/conformance.routes.ts b/src/routes/conformance.routes.ts index 05af97c0..7f033ccd 100644 --- a/src/routes/conformance.routes.ts +++ b/src/routes/conformance.routes.ts @@ -33,25 +33,25 @@ if (conformanceModeEnabled()) { // the shape of a refusal both belong in the controller. conformanceRouter.post( '/attestation/options', - { summary: 'FIDO2 conformance: registration options', tags: ['Conformance'] }, + { adapter: false, summary: 'FIDO2 conformance: registration options', tags: ['Conformance'] }, attestationOptions, ); conformanceRouter.post( '/attestation/result', - { summary: 'FIDO2 conformance: registration result', tags: ['Conformance'] }, + { adapter: false, summary: 'FIDO2 conformance: registration result', tags: ['Conformance'] }, attestationResult, ); conformanceRouter.post( '/assertion/options', - { summary: 'FIDO2 conformance: authentication options', tags: ['Conformance'] }, + { adapter: false, summary: 'FIDO2 conformance: authentication options', tags: ['Conformance'] }, assertionOptions, ); conformanceRouter.post( '/assertion/result', - { summary: 'FIDO2 conformance: authentication result', tags: ['Conformance'] }, + { adapter: false, summary: 'FIDO2 conformance: authentication result', tags: ['Conformance'] }, assertionResult, ); } diff --git a/src/routes/health.routes.ts b/src/routes/health.routes.ts index 1cbd811c..7e38502a 100644 --- a/src/routes/health.routes.ts +++ b/src/routes/health.routes.ts @@ -13,6 +13,7 @@ const healthRouter = createRouter('/health'); healthRouter.get( '/status', { + adapter: false, summary: 'Health check endpoint', tags: ['Health'], @@ -28,6 +29,7 @@ healthRouter.get( healthRouter.get( '/version', { + adapter: false, summary: 'API version information', tags: ['Health'], diff --git a/src/routes/jwks.routes.ts b/src/routes/jwks.routes.ts index 318f8ed9..3f782b45 100644 --- a/src/routes/jwks.routes.ts +++ b/src/routes/jwks.routes.ts @@ -14,6 +14,7 @@ const jwksRouter = createRouter(''); jwksRouter.get( '/.well-known/jwks.json', { + adapter: false, summary: 'Public JSON Web Key Set', tags: ['JWKS'], diff --git a/src/routes/magicLink.routes.ts b/src/routes/magicLink.routes.ts index da55270f..587746a6 100644 --- a/src/routes/magicLink.routes.ts +++ b/src/routes/magicLink.routes.ts @@ -21,9 +21,36 @@ import { MagicLinkPollSuccessSchema } from '../schemas/magiclink.responses.js'; const magicLinkRouter = createRouter('/magic-link'); +magicLinkRouter.post( + '', + { + adapter: { credential: 'preAuth', delivery: true }, + auth: 'ephemeral', + summary: 'Request a magic login link', + decoy: decoyRequestMagicLink, + tags: ['MagicLinks'], + middleware: [magicLinkIpLimiter, magicLinkEmailLimiter], + + schemas: { + query: MagicLinkRequestQuerySchema, + response: { + 200: MessageSchema, + 400: ErrorSchema, + 403: ErrorSchema, + 500: InternalErrorSchema, + }, + }, + }, + requestMagicLink, +); + magicLinkRouter.get( '', { + adapter: false, + deprecated: true, + description: + 'Use POST. A GET that sends a message can be triggered cross-site without a CORS preflight.', auth: 'ephemeral', summary: 'Request a magic login link', decoy: decoyRequestMagicLink, @@ -46,6 +73,7 @@ magicLinkRouter.get( magicLinkRouter.get( '/check', { + adapter: { credential: 'preAuth', issues: 'session' }, auth: 'ephemeral', summary: 'Poll for magic link confirmation', decoy: decoyPollMagicLink, @@ -67,6 +95,7 @@ magicLinkRouter.get( magicLinkRouter.get( '/verify/:token', { + adapter: {}, summary: 'Verify magic link token', tags: ['MagicLinks'], diff --git a/src/routes/oauth.routes.ts b/src/routes/oauth.routes.ts index bb0b1caf..0c710c55 100644 --- a/src/routes/oauth.routes.ts +++ b/src/routes/oauth.routes.ts @@ -25,6 +25,7 @@ const oauthRouter = createRouter('/oauth'); oauthRouter.get( '/providers', { + adapter: {}, summary: 'List enabled OAuth providers', tags: ['OAuth'], schemas: { @@ -39,6 +40,7 @@ oauthRouter.get( oauthRouter.post( '/:providerId/start', { + adapter: {}, summary: 'Start OAuth login', tags: ['OAuth'], middleware: [oauthIpLimiter, oauthProviderLimiter], @@ -58,6 +60,7 @@ oauthRouter.post( oauthRouter.post( '/:providerId/callback', { + adapter: { issues: 'session' }, summary: 'Finish OAuth login', description: 'Answers 403 with `code: oauth_provider_retired` when an organization the user belongs to has retired this provider. A success carries `nextStep: enroll_passkey` when the provider has `promptPasskeyEnrollment` set and the user has no passkey yet.', diff --git a/src/routes/organization.routes.ts b/src/routes/organization.routes.ts index 76f3868a..c1fff063 100644 --- a/src/routes/organization.routes.ts +++ b/src/routes/organization.routes.ts @@ -104,6 +104,7 @@ organizationRouter.patch( organizationRouter.post( '/:organizationId/switch', { + adapter: { issues: 'access' }, auth: 'access', tags: ['Organizations'], summary: 'Switch active organization for the current session', diff --git a/src/routes/otp.routes.ts b/src/routes/otp.routes.ts index 4f07005b..2f748e57 100644 --- a/src/routes/otp.routes.ts +++ b/src/routes/otp.routes.ts @@ -32,9 +32,34 @@ import { OTPVerifyTokenSuccessSchema } from '../schemas/otp.responses.js'; const otpRouter = createRouter('/otp'); +otpRouter.post( + '/generate-email-otp', + { + adapter: { credential: 'registration', delivery: true }, + auth: 'ephemeral', + summary: 'Generate email OTP', + decoy: decoySendEmailOtp, + tags: ['OTP'], + middleware: [otpIpLimiter, otpIdentityLimiter], + + schemas: { + response: { + 200: MessageSchema, + 400: ErrorSchema, + 500: InternalErrorSchema, + }, + }, + }, + sendEmailOTP, +); + otpRouter.get( '/generate-email-otp', { + adapter: false, + deprecated: true, + description: + 'Use POST. A GET that sends a message can be triggered cross-site without a CORS preflight.', auth: 'ephemeral', summary: 'Generate email OTP', decoy: decoySendEmailOtp, @@ -52,9 +77,34 @@ otpRouter.get( sendEmailOTP, ); +otpRouter.post( + '/generate-phone-otp', + { + adapter: { credential: 'registration', delivery: true }, + auth: 'ephemeral', + summary: 'Generate phone OTP', + decoy: decoySendPhoneOtp, + tags: ['OTP'], + middleware: [otpIpLimiter, otpIdentityLimiter], + + schemas: { + response: { + 200: MessageSchema, + 400: ErrorSchema, + 500: InternalErrorSchema, + }, + }, + }, + sendPhoneOTP, +); + otpRouter.get( '/generate-phone-otp', { + adapter: false, + deprecated: true, + description: + 'Use POST. A GET that sends a message can be triggered cross-site without a CORS preflight.', auth: 'ephemeral', summary: 'Generate phone OTP', decoy: decoySendPhoneOtp, @@ -72,9 +122,33 @@ otpRouter.get( sendPhoneOTP, ); +otpRouter.post( + '/generate-login-email-otp', + { + adapter: { credential: 'preAuth', delivery: true }, + auth: 'ephemeral', + summary: 'Generate login email OTP', + decoy: decoySendLoginEmailOtp, + tags: ['OTP'], + middleware: [otpIpLimiter, otpIdentityLimiter], + + schemas: { + response: { + 200: MessageSchema, + 403: ErrorSchema, + }, + }, + }, + sendLoginEmailOTP, +); + otpRouter.get( '/generate-login-email-otp', { + adapter: false, + deprecated: true, + description: + 'Use POST. A GET that sends a message can be triggered cross-site without a CORS preflight.', auth: 'ephemeral', summary: 'Generate login email OTP', decoy: decoySendLoginEmailOtp, @@ -91,9 +165,33 @@ otpRouter.get( sendLoginEmailOTP, ); +otpRouter.post( + '/generate-login-phone-otp', + { + adapter: { credential: 'preAuth', delivery: true }, + auth: 'ephemeral', + summary: 'Generate login phone OTP', + decoy: decoySendLoginPhoneOtp, + tags: ['OTP'], + middleware: [otpIpLimiter, otpIdentityLimiter], + + schemas: { + response: { + 200: MessageSchema, + 403: ErrorSchema, + }, + }, + }, + sendLoginPhoneOTP, +); + otpRouter.get( '/generate-login-phone-otp', { + adapter: false, + deprecated: true, + description: + 'Use POST. A GET that sends a message can be triggered cross-site without a CORS preflight.', auth: 'ephemeral', summary: 'Generate login phone OTP', decoy: decoySendLoginPhoneOtp, @@ -113,6 +211,7 @@ otpRouter.get( otpRouter.post( '/verify-email-otp', { + adapter: { credential: 'registration', issues: 'session' }, auth: 'ephemeral', summary: 'Verify email OTP', decoy: decoyVerifyEmailOtp, @@ -136,6 +235,7 @@ otpRouter.post( otpRouter.post( '/verify-phone-otp', { + adapter: { credential: 'registration', issues: 'session' }, auth: 'ephemeral', summary: 'Verify phone OTP', decoy: decoyVerifyPhoneOtp, @@ -159,6 +259,7 @@ otpRouter.post( otpRouter.post( '/verify-login-email-otp', { + adapter: { credential: 'preAuth', issues: 'session' }, auth: 'ephemeral', summary: 'Verify login email OTP', decoy: decoyVerifyLoginEmailOtp, @@ -181,6 +282,7 @@ otpRouter.post( otpRouter.post( '/verify-login-phone-otp', { + adapter: { credential: 'preAuth', issues: 'session' }, auth: 'ephemeral', summary: 'Verify login phone OTP', decoy: decoyVerifyLoginPhoneOtp, diff --git a/src/routes/registration.routes.ts b/src/routes/registration.routes.ts index 129125e5..8e370f92 100644 --- a/src/routes/registration.routes.ts +++ b/src/routes/registration.routes.ts @@ -24,6 +24,7 @@ const registrationRouter = createRouter('/registration'); registrationRouter.post( '/register', { + adapter: { issues: 'registration' }, summary: 'Register a new user', tags: ['Registration'], middleware: [otpIpLimiter, otpIdentityLimiter], diff --git a/src/routes/systemConfig.routes.ts b/src/routes/systemConfig.routes.ts index 5aa8fd35..d969d8c3 100644 --- a/src/routes/systemConfig.routes.ts +++ b/src/routes/systemConfig.routes.ts @@ -47,6 +47,7 @@ const systemConfigRouter = createRouter('/system-config'); systemConfigRouter.get( '/public', { + adapter: {}, summary: 'Read the publicly visible system configuration', tags: ['SystemConfig'], diff --git a/src/routes/totp.routes.ts b/src/routes/totp.routes.ts index ab6dea87..b7c7fa38 100644 --- a/src/routes/totp.routes.ts +++ b/src/routes/totp.routes.ts @@ -105,6 +105,7 @@ totpRouter.post( totpRouter.post( '/verify-login', { + adapter: { credential: 'preAuth', issues: 'session' }, middleware: [otpIpLimiter, otpIdentityLimiter], auth: 'ephemeral', summary: 'Verify TOTP during login', diff --git a/src/routes/users.routes.ts b/src/routes/users.routes.ts index 1da4d7de..8efacaaf 100644 --- a/src/routes/users.routes.ts +++ b/src/routes/users.routes.ts @@ -17,6 +17,7 @@ const usersRouter = createRouter('/users'); usersRouter.get( '/me', { + adapter: { clears: ['preAuth'] }, auth: 'access', tags: ['Users'], summary: 'Get authenticated user', @@ -47,6 +48,7 @@ usersRouter.post( usersRouter.delete( '/delete', { + adapter: { clears: ['access', 'registration', 'refresh'] }, auth: 'access', tags: ['Users'], summary: 'Delete authenticated user', diff --git a/src/routes/webauthn.routes.ts b/src/routes/webauthn.routes.ts index b8d4b711..05aed21b 100644 --- a/src/routes/webauthn.routes.ts +++ b/src/routes/webauthn.routes.ts @@ -82,6 +82,7 @@ webauthnRouter.post( webauthnRouter.post( '/login/start', { + adapter: { credential: 'preAuth' }, auth: 'ephemeral', summary: 'Start WebAuthn login', decoy: decoyStartWebAuthnLogin, @@ -104,6 +105,7 @@ webauthnRouter.post( webauthnRouter.post( '/login/finish', { + adapter: { credential: 'preAuth', issues: 'session' }, auth: 'ephemeral', summary: 'Finish WebAuthn login', decoy: decoyFinishWebAuthnLogin, diff --git a/src/schemas/adapterManifest.responses.ts b/src/schemas/adapterManifest.responses.ts new file mode 100644 index 00000000..8f23b596 --- /dev/null +++ b/src/schemas/adapterManifest.responses.ts @@ -0,0 +1,32 @@ +/* + * Copyright © 2026 Fells Code, LLC + * Licensed under the Apache License, Version 2.0 + * See LICENSE file in the project root for full license information + */ + +import { z } from 'zod'; + +const AdapterHeldSchema = z.enum(['preAuth', 'registration', 'access', 'refresh']); + +export const AdapterManifestRouteSchema = z.object({ + method: z.enum(['GET', 'POST', 'PUT', 'PATCH', 'DELETE']), + path: z.string(), + credential: z.enum(['none', 'preAuth', 'registration', 'access', 'refresh']), + issues: z.enum(['preAuth', 'registration', 'session', 'access']).optional(), + clears: z.array(AdapterHeldSchema).optional(), + body: z.object({ pick: z.array(z.string()) }).optional(), + delivery: z.literal(true).optional(), +}); + +export const AdapterManifestSchema = z.object({ + schemaVersion: z.literal(1), + apiVersion: z.string(), + session: z.object({ + subject: z.literal('sub'), + token: z.literal('token'), + refreshToken: z.literal('refreshToken'), + ttl: z.literal('ttl'), + refreshTtl: z.literal('refreshTtl'), + }), + routes: z.array(AdapterManifestRouteSchema), +}); diff --git a/src/scripts/generateApiTypes.ts b/src/scripts/generateApiTypes.ts index 3d4d8a51..94588fe5 100644 --- a/src/scripts/generateApiTypes.ts +++ b/src/scripts/generateApiTypes.ts @@ -5,7 +5,8 @@ */ /** - * Emits the OpenAPI document and the typed client from the live route definitions. + * Emits the OpenAPI document, the typed client and the adapter manifest from the live + * route definitions. * * The registry is populated as a side effect of importing the route modules, which is * why this loads routes into a throwaway Express app rather than reading a checked-in @@ -22,6 +23,7 @@ import openapiTS, { astToString } from 'openapi-typescript'; import path from 'path'; import { format, resolveConfig } from 'prettier'; +import { getAdapterManifest } from '../lib/adapterManifest.js'; import { loadRoutes } from '../lib/loadRoutes.js'; import { generateOpenApiDocument } from '../openapi/document.js'; @@ -43,6 +45,7 @@ const GENERATED_NOTICE = ` const SPEC_PATH = path.resolve(process.cwd(), 'openapi.json'); const TYPES_PATH = path.resolve(process.cwd(), 'src/generated/api.ts'); +const MANIFEST_PATH = path.resolve(process.cwd(), 'adapter-manifest.json'); // Formatted here rather than left to the commit hook, so regenerating never leaves the // tree failing `format:check`. @@ -70,13 +73,19 @@ export async function generateApiTypes() { await mkdir(path.dirname(TYPES_PATH), { recursive: true }); await writeFile(SPEC_PATH, await formatAs(JSON.stringify(document), SPEC_PATH)); await writeFile(TYPES_PATH, await formatAs(types, TYPES_PATH)); + await writeFile( + MANIFEST_PATH, + await formatAs(JSON.stringify(getAdapterManifest()), MANIFEST_PATH), + ); - return { pathCount, specPath: SPEC_PATH, typesPath: TYPES_PATH }; + return { pathCount, specPath: SPEC_PATH, typesPath: TYPES_PATH, manifestPath: MANIFEST_PATH }; } const invokedDirectly = process.argv[1] && import.meta.url.endsWith(path.basename(process.argv[1])); if (invokedDirectly) { const { pathCount } = await generateApiTypes(); - console.log(`Generated openapi.json and src/generated/api.ts from ${pathCount} paths.`); + console.log( + `Generated openapi.json, src/generated/api.ts and adapter-manifest.json from ${pathCount} paths.`, + ); } diff --git a/tests/unit/lib/adapterManifest.spec.ts b/tests/unit/lib/adapterManifest.spec.ts new file mode 100644 index 00000000..1b69a289 --- /dev/null +++ b/tests/unit/lib/adapterManifest.spec.ts @@ -0,0 +1,159 @@ +import { beforeEach, describe, expect, it } from 'vitest'; +import { z } from 'zod'; + +import { + getAdapterManifest, + registerAdapterRoute, + resetAdapterManifest, + tokenInResponse, +} from '../../../src/lib/adapterManifest'; + +const Message = z.object({ message: z.string() }); +const Session = z.object({ sub: z.string(), token: z.string(), ttl: z.number() }); +const MaybeToken = z.object({ message: z.string(), token: z.string().optional() }); + +function register(overrides: Partial[0]>) { + registerAdapterRoute({ + method: 'post', + path: '/thing', + auth: undefined, + adapter: undefined, + response: { 200: Message }, + ...overrides, + }); +} + +beforeEach(() => { + resetAdapterManifest(); +}); + +describe('tokenInResponse', () => { + it('reports a required token', () => { + expect(tokenInResponse({ 200: Session })).toBe('required'); + }); + + it('reports an optional token', () => { + expect(tokenInResponse({ 200: MaybeToken })).toBe('optional'); + }); + + it('reports no token', () => { + expect(tokenInResponse({ 200: Message })).toBe('none'); + expect(tokenInResponse(undefined)).toBe('none'); + }); + + it('looks inside unions, intersections and transforms', () => { + expect(tokenInResponse({ 200: z.union([Message, Session]) })).toBe('required'); + expect(tokenInResponse({ 200: z.intersection(Message, Session) })).toBe('required'); + expect(tokenInResponse({ 200: Session.transform((value) => value) })).toBe('required'); + }); + + it('accepts a bare schema as the 200 response', () => { + expect(tokenInResponse(Session)).toBe('required'); + }); + + it('ignores error responses', () => { + expect(tokenInResponse({ 200: Message, 401: Session })).toBe('none'); + }); +}); + +describe('registerAdapterRoute', () => { + it('defaults an access route to an exposed passthrough', () => { + register({ method: 'get', path: '/users/:id', auth: 'access' }); + + expect(getAdapterManifest().routes).toEqual([ + { method: 'GET', path: '/users/{id}', credential: 'access' }, + ]); + }); + + it('refuses a public route that does not say whether adapters expose it', () => { + expect(() => register({})).toThrow(/declares no adapter behaviour/); + }); + + it('leaves a route marked false out of the manifest', () => { + register({ adapter: false }); + register({ auth: 'access', adapter: false, response: { 200: Session } }); + + expect(getAdapterManifest().routes).toEqual([]); + }); + + it('refuses an ephemeral route with no credential', () => { + expect(() => register({ auth: 'ephemeral', adapter: {} })).toThrow( + /declares no adapter credential/, + ); + }); + + it.each([ + ['access', 'preAuth'], + ['ephemeral', 'access'], + [undefined, 'access'], + [undefined, 'registration'], + ] as const)('refuses a %s route that sends the %s credential', (auth, credential) => { + expect(() => register({ auth, adapter: { credential } })).toThrow( + /cannot use adapter credential/, + ); + }); + + it('refuses a route that always returns a token but issues nothing', () => { + expect(() => register({ auth: 'access', response: { 200: Session } })).toThrow( + /returns a token but declares no adapter `issues`/, + ); + }); + + it('allows a route whose token is optional to issue nothing', () => { + expect(() => + register({ + auth: 'ephemeral', + adapter: { credential: 'preAuth' }, + response: { 200: MaybeToken }, + }), + ).not.toThrow(); + }); + + it('refuses issues on a route with no token in its response', () => { + expect(() => register({ adapter: { issues: 'session' } })).toThrow( + /no success response schema has a token/, + ); + }); + + it('refuses a body pick on a route that issues nothing', () => { + expect(() => register({ adapter: { body: { pick: ['message'] } } })).toThrow(/issues nothing/); + }); + + it('records every declared field', () => { + register({ + path: '/login/', + adapter: { issues: 'preAuth', body: { pick: ['message'] } }, + response: { 200: MaybeToken }, + }); + register({ + method: 'delete', + path: '/logout', + auth: 'access', + adapter: { clears: ['access', 'refresh'] }, + }); + register({ + path: '/send', + auth: 'ephemeral', + adapter: { credential: 'registration', delivery: true }, + }); + + expect(getAdapterManifest().routes).toEqual([ + { + method: 'POST', + path: '/login', + credential: 'none', + issues: 'preAuth', + body: { pick: ['message'] }, + }, + { method: 'DELETE', path: '/logout', credential: 'access', clears: ['access', 'refresh'] }, + { method: 'POST', path: '/send', credential: 'registration', delivery: true }, + ]); + }); + + it('keeps one entry per method and path when a route is registered again', () => { + register({ auth: 'access' }); + register({ auth: 'access' }); + + expect(getAdapterManifest().routes).toHaveLength(1); + }); +}); diff --git a/tests/unit/lib/defineRoute.spec.ts b/tests/unit/lib/defineRoute.spec.ts index b4a38b88..62bd5b5f 100644 --- a/tests/unit/lib/defineRoute.spec.ts +++ b/tests/unit/lib/defineRoute.spec.ts @@ -15,6 +15,11 @@ vi.mock('../../../src/openapi/registry', () => ({ }, })); +// Adapter declarations have their own spec; these routes are fixtures for other behaviour. +vi.mock('../../../src/lib/adapterManifest', () => ({ + registerAdapterRoute: vi.fn(), +})); + describe('defineRoute', () => { beforeEach(() => { vi.resetModules(); diff --git a/tests/unit/lib/defineRouteDecoy.spec.ts b/tests/unit/lib/defineRouteDecoy.spec.ts index b1f547d3..5aae5e53 100644 --- a/tests/unit/lib/defineRouteDecoy.spec.ts +++ b/tests/unit/lib/defineRouteDecoy.spec.ts @@ -6,6 +6,11 @@ vi.mock('../../../src/openapi/registry', () => ({ registry: { registerPath: vi.fn() }, })); +// Adapter declarations have their own spec; these routes are fixtures for other behaviour. +vi.mock('../../../src/lib/adapterManifest', () => ({ + registerAdapterRoute: vi.fn(), +})); + const schemas = { response: { 200: z.object({ message: z.string() }), diff --git a/tests/unit/routes/adapterManifest.routes.spec.ts b/tests/unit/routes/adapterManifest.routes.spec.ts new file mode 100644 index 00000000..7f9cda1f --- /dev/null +++ b/tests/unit/routes/adapterManifest.routes.spec.ts @@ -0,0 +1,98 @@ +import { readFileSync } from 'node:fs'; +import { dirname, join } from 'node:path'; +import { fileURLToPath } from 'node:url'; + +import express from 'express'; +import request from 'supertest'; +import { beforeAll, describe, expect, it } from 'vitest'; + +import { AdapterManifest, getAdapterManifest } from '../../../src/lib/adapterManifest'; +import { loadRoutes } from '../../../src/lib/loadRoutes'; +import { AdapterManifestSchema } from '../../../src/schemas/adapterManifest.responses'; + +const repoRoot = join(dirname(fileURLToPath(import.meta.url)), '../../..'); + +let manifest: AdapterManifest; +let app: express.Express; + +function route(method: string, path: string) { + return manifest.routes.find((entry) => entry.method === method && entry.path === path); +} + +beforeAll(async () => { + app = express(); + await loadRoutes(app); + manifest = getAdapterManifest(); +}); + +describe('adapter manifest for the live routes', () => { + it('is served at the well-known path in the published shape', async () => { + const res = await request(app).get('/.well-known/seamless-adapter.json'); + + expect(res.status).toBe(200); + expect(AdapterManifestSchema.parse(res.body)).toEqual(manifest); + }); + + it('matches the committed adapter-manifest.json', () => { + const committed = JSON.parse(readFileSync(join(repoRoot, 'adapter-manifest.json'), 'utf8')); + + // The version tracks package.json, which Changesets bumps without regenerating. + expect({ ...manifest, apiVersion: undefined }).toEqual({ ...committed, apiVersion: undefined }); + }); + + it('exposes none of the operational or conformance surfaces', () => { + const hidden = manifest.routes.filter((entry) => + /^\/(health|conformance|\.well-known)\b/.test(entry.path), + ); + + expect(hidden).toEqual([]); + }); + + // A GET is sent cross-site without a CORS preflight, so a route that sends a message + // must only reach adapters as POST. + it('only exposes message-sending routes as POST', () => { + const delivery = manifest.routes.filter((entry) => entry.delivery); + + expect(delivery.map((entry) => `${entry.method} ${entry.path}`).sort()).toEqual([ + 'POST /magic-link', + 'POST /otp/generate-email-otp', + 'POST /otp/generate-login-email-otp', + 'POST /otp/generate-login-phone-otp', + 'POST /otp/generate-phone-otp', + ]); + }); + + it('lists every route that issues a session', () => { + const issuing = manifest.routes + .filter((entry) => entry.issues) + .map((entry) => `${entry.method} ${entry.path} ${entry.credential} -> ${entry.issues}`) + .sort(); + + expect(issuing).toEqual([ + 'GET /magic-link/check preAuth -> session', + 'POST /login none -> preAuth', + 'POST /oauth/{providerId}/callback none -> session', + 'POST /organizations/{organizationId}/switch access -> access', + 'POST /otp/verify-email-otp registration -> session', + 'POST /otp/verify-login-email-otp preAuth -> session', + 'POST /otp/verify-login-phone-otp preAuth -> session', + 'POST /otp/verify-phone-otp registration -> session', + 'POST /refresh refresh -> session', + 'POST /registration/register none -> registration', + 'POST /totp/verify-login preAuth -> session', + 'POST /webauthn/login/finish preAuth -> session', + ]); + }); + + it('keeps /login narrowed to the fields the sign-in screen reads', () => { + expect(route('POST', '/login')?.body).toEqual({ + pick: ['message', 'identifierType', 'loginMethods'], + }); + }); + + it('clears every held token on sign-out and account deletion', () => { + for (const path of ['/logout', '/logout/all', '/users/delete']) { + expect(route('DELETE', path)?.clears).toEqual(['access', 'registration', 'refresh']); + } + }); +});