mirror of
https://github.com/saltbo/zpan.git
synced 2026-08-28 15:51:29 +08:00
fix(openapi): whitelist Better Auth downloader Device Flow (#552)
Publish only explicitly registered Better Auth operations in the ZPan product OpenAPI contract while preserving runtime auth routes and discovery.
This commit is contained in:
@@ -416,7 +416,7 @@ func (c *Client) SeedingTasks(ctx context.Context) ([]DownloadTask, error) {
|
||||
|
||||
func (c *Client) RequestDeviceCode(ctx context.Context) (DeviceCode, error) {
|
||||
scope := "downloader:register"
|
||||
res, err := c.api.PostApiAuthDeviceCodeWithResponse(ctx, openapi.PostApiAuthDeviceCodeJSONRequestBody{
|
||||
res, err := c.api.CreateDeviceAuthorizationWithResponse(ctx, openapi.CreateDeviceAuthorizationJSONRequestBody{
|
||||
ClientId: "zpan-cli",
|
||||
Scope: &scope,
|
||||
})
|
||||
@@ -440,7 +440,7 @@ func (c *Client) RequestDeviceCode(ctx context.Context) (DeviceCode, error) {
|
||||
}
|
||||
|
||||
func (c *Client) PollDeviceToken(ctx context.Context, deviceCode string) (DeviceToken, error) {
|
||||
res, err := c.api.PostApiAuthDeviceTokenWithResponse(ctx, openapi.PostApiAuthDeviceTokenJSONRequestBody{
|
||||
res, err := c.api.CreateDeviceAccessTokenWithResponse(ctx, openapi.CreateDeviceAccessTokenJSONRequestBody{
|
||||
GrantType: "urn:ietf:params:oauth:grant-type:device_code",
|
||||
DeviceCode: deviceCode,
|
||||
ClientId: "zpan-cli",
|
||||
|
||||
+2486
-26889
File diff suppressed because it is too large
Load Diff
+10
-4
@@ -147,8 +147,11 @@ The server follows a ports-and-adapters shape.
|
||||
### `server/app.ts`
|
||||
|
||||
Creates the Hono app, installs global middleware, exposes OpenAPI/Scalar docs,
|
||||
mounts WebDAV, and mounts each API resource. It also merges Better Auth's
|
||||
generated OpenAPI schema into `/api/openapi.json`.
|
||||
mounts WebDAV, and mounts each API resource. `/api/openapi.json` includes ZPan's
|
||||
explicit resource contracts plus operations admitted by the declarative Better
|
||||
Auth OpenAPI registry. The registry currently contains only the two Downloader
|
||||
Device Flow operations. Better Auth's full runtime schema remains available
|
||||
only from its own reference endpoints.
|
||||
|
||||
### `server/http/`
|
||||
|
||||
@@ -311,8 +314,11 @@ external URLs that are not ZPan APIs, such as presigned S3 upload URLs.
|
||||
|
||||
- `/api/*` — primary JSON API, mounted from route modules in `server/http/`
|
||||
- `/api/auth/*` — Better Auth routes
|
||||
- `/api/openapi.json` — combined OpenAPI document for ZPan routes and Better
|
||||
Auth routes
|
||||
- `/api/openapi.json` — public ZPan product contract; Better Auth operations are
|
||||
denied by default and currently only the registered Downloader Device Flow
|
||||
operations are included
|
||||
- `/api/auth/reference` and `/api/auth/open-api/generate-schema` — Better Auth's
|
||||
complete reference UI and generated runtime schema
|
||||
- `/api/docs` — Scalar API reference
|
||||
- `/dav/*` — WebDAV endpoint
|
||||
- `/api/events` — one resumable server-sent event stream for scoped durable
|
||||
|
||||
@@ -56,9 +56,23 @@ OpenAPI uses standard `security` declarations. Every protected ZPan operation
|
||||
declares its OAuth scopes, plus cookie and bearer alternatives. Role constraints
|
||||
that OpenAPI cannot express use the narrow
|
||||
`x-zpan-authorization-constraints` extension. Better Auth operations and their
|
||||
generated OpenAPI definitions remain owned by Better Auth. ZPan augments the
|
||||
dynamic-registration response and adds the RFC 7592 configuration endpoint that
|
||||
is implemented at its auth boundary.
|
||||
complete generated definitions remain owned by Better Auth and available from
|
||||
its reference endpoints. The public product contract uses a deny-by-default
|
||||
operation registry. Each admitted entry names the exact source path and method,
|
||||
public path, stable operation ID, tags, and security policy; it may also declare
|
||||
a narrow contract correction. The aggregator copies only path-item parameters
|
||||
and the transitive local component closure reachable from that operation,
|
||||
rejecting missing sources, collisions, dangling references, and external
|
||||
references.
|
||||
|
||||
The registry currently imports only `POST /device/code` and `POST
|
||||
/device/token`, under their `/api/auth` runtime mount, for the Downloader Device
|
||||
Flow protocol; both explicitly require no existing session or bearer
|
||||
credential. Adding a Better Auth operation to the product contract is therefore
|
||||
an explicit contract and authorization decision made in one registry entry, not
|
||||
an automatic consequence of installing or updating a plugin. ZPan separately
|
||||
publishes its dynamic-registration and RFC 7592 configuration operations
|
||||
implemented at the auth boundary.
|
||||
|
||||
## Workspace Authorization Details
|
||||
|
||||
|
||||
+10
-10
@@ -1,7 +1,9 @@
|
||||
// Generates the downloader's Go OpenAPI client (cmd/internal/openapi/client.gen.go)
|
||||
// straight from the complete, live /api/openapi.json — no committed intermediate
|
||||
// spec, no hand-curated subset. The spec is written to a temp file only so
|
||||
// oapi-codegen has something to read, then discarded.
|
||||
// straight from the live public product contract at /api/openapi.json, including
|
||||
// its explicitly registered Better Auth operations (currently Downloader
|
||||
// Device Flow). Code generation applies no further endpoint subset and commits
|
||||
// no intermediate spec; the temporary spec exists only as oapi-codegen input
|
||||
// and is discarded.
|
||||
//
|
||||
// pnpm openapi:client regenerate the committed Go client
|
||||
// pnpm openapi:client --check fail if the committed client is stale
|
||||
@@ -36,10 +38,9 @@ function downconvertTo30(node: unknown): void {
|
||||
for (const v of Object.values(obj)) downconvertTo30(v)
|
||||
}
|
||||
|
||||
// The client attaches its bearer token manually via a RequestEditorFn, so it
|
||||
// needs no security metadata. Strip it: better-auth's bearerAuth scheme otherwise
|
||||
// makes oapi-codegen (client-only) emit a `BearerAuthScopes` const whose
|
||||
// context-key type is only generated in server mode → undefined symbol.
|
||||
// The client attaches credentials manually via a RequestEditorFn, so the
|
||||
// generated transport needs no product-wide security metadata. Stripping it
|
||||
// also avoids client-only oapi-codegen emitting server-mode scope helpers.
|
||||
function stripSecurity(doc: Doc): void {
|
||||
delete (doc as Record<string, unknown>).security
|
||||
if (doc.components) delete (doc.components as Record<string, unknown>).securitySchemes
|
||||
@@ -53,9 +54,8 @@ function stripSecurity(doc: Doc): void {
|
||||
|
||||
const HTTP_METHODS = new Set(['get', 'post', 'put', 'patch', 'delete', 'head', 'options', 'trace'])
|
||||
|
||||
// better-auth omits path-parameter declarations on some operations (e.g.
|
||||
// /api/auth/callback/{id}), which oapi-codegen rejects. Declare any `{param}`
|
||||
// segment that an operation is missing. Whole-document, mechanical.
|
||||
// Declare any `{param}` segment that an operation is missing because
|
||||
// oapi-codegen rejects undeclared path parameters. Whole-document, mechanical.
|
||||
function declareMissingPathParams(doc: Doc): void {
|
||||
for (const [path, item] of Object.entries(doc.paths)) {
|
||||
const names = [...path.matchAll(/\{([^}]+)\}/g)].map((m) => m[1])
|
||||
|
||||
+17
-40
@@ -15,6 +15,7 @@ import { adminStats } from './http/admin-stats'
|
||||
import { ARAZZO_DOCUMENT_PATH, ARAZZO_MEDIA_TYPE, createArazzoDocument } from './http/arazzo'
|
||||
import { serveAvatarBlob } from './http/avatar-blobs'
|
||||
import backgroundJobs from './http/background-jobs'
|
||||
import { addRegisteredBetterAuthOpenApiOperations, DOWNLOADER_DEVICE_FLOW_TAG } from './http/better-auth-openapi'
|
||||
import { configz } from './http/configz'
|
||||
import downloadTasks, { downloaderTasksRoute } from './http/downloads/download-tasks'
|
||||
import downloaders, { downloaderSelfRoute } from './http/downloads/downloaders'
|
||||
@@ -224,10 +225,10 @@ export function createApp(platform: Platform, auth: Auth, deps: Deps = createDep
|
||||
return c.newResponse(JSON.stringify(createArazzoDocument(new URL(c.req.url).origin)), 200, headers)
|
||||
})
|
||||
|
||||
// Global OpenAPI document. Aggregates every route defined with `.openapi()`
|
||||
// across all mounted sub-apps — a route appears here as soon as its resource is
|
||||
// converted to OpenAPIHono, no curation needed. better-auth endpoints (incl. the
|
||||
// device flow) document themselves separately at /api/auth/reference.
|
||||
// Global OpenAPI document. ZPan routes defined with `.openapi()` are
|
||||
// aggregated across all mounted sub-apps. Better Auth documents its complete
|
||||
// runtime surface separately at /api/auth/reference; only the Downloader
|
||||
// Device Flow protocol is explicitly admitted to this product contract.
|
||||
app.get('/api/openapi.json', async (c) => {
|
||||
const doc = app.getOpenAPIDocument({
|
||||
openapi: '3.1.0',
|
||||
@@ -243,41 +244,13 @@ export function createApp(platform: Platform, auth: Auth, deps: Deps = createDep
|
||||
{ name: 'Events', description: 'Multiplexed server-sent event stream' },
|
||||
{ name: 'Download Tasks', description: 'Remote download tasks' },
|
||||
{ name: 'Downloaders', description: 'Download agents and their heartbeats' },
|
||||
{
|
||||
name: DOWNLOADER_DEVICE_FLOW_TAG,
|
||||
description: 'Public device authorization protocol used by downloader clients',
|
||||
},
|
||||
],
|
||||
})
|
||||
|
||||
// Merge better-auth's own auto-generated schema (sign-in/up, organization,
|
||||
// the device-authorization flow, …) into the same document. Both halves are
|
||||
// generated — nothing here is a hand-maintained endpoint definition; new
|
||||
// better-auth endpoints appear automatically. Its paths are relative to the
|
||||
// /api/auth mount, so prefix them.
|
||||
const authDoc = (await c.get('auth').api.generateOpenAPISchema()) as {
|
||||
paths?: Record<string, unknown>
|
||||
components?: { schemas?: Record<string, unknown> }
|
||||
}
|
||||
for (const [path, item] of Object.entries(authDoc.paths ?? {})) {
|
||||
doc.paths[`/api/auth${path}`] = item as (typeof doc.paths)[string]
|
||||
}
|
||||
// better-auth 1.7.0-rc.2 documents POST /device/token with its session
|
||||
// response even though the handler returns an OAuth device token. Keep the
|
||||
// generated contract aligned with the wire response until upstream fixes it.
|
||||
const deviceTokenJson = (
|
||||
doc.paths['/api/auth/device/token'] as
|
||||
| { post?: { responses?: Record<string, { content?: Record<string, { schema?: unknown }> }> } }
|
||||
| undefined
|
||||
)?.post?.responses?.['200']?.content?.['application/json']
|
||||
if (deviceTokenJson) {
|
||||
deviceTokenJson.schema = {
|
||||
type: 'object',
|
||||
properties: {
|
||||
access_token: { type: 'string' },
|
||||
token_type: { type: 'string' },
|
||||
expires_in: { type: 'integer' },
|
||||
scope: { type: 'string' },
|
||||
},
|
||||
required: ['access_token', 'token_type', 'expires_in'],
|
||||
}
|
||||
}
|
||||
doc.components ??= {}
|
||||
doc.components.securitySchemes = {
|
||||
...(doc.components.securitySchemes ?? {}),
|
||||
@@ -298,10 +271,14 @@ export function createApp(platform: Platform, auth: Auth, deps: Deps = createDep
|
||||
},
|
||||
}
|
||||
addOAuthClientRegistrationManagementOpenApi(doc)
|
||||
doc.components.schemas = {
|
||||
...(authDoc.components?.schemas as typeof doc.components.schemas),
|
||||
...doc.components.schemas,
|
||||
}
|
||||
|
||||
// Better Auth owns the runtime routes and its complete reference schema.
|
||||
// The product contract imports only operations whose path, method, public
|
||||
// identity, tags, security, and any narrow correction are registered
|
||||
// explicitly. Reachable components are copied transitively; everything
|
||||
// else remains private by default.
|
||||
const authDoc = await c.get('auth').api.generateOpenAPISchema()
|
||||
addRegisteredBetterAuthOpenApiOperations(doc, authDoc)
|
||||
Object.assign(doc, {
|
||||
'x-zpan-discovery': {
|
||||
oauthAuthorizationServer: '/.well-known/oauth-authorization-server/api/auth',
|
||||
|
||||
+5
-4
@@ -648,10 +648,11 @@ export async function createAuth(
|
||||
},
|
||||
plugins: [
|
||||
admin(),
|
||||
// Self-documents every better-auth endpoint (incl. the device-authorization
|
||||
// flow) at GET /api/auth/reference (Scalar UI) and
|
||||
// /api/auth/open-api/generate-schema. Replaces the old hand-written device
|
||||
// route stubs; our own routes live in the global doc at /api/openapi.json.
|
||||
// Self-documents Better Auth's complete runtime surface at
|
||||
// GET /api/auth/reference (Scalar UI) and
|
||||
// /api/auth/open-api/generate-schema. The global product contract at
|
||||
// /api/openapi.json admits only explicitly registered path/methods
|
||||
// (currently the Downloader Device Flow protocol).
|
||||
openAPI(),
|
||||
organization({
|
||||
roles: {
|
||||
|
||||
@@ -62,6 +62,42 @@ const KNOWN_METADATA_FIELDS = new Set([
|
||||
'resources',
|
||||
'require_pkce',
|
||||
])
|
||||
const oauthClientMetadataOpenApiProperties = {
|
||||
redirect_uris: { type: 'array', items: { type: 'string', format: 'uri' } },
|
||||
token_endpoint_auth_method: { type: 'string' },
|
||||
grant_types: { type: 'array', items: { type: 'string' } },
|
||||
response_types: { type: 'array', items: { type: 'string' } },
|
||||
client_name: { type: 'string' },
|
||||
client_uri: { type: 'string', format: 'uri' },
|
||||
logo_uri: { type: 'string', format: 'uri' },
|
||||
scope: { type: 'string' },
|
||||
contacts: { type: 'array', items: { type: 'string' } },
|
||||
tos_uri: { type: 'string', format: 'uri' },
|
||||
policy_uri: { type: 'string', format: 'uri' },
|
||||
jwks_uri: { type: 'string', format: 'uri' },
|
||||
jwks: {
|
||||
oneOf: [
|
||||
{ type: 'array', items: { type: 'object', additionalProperties: true } },
|
||||
{
|
||||
type: 'object',
|
||||
additionalProperties: true,
|
||||
properties: { keys: { type: 'array', items: { type: 'object', additionalProperties: true } } },
|
||||
},
|
||||
],
|
||||
},
|
||||
software_id: { type: 'string' },
|
||||
software_version: { type: 'string' },
|
||||
software_statement: { type: 'string' },
|
||||
post_logout_redirect_uris: { type: 'array', items: { type: 'string', format: 'uri' } },
|
||||
backchannel_logout_uri: { type: 'string', format: 'uri' },
|
||||
backchannel_logout_session_required: { type: 'boolean' },
|
||||
type: { type: 'string', enum: ['web', 'native', 'user-agent-based'] },
|
||||
subject_type: { type: 'string', enum: ['public', 'pairwise'] },
|
||||
dpop_bound_access_tokens: { type: 'boolean' },
|
||||
authorization_details_types: { type: 'array', items: { type: 'string' } },
|
||||
resources: { type: 'array', items: { type: 'string', format: 'uri' } },
|
||||
require_pkce: { type: 'boolean' },
|
||||
} as const
|
||||
|
||||
const absoluteUrl = z.string().url()
|
||||
const updateSchema = z
|
||||
@@ -102,37 +138,48 @@ const updateSchema = z
|
||||
.passthrough()
|
||||
|
||||
export function addOAuthClientRegistrationManagementOpenApi(document: { paths: Record<string, unknown> }): void {
|
||||
const registration = document.paths['/api/auth/oauth2/register'] as
|
||||
| { post?: { responses?: Record<string, { content?: Record<string, { schema?: Record<string, unknown> }> }> } }
|
||||
| undefined
|
||||
const registrationSchema = registration?.post?.responses?.['201']?.content?.['application/json']?.schema
|
||||
if (registrationSchema) {
|
||||
const properties = (registrationSchema.properties ?? {}) as Record<string, unknown>
|
||||
registrationSchema.properties = {
|
||||
...properties,
|
||||
registration_client_uri: { type: 'string', format: 'uri' },
|
||||
registration_access_token: { type: 'string' },
|
||||
}
|
||||
registrationSchema.required = [
|
||||
...new Set([
|
||||
...(Array.isArray(registrationSchema.required) ? registrationSchema.required : []),
|
||||
'registration_client_uri',
|
||||
'registration_access_token',
|
||||
]),
|
||||
]
|
||||
}
|
||||
|
||||
const clientInformationSchema = {
|
||||
type: 'object',
|
||||
additionalProperties: true,
|
||||
properties: {
|
||||
...oauthClientMetadataOpenApiProperties,
|
||||
client_id: { type: 'string' },
|
||||
client_secret: { type: 'string' },
|
||||
client_id_issued_at: { type: 'integer' },
|
||||
client_secret_expires_at: { type: 'integer' },
|
||||
registration_client_uri: { type: 'string', format: 'uri' },
|
||||
registration_access_token: { type: 'string' },
|
||||
scope: { type: 'string' },
|
||||
},
|
||||
required: ['client_id', 'registration_client_uri', 'registration_access_token'],
|
||||
}
|
||||
document.paths['/api/auth/oauth2/register'] = {
|
||||
post: {
|
||||
operationId: 'createDynamicOAuthClientRegistration',
|
||||
tags: ['OAuth'],
|
||||
summary: 'Create a dynamic OAuth client registration',
|
||||
security: [],
|
||||
requestBody: {
|
||||
required: true,
|
||||
content: {
|
||||
'application/json': {
|
||||
schema: {
|
||||
type: 'object',
|
||||
additionalProperties: true,
|
||||
properties: oauthClientMetadataOpenApiProperties,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
responses: {
|
||||
'201': {
|
||||
description: 'OAuth client registration created',
|
||||
content: { 'application/json': { schema: clientInformationSchema } },
|
||||
},
|
||||
'400': { description: 'Invalid client metadata' },
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
const bearerSecurity = [{ bearerAuth: [] }]
|
||||
document.paths['/api/auth/oauth2/register/{clientId}'] = {
|
||||
parameters: [{ name: 'clientId', in: 'path', required: true, schema: { type: 'string' } }],
|
||||
|
||||
@@ -0,0 +1,378 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import {
|
||||
addRegisteredBetterAuthOpenApiOperations,
|
||||
type BetterAuthOpenApiOperationRegistration,
|
||||
DOWNLOADER_DEVICE_FLOW_TAG,
|
||||
} from './better-auth-openapi'
|
||||
|
||||
type TestOperation = Record<string, unknown> & {
|
||||
responses?: Record<string, { content?: Record<string, { schema?: unknown }> }>
|
||||
}
|
||||
|
||||
type TestPathItem = {
|
||||
parameters?: unknown[]
|
||||
delete?: TestOperation
|
||||
get?: TestOperation
|
||||
patch?: TestOperation
|
||||
post?: TestOperation
|
||||
put?: TestOperation
|
||||
}
|
||||
|
||||
type TestOpenApiDocument = {
|
||||
paths: Record<string, TestPathItem>
|
||||
components?: Record<string, Record<string, unknown>>
|
||||
}
|
||||
|
||||
describe('Better Auth OpenAPI operation registry', () => {
|
||||
it('publishes only the registered Device Flow methods with their declared contract policy', () => {
|
||||
const doc: TestOpenApiDocument = { paths: { '/api/objects': { get: { operationId: 'listObjects' } } } }
|
||||
const authDoc = {
|
||||
paths: {
|
||||
'/device/code': {
|
||||
get: { operationId: 'futureDeviceCodeRead' },
|
||||
post: { operationId: 'upstreamDeviceCode', security: [{ bearerAuth: [] }], responses: {} },
|
||||
},
|
||||
'/device/token': {
|
||||
delete: { operationId: 'futureDeviceTokenDelete' },
|
||||
post: {
|
||||
operationId: 'upstreamDeviceToken',
|
||||
security: [{ bearerAuth: [] }],
|
||||
responses: {
|
||||
'200': {
|
||||
content: {
|
||||
'application/json': { schema: { $ref: '#/components/schemas/Session' } },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
'/sign-in/email': { post: { operationId: 'signInEmail' } },
|
||||
},
|
||||
components: { schemas: { Session: {}, User: {} } },
|
||||
}
|
||||
|
||||
addRegisteredBetterAuthOpenApiOperations(doc, authDoc)
|
||||
|
||||
expect(Object.keys(doc.paths)).toEqual(['/api/objects', '/api/auth/device/code', '/api/auth/device/token'])
|
||||
expect(Object.keys(doc.paths['/api/auth/device/code'] ?? {})).toEqual(['post'])
|
||||
expect(Object.keys(doc.paths['/api/auth/device/token'] ?? {})).toEqual(['post'])
|
||||
expect(doc.paths['/api/auth/device/code'].post).toMatchObject({
|
||||
operationId: 'createDeviceAuthorization',
|
||||
tags: [DOWNLOADER_DEVICE_FLOW_TAG],
|
||||
security: [],
|
||||
})
|
||||
expect(doc.paths['/api/auth/device/token'].post).toMatchObject({
|
||||
operationId: 'createDeviceAccessToken',
|
||||
tags: [DOWNLOADER_DEVICE_FLOW_TAG],
|
||||
security: [],
|
||||
responses: {
|
||||
'200': {
|
||||
content: {
|
||||
'application/json': {
|
||||
schema: {
|
||||
properties: {
|
||||
access_token: { type: 'string' },
|
||||
token_type: { type: 'string' },
|
||||
expires_in: { type: 'integer' },
|
||||
scope: { type: 'string' },
|
||||
},
|
||||
required: ['access_token', 'token_type', 'expires_in'],
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
expect(doc.components?.schemas?.Session).toBeUndefined()
|
||||
expect(doc.components?.schemas?.User).toBeUndefined()
|
||||
})
|
||||
|
||||
it('merges registered methods on one public path and applies security per operation', () => {
|
||||
const doc: TestOpenApiDocument = { paths: {} }
|
||||
const authDoc = {
|
||||
paths: {
|
||||
'/sessions': {
|
||||
get: { responses: {} },
|
||||
post: { responses: {} },
|
||||
},
|
||||
},
|
||||
}
|
||||
const registry = [
|
||||
registration({
|
||||
sourcePath: '/sessions',
|
||||
method: 'get',
|
||||
publicPath: '/api/auth/sessions',
|
||||
operationId: 'listAuthSessions',
|
||||
tags: ['Auth Sessions'],
|
||||
security: { mode: 'requirements', requirements: [{ cookieAuth: [] }, { bearerAuth: [] }] },
|
||||
}),
|
||||
registration({
|
||||
sourcePath: '/sessions',
|
||||
method: 'post',
|
||||
publicPath: '/api/auth/sessions',
|
||||
operationId: 'createAuthSession',
|
||||
tags: ['Auth Session Creation'],
|
||||
security: { mode: 'public' },
|
||||
}),
|
||||
]
|
||||
|
||||
addRegisteredBetterAuthOpenApiOperations(doc, authDoc, registry)
|
||||
|
||||
expect(Object.keys(doc.paths['/api/auth/sessions'] ?? {})).toEqual(['get', 'post'])
|
||||
expect(doc.paths['/api/auth/sessions'].get).toMatchObject({
|
||||
operationId: 'listAuthSessions',
|
||||
tags: ['Auth Sessions'],
|
||||
security: [{ cookieAuth: [] }, { bearerAuth: [] }],
|
||||
})
|
||||
expect(doc.paths['/api/auth/sessions'].post).toMatchObject({
|
||||
operationId: 'createAuthSession',
|
||||
tags: ['Auth Session Creation'],
|
||||
security: [],
|
||||
})
|
||||
})
|
||||
|
||||
it('copies path-item parameters and their reachable component closure', () => {
|
||||
const doc: TestOpenApiDocument = { paths: {}, components: { schemas: { Existing: { type: 'string' } } } }
|
||||
const authDoc = {
|
||||
paths: {
|
||||
'/devices/{deviceId}': {
|
||||
parameters: [{ $ref: '#/components/parameters/DeviceId' }],
|
||||
get: { responses: {} },
|
||||
},
|
||||
},
|
||||
components: {
|
||||
parameters: {
|
||||
DeviceId: {
|
||||
name: 'deviceId',
|
||||
in: 'path',
|
||||
required: true,
|
||||
schema: { $ref: '#/components/schemas/DeviceIdentifier' },
|
||||
},
|
||||
},
|
||||
schemas: {
|
||||
DeviceIdentifier: { type: 'string', pattern: '^device_' },
|
||||
Unrelated: { type: 'object' },
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
addRegisteredBetterAuthOpenApiOperations(doc, authDoc, [
|
||||
registration({ sourcePath: '/devices/{deviceId}', method: 'get', publicPath: '/api/auth/devices/{deviceId}' }),
|
||||
])
|
||||
|
||||
expect(doc.paths['/api/auth/devices/{deviceId}'].parameters).toEqual([{ $ref: '#/components/parameters/DeviceId' }])
|
||||
expect(doc.components).toEqual({
|
||||
parameters: { DeviceId: authDoc.components.parameters.DeviceId },
|
||||
schemas: {
|
||||
Existing: { type: 'string' },
|
||||
DeviceIdentifier: authDoc.components.schemas.DeviceIdentifier,
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
it('copies only the transitive component closure and handles circular references', () => {
|
||||
const doc: TestOpenApiDocument = { paths: {} }
|
||||
const authDoc = {
|
||||
paths: {
|
||||
'/source': {
|
||||
post: {
|
||||
responses: {
|
||||
'200': { content: { 'application/json': { schema: { $ref: '#/components/schemas/Root' } } } },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
components: {
|
||||
schemas: {
|
||||
Root: { type: 'object', properties: { child: { $ref: '#/components/schemas/Child' } } },
|
||||
Child: { type: 'object', properties: { parent: { $ref: '#/components/schemas/Root' } } },
|
||||
Session: { type: 'object' },
|
||||
User: { type: 'object' },
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
addRegisteredBetterAuthOpenApiOperations(doc, authDoc, [registration()])
|
||||
|
||||
expect(Object.keys(doc.components?.schemas ?? {}).sort()).toEqual(['Child', 'Root'])
|
||||
expect(doc.components?.schemas).toMatchObject({
|
||||
Root: authDoc.components.schemas.Root,
|
||||
Child: authDoc.components.schemas.Child,
|
||||
})
|
||||
})
|
||||
|
||||
it('fails closed when a registered source operation is missing and names its actual method', () => {
|
||||
const doc: TestOpenApiDocument = { paths: {} }
|
||||
const registry = [registration({ sourcePath: '/source', method: 'delete', operationId: 'deleteAuthSession' })]
|
||||
|
||||
expect(() => addRegisteredBetterAuthOpenApiOperations(doc, { paths: {} }, registry)).toThrow(
|
||||
'Better Auth OpenAPI is missing DELETE /source',
|
||||
)
|
||||
expect(doc).toEqual({ paths: {} })
|
||||
})
|
||||
|
||||
it('rejects duplicate source and target registrations before aggregation', () => {
|
||||
const sourceDuplicate = [
|
||||
registration(),
|
||||
registration({ publicPath: '/api/auth/other', operationId: 'createOther' }),
|
||||
]
|
||||
const targetDuplicate = [registration(), registration({ sourcePath: '/other', operationId: 'createOther' })]
|
||||
|
||||
expect(() => addRegisteredBetterAuthOpenApiOperations({ paths: {} }, { paths: {} }, sourceDuplicate)).toThrow(
|
||||
'Duplicate Better Auth OpenAPI source registration: POST /source',
|
||||
)
|
||||
expect(() => addRegisteredBetterAuthOpenApiOperations({ paths: {} }, { paths: {} }, targetDuplicate)).toThrow(
|
||||
'Duplicate Better Auth OpenAPI target registration: POST /api/auth/source',
|
||||
)
|
||||
})
|
||||
|
||||
it('rejects duplicate registry and existing-document operationIds', () => {
|
||||
const duplicateRegistry = [registration(), registration({ sourcePath: '/other', publicPath: '/api/auth/other' })]
|
||||
expect(() => addRegisteredBetterAuthOpenApiOperations({ paths: {} }, { paths: {} }, duplicateRegistry)).toThrow(
|
||||
'Duplicate Better Auth OpenAPI operationId registration: createAuthSource',
|
||||
)
|
||||
|
||||
const doc = { paths: { '/api/existing': { get: { operationId: 'createAuthSource' } } } }
|
||||
const authDoc = { paths: { '/source': { post: { responses: {} } } } }
|
||||
expect(() => addRegisteredBetterAuthOpenApiOperations(doc, authDoc, [registration()])).toThrow(
|
||||
'OpenAPI operationId createAuthSource conflicts between GET /api/existing and POST /api/auth/source',
|
||||
)
|
||||
expect(doc).toEqual({ paths: { '/api/existing': { get: { operationId: 'createAuthSource' } } } })
|
||||
})
|
||||
|
||||
it('rejects an existing target method without overwriting it', () => {
|
||||
const doc = { paths: { '/api/auth/source': { post: { operationId: 'existingSource' } } } }
|
||||
const authDoc = { paths: { '/source': { post: { responses: {} } } } }
|
||||
|
||||
expect(() => addRegisteredBetterAuthOpenApiOperations(doc, authDoc, [registration()])).toThrow(
|
||||
'OpenAPI target already defines POST /api/auth/source',
|
||||
)
|
||||
expect(doc.paths['/api/auth/source'].post).toEqual({ operationId: 'existingSource' })
|
||||
})
|
||||
|
||||
it('rejects conflicting target path parameters', () => {
|
||||
const doc = {
|
||||
paths: {
|
||||
'/api/auth/source': {
|
||||
parameters: [{ name: 'tenantId', in: 'path' }],
|
||||
get: { operationId: 'getExistingSource' },
|
||||
},
|
||||
},
|
||||
}
|
||||
const authDoc = {
|
||||
paths: {
|
||||
'/source': {
|
||||
parameters: [{ name: 'sourceId', in: 'path' }],
|
||||
post: { responses: {} },
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
expect(() => addRegisteredBetterAuthOpenApiOperations(doc, authDoc, [registration()])).toThrow(
|
||||
'OpenAPI path parameters conflict at /api/auth/source',
|
||||
)
|
||||
})
|
||||
|
||||
it('rejects a reachable component collision', () => {
|
||||
const doc = { paths: {}, components: { schemas: { Shared: { type: 'integer' } } } }
|
||||
const authDoc = {
|
||||
paths: {
|
||||
'/source': {
|
||||
post: {
|
||||
responses: {
|
||||
'200': { content: { 'application/json': { schema: { $ref: '#/components/schemas/Shared' } } } },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
components: { schemas: { Shared: { type: 'string' } } },
|
||||
}
|
||||
|
||||
expect(() => addRegisteredBetterAuthOpenApiOperations(doc, authDoc, [registration()])).toThrow(
|
||||
'OpenAPI component collision at #/components/schemas/Shared',
|
||||
)
|
||||
expect(doc.components.schemas.Shared).toEqual({ type: 'integer' })
|
||||
})
|
||||
|
||||
it.each([
|
||||
['dangling local', '#/components/schemas/Missing', 'has a dangling reference'],
|
||||
[
|
||||
'external',
|
||||
'https://example.com/schema.json',
|
||||
'external reference https://example.com/schema.json is not allowed',
|
||||
],
|
||||
['non-component local', '#/paths/~1other', 'must target a component'],
|
||||
])('rejects %s references', (_name, reference, error) => {
|
||||
const authDoc = {
|
||||
paths: {
|
||||
'/source': {
|
||||
post: {
|
||||
responses: { '200': { content: { 'application/json': { schema: { $ref: reference } } } } },
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
expect(() => addRegisteredBetterAuthOpenApiOperations({ paths: {} }, authDoc, [registration()])).toThrow(error)
|
||||
})
|
||||
|
||||
it('uses a registry-declared response schema override before resolving references', () => {
|
||||
const doc: TestOpenApiDocument = { paths: {} }
|
||||
const authDoc = {
|
||||
paths: {
|
||||
'/source': {
|
||||
post: {
|
||||
responses: {
|
||||
'200': { content: { 'application/json': { schema: { $ref: '#/components/schemas/Incorrect' } } } },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
components: { schemas: { Incorrect: { type: 'object' } } },
|
||||
}
|
||||
const registry = [
|
||||
registration({
|
||||
contract: {
|
||||
responseSchemas: {
|
||||
'200': { 'application/json': { type: 'object', required: ['access_token'] } },
|
||||
},
|
||||
},
|
||||
}),
|
||||
]
|
||||
|
||||
addRegisteredBetterAuthOpenApiOperations(doc, authDoc, registry)
|
||||
|
||||
expect(doc.paths['/api/auth/source']?.post?.responses?.['200']?.content?.['application/json']?.schema).toEqual({
|
||||
type: 'object',
|
||||
required: ['access_token'],
|
||||
})
|
||||
expect(doc.components?.schemas?.Incorrect).toBeUndefined()
|
||||
})
|
||||
|
||||
it('fails closed when a declared response schema override cannot be applied', () => {
|
||||
const authDoc = { paths: { '/source': { post: { responses: {} } } } }
|
||||
const registry = [
|
||||
registration({
|
||||
contract: { responseSchemas: { '201': { 'application/problem+json': { type: 'object' } } } },
|
||||
}),
|
||||
]
|
||||
|
||||
expect(() => addRegisteredBetterAuthOpenApiOperations({ paths: {} }, authDoc, registry)).toThrow(
|
||||
'Better Auth OpenAPI cannot apply the 201 application/problem+json response schema override for POST /source',
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
function registration(
|
||||
overrides: Partial<BetterAuthOpenApiOperationRegistration> = {},
|
||||
): BetterAuthOpenApiOperationRegistration {
|
||||
return {
|
||||
sourcePath: '/source',
|
||||
method: 'post',
|
||||
publicPath: '/api/auth/source',
|
||||
operationId: 'createAuthSource',
|
||||
tags: ['Auth Sources'],
|
||||
security: { mode: 'public' },
|
||||
...overrides,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,431 @@
|
||||
type OpenApiObject = Record<string, unknown>
|
||||
type OpenApiOperation = OpenApiObject
|
||||
|
||||
type OpenApiDocument = {
|
||||
paths?: Record<string, unknown>
|
||||
components?: object
|
||||
}
|
||||
|
||||
type ZPanOpenApiDocument = OpenApiDocument & {
|
||||
paths: Record<string, unknown>
|
||||
}
|
||||
|
||||
export type BetterAuthOpenApiMethod = 'delete' | 'get' | 'head' | 'options' | 'patch' | 'post' | 'put' | 'trace'
|
||||
|
||||
type OpenApiSecurityRequirement = Record<string, readonly string[]>
|
||||
|
||||
export type BetterAuthOpenApiSecurityPolicy =
|
||||
| { mode: 'public' }
|
||||
| {
|
||||
mode: 'requirements'
|
||||
requirements: readonly OpenApiSecurityRequirement[]
|
||||
}
|
||||
|
||||
type BetterAuthOpenApiContractNormalization = {
|
||||
responseSchemas?: Readonly<Record<string, Readonly<Record<string, unknown>>>>
|
||||
}
|
||||
|
||||
export type BetterAuthOpenApiOperationRegistration = {
|
||||
sourcePath: string
|
||||
method: BetterAuthOpenApiMethod
|
||||
publicPath: string
|
||||
operationId: string
|
||||
tags: readonly [string, ...string[]]
|
||||
security: BetterAuthOpenApiSecurityPolicy
|
||||
contract?: BetterAuthOpenApiContractNormalization
|
||||
}
|
||||
|
||||
export const DOWNLOADER_DEVICE_FLOW_TAG = 'Downloader Device Flow'
|
||||
|
||||
const deviceAccessTokenResponseSchema = {
|
||||
type: 'object',
|
||||
properties: {
|
||||
access_token: { type: 'string' },
|
||||
token_type: { type: 'string' },
|
||||
expires_in: { type: 'integer' },
|
||||
scope: { type: 'string' },
|
||||
},
|
||||
required: ['access_token', 'token_type', 'expires_in'],
|
||||
}
|
||||
|
||||
// This registry is the complete Better Auth boundary of the ZPan product
|
||||
// contract. Adding an entry is an explicit compatibility and authorization
|
||||
// decision; unregistered Better Auth operations remain runtime-only.
|
||||
export const BETTER_AUTH_OPENAPI_OPERATION_REGISTRY = [
|
||||
{
|
||||
sourcePath: '/device/code',
|
||||
method: 'post',
|
||||
publicPath: '/api/auth/device/code',
|
||||
operationId: 'createDeviceAuthorization',
|
||||
tags: [DOWNLOADER_DEVICE_FLOW_TAG],
|
||||
security: { mode: 'public' },
|
||||
},
|
||||
{
|
||||
sourcePath: '/device/token',
|
||||
method: 'post',
|
||||
publicPath: '/api/auth/device/token',
|
||||
operationId: 'createDeviceAccessToken',
|
||||
tags: [DOWNLOADER_DEVICE_FLOW_TAG],
|
||||
security: { mode: 'public' },
|
||||
contract: {
|
||||
responseSchemas: {
|
||||
'200': {
|
||||
'application/json': deviceAccessTokenResponseSchema,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
] as const satisfies readonly BetterAuthOpenApiOperationRegistration[]
|
||||
|
||||
const OPENAPI_METHODS = new Set<BetterAuthOpenApiMethod>([
|
||||
'delete',
|
||||
'get',
|
||||
'head',
|
||||
'options',
|
||||
'patch',
|
||||
'post',
|
||||
'put',
|
||||
'trace',
|
||||
])
|
||||
|
||||
export function addRegisteredBetterAuthOpenApiOperations(
|
||||
doc: ZPanOpenApiDocument,
|
||||
authDoc: OpenApiDocument,
|
||||
registry: readonly BetterAuthOpenApiOperationRegistration[] = BETTER_AUTH_OPENAPI_OPERATION_REGISTRY,
|
||||
): void {
|
||||
validateRegistry(registry)
|
||||
|
||||
const nextPaths = structuredClone(doc.paths)
|
||||
const nextComponents = structuredClone(doc.components ?? {}) as OpenApiObject
|
||||
const importedComponentRoots = new Set<string>()
|
||||
|
||||
for (const registration of registry) {
|
||||
const sourcePathValue = authDoc.paths?.[registration.sourcePath]
|
||||
const sourcePathItem =
|
||||
sourcePathValue === undefined
|
||||
? undefined
|
||||
: requireObject(sourcePathValue, `Better Auth OpenAPI path ${registration.sourcePath} is not a path item`)
|
||||
const sourceOperationValue = sourcePathItem?.[registration.method]
|
||||
if (!isObject(sourceOperationValue) || !sourcePathItem) {
|
||||
throw new Error(`Better Auth OpenAPI is missing ${formatOperation(registration.method, registration.sourcePath)}`)
|
||||
}
|
||||
const sourceParameters = readPathParameters(sourcePathItem, registration)
|
||||
|
||||
const operation = structuredClone(sourceOperationValue) as OpenApiOperation
|
||||
applyContractNormalization(operation, registration)
|
||||
operation.operationId = registration.operationId
|
||||
operation.tags = [...registration.tags]
|
||||
operation.security = openApiSecurity(registration.security, registration)
|
||||
|
||||
importReachableComponents(
|
||||
[operation, ...(sourceParameters === undefined ? [] : [sourceParameters])],
|
||||
authDoc,
|
||||
nextComponents,
|
||||
importedComponentRoots,
|
||||
registration,
|
||||
)
|
||||
|
||||
const existingPathItem = nextPaths[registration.publicPath]
|
||||
const targetPathItem =
|
||||
existingPathItem === undefined
|
||||
? {}
|
||||
: requireObject(existingPathItem, `OpenAPI target path ${registration.publicPath} is not a path item`)
|
||||
|
||||
if (Object.hasOwn(targetPathItem, registration.method)) {
|
||||
throw new Error(`OpenAPI target already defines ${formatOperation(registration.method, registration.publicPath)}`)
|
||||
}
|
||||
mergePathParameters(targetPathItem, sourceParameters, registration)
|
||||
targetPathItem[registration.method] = operation
|
||||
nextPaths[registration.publicPath] = targetPathItem
|
||||
}
|
||||
|
||||
assertUniqueOperationIds(nextPaths)
|
||||
assertImportedReferencesResolve(nextPaths, nextComponents, registry, importedComponentRoots)
|
||||
|
||||
doc.paths = nextPaths
|
||||
doc.components = nextComponents
|
||||
}
|
||||
|
||||
function validateRegistry(registry: readonly BetterAuthOpenApiOperationRegistration[]): void {
|
||||
const sources = new Set<string>()
|
||||
const targets = new Set<string>()
|
||||
const operationIds = new Set<string>()
|
||||
|
||||
for (const registration of registry) {
|
||||
const source = `${registration.method} ${registration.sourcePath}`
|
||||
if (sources.has(source)) {
|
||||
throw new Error(
|
||||
`Duplicate Better Auth OpenAPI source registration: ${formatOperation(registration.method, registration.sourcePath)}`,
|
||||
)
|
||||
}
|
||||
sources.add(source)
|
||||
|
||||
const target = `${registration.method} ${registration.publicPath}`
|
||||
if (targets.has(target)) {
|
||||
throw new Error(
|
||||
`Duplicate Better Auth OpenAPI target registration: ${formatOperation(registration.method, registration.publicPath)}`,
|
||||
)
|
||||
}
|
||||
targets.add(target)
|
||||
|
||||
if (operationIds.has(registration.operationId)) {
|
||||
throw new Error(`Duplicate Better Auth OpenAPI operationId registration: ${registration.operationId}`)
|
||||
}
|
||||
operationIds.add(registration.operationId)
|
||||
|
||||
if (registration.tags.length === 0) {
|
||||
throw new Error(`Better Auth OpenAPI registration ${registration.operationId} must declare at least one tag`)
|
||||
}
|
||||
if (registration.security.mode === 'requirements' && registration.security.requirements.length === 0) {
|
||||
throw new Error(
|
||||
`Better Auth OpenAPI registration ${registration.operationId} must use mode public for an empty security requirement`,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function readPathParameters(
|
||||
sourcePathItem: OpenApiObject,
|
||||
registration: BetterAuthOpenApiOperationRegistration,
|
||||
): unknown[] | undefined {
|
||||
if (!Object.hasOwn(sourcePathItem, 'parameters')) return undefined
|
||||
if (!Array.isArray(sourcePathItem.parameters)) {
|
||||
throw new Error(
|
||||
`Better Auth OpenAPI path parameters for ${formatOperation(registration.method, registration.sourcePath)} must be an array`,
|
||||
)
|
||||
}
|
||||
return structuredClone(sourcePathItem.parameters)
|
||||
}
|
||||
|
||||
function mergePathParameters(
|
||||
targetPathItem: OpenApiObject,
|
||||
sourceParameters: unknown[] | undefined,
|
||||
registration: BetterAuthOpenApiOperationRegistration,
|
||||
): void {
|
||||
const hasTargetParameters = Object.hasOwn(targetPathItem, 'parameters')
|
||||
if (!hasTargetParameters && sourceParameters === undefined) return
|
||||
if (!hasTargetParameters && sourceParameters !== undefined) {
|
||||
targetPathItem.parameters = sourceParameters
|
||||
return
|
||||
}
|
||||
if (sourceParameters === undefined || !jsonEqual(targetPathItem.parameters, sourceParameters)) {
|
||||
throw new Error(`OpenAPI path parameters conflict at ${registration.publicPath}`)
|
||||
}
|
||||
}
|
||||
|
||||
function applyContractNormalization(
|
||||
operation: OpenApiOperation,
|
||||
registration: BetterAuthOpenApiOperationRegistration,
|
||||
): void {
|
||||
for (const [status, mediaTypes] of Object.entries(registration.contract?.responseSchemas ?? {})) {
|
||||
for (const [mediaType, schema] of Object.entries(mediaTypes)) {
|
||||
const response = requireObject(
|
||||
requireObject(operation.responses, missingResponseSchemaError(registration, status, mediaType))[status],
|
||||
missingResponseSchemaError(registration, status, mediaType),
|
||||
)
|
||||
const content = requireObject(response.content, missingResponseSchemaError(registration, status, mediaType))
|
||||
const representation = requireObject(
|
||||
content[mediaType],
|
||||
missingResponseSchemaError(registration, status, mediaType),
|
||||
)
|
||||
representation.schema = structuredClone(schema)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function missingResponseSchemaError(
|
||||
registration: BetterAuthOpenApiOperationRegistration,
|
||||
status: string,
|
||||
mediaType: string,
|
||||
): string {
|
||||
return `Better Auth OpenAPI cannot apply the ${status} ${mediaType} response schema override for ${formatOperation(registration.method, registration.sourcePath)}`
|
||||
}
|
||||
|
||||
function openApiSecurity(
|
||||
policy: BetterAuthOpenApiSecurityPolicy,
|
||||
registration: BetterAuthOpenApiOperationRegistration,
|
||||
): OpenApiSecurityRequirement[] {
|
||||
if (policy.mode === 'public') return []
|
||||
if (policy.requirements.length === 0) {
|
||||
throw new Error(
|
||||
`Better Auth OpenAPI registration ${registration.operationId} must use mode public for an empty security requirement`,
|
||||
)
|
||||
}
|
||||
return structuredClone(policy.requirements) as OpenApiSecurityRequirement[]
|
||||
}
|
||||
|
||||
function importReachableComponents(
|
||||
values: readonly unknown[],
|
||||
authDoc: OpenApiDocument,
|
||||
targetComponents: OpenApiObject,
|
||||
importedComponentRoots: Set<string>,
|
||||
registration: BetterAuthOpenApiOperationRegistration,
|
||||
): void {
|
||||
for (const value of values) {
|
||||
for (const reference of collectReferences(value)) {
|
||||
const segments = parseComponentReference(reference, registration)
|
||||
if (resolvePointer(authDoc, segments) === undefined) {
|
||||
throw new Error(
|
||||
`Better Auth OpenAPI has a dangling reference ${reference} in ${formatOperation(registration.method, registration.sourcePath)}`,
|
||||
)
|
||||
}
|
||||
|
||||
const [componentType, componentName] = [segments[1], segments[2]]
|
||||
const rootKey = JSON.stringify([componentType, componentName])
|
||||
if (importedComponentRoots.has(rootKey)) continue
|
||||
|
||||
const sourceRoot = resolvePointer(authDoc, ['components', componentType, componentName])
|
||||
if (sourceRoot === undefined) {
|
||||
throw new Error(
|
||||
`Better Auth OpenAPI has a dangling component reference ${reference} in ${formatOperation(registration.method, registration.sourcePath)}`,
|
||||
)
|
||||
}
|
||||
|
||||
const targetSection = targetComponents[componentType]
|
||||
const section =
|
||||
targetSection === undefined
|
||||
? {}
|
||||
: requireObject(targetSection, `OpenAPI component section components.${componentType} is not an object`)
|
||||
if (Object.hasOwn(section, componentName)) {
|
||||
if (!jsonEqual(section[componentName], sourceRoot)) {
|
||||
throw new Error(`OpenAPI component collision at #/components/${componentType}/${componentName}`)
|
||||
}
|
||||
} else {
|
||||
section[componentName] = structuredClone(sourceRoot)
|
||||
}
|
||||
targetComponents[componentType] = section
|
||||
importedComponentRoots.add(rootKey)
|
||||
|
||||
importReachableComponents([sourceRoot], authDoc, targetComponents, importedComponentRoots, registration)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function parseComponentReference(reference: string, registration: BetterAuthOpenApiOperationRegistration): string[] {
|
||||
if (!reference.startsWith('#/')) {
|
||||
throw new Error(
|
||||
`Better Auth OpenAPI external reference ${reference} is not allowed in ${formatOperation(registration.method, registration.sourcePath)}`,
|
||||
)
|
||||
}
|
||||
const segments = parseJsonPointer(reference)
|
||||
if (segments.length < 3 || segments[0] !== 'components') {
|
||||
throw new Error(
|
||||
`Better Auth OpenAPI local reference ${reference} must target a component in ${formatOperation(registration.method, registration.sourcePath)}`,
|
||||
)
|
||||
}
|
||||
return segments
|
||||
}
|
||||
|
||||
function assertImportedReferencesResolve(
|
||||
paths: Record<string, unknown>,
|
||||
components: OpenApiObject,
|
||||
registry: readonly BetterAuthOpenApiOperationRegistration[],
|
||||
importedComponentRoots: ReadonlySet<string>,
|
||||
): void {
|
||||
const document = { paths, components }
|
||||
const values: unknown[] = []
|
||||
|
||||
for (const registration of registry) {
|
||||
const pathItem = requireObject(
|
||||
paths[registration.publicPath],
|
||||
`OpenAPI target path ${registration.publicPath} is missing`,
|
||||
)
|
||||
values.push(pathItem[registration.method])
|
||||
if (Object.hasOwn(pathItem, 'parameters')) values.push(pathItem.parameters)
|
||||
}
|
||||
for (const rootKey of importedComponentRoots) {
|
||||
const [componentType, componentName] = JSON.parse(rootKey) as [string, string]
|
||||
values.push(resolvePointer(document, ['components', componentType, componentName]))
|
||||
}
|
||||
|
||||
for (const value of values) {
|
||||
for (const reference of collectReferences(value)) {
|
||||
if (!reference.startsWith('#/')) {
|
||||
throw new Error(`OpenAPI imported operation contains unsupported external reference ${reference}`)
|
||||
}
|
||||
if (resolvePointer(document, parseJsonPointer(reference)) === undefined) {
|
||||
throw new Error(`OpenAPI imported operation contains dangling reference ${reference}`)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function assertUniqueOperationIds(paths: Record<string, unknown>): void {
|
||||
const operationIds = new Map<string, string>()
|
||||
for (const [path, value] of Object.entries(paths)) {
|
||||
if (!isObject(value)) continue
|
||||
for (const [method, operation] of Object.entries(value)) {
|
||||
if (!OPENAPI_METHODS.has(method as BetterAuthOpenApiMethod) || !isObject(operation)) continue
|
||||
if (typeof operation.operationId !== 'string') continue
|
||||
const existing = operationIds.get(operation.operationId)
|
||||
const current = formatOperation(method as BetterAuthOpenApiMethod, path)
|
||||
if (existing) {
|
||||
throw new Error(`OpenAPI operationId ${operation.operationId} conflicts between ${existing} and ${current}`)
|
||||
}
|
||||
operationIds.set(operation.operationId, current)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function collectReferences(value: unknown, seen = new WeakSet<object>()): string[] {
|
||||
if (!value || typeof value !== 'object') return []
|
||||
if (seen.has(value)) return []
|
||||
seen.add(value)
|
||||
if (Array.isArray(value)) return value.flatMap((item) => collectReferences(item, seen))
|
||||
|
||||
const object = value as OpenApiObject
|
||||
return [
|
||||
...(typeof object.$ref === 'string' ? [object.$ref] : []),
|
||||
...Object.values(object).flatMap((item) => collectReferences(item, seen)),
|
||||
]
|
||||
}
|
||||
|
||||
function parseJsonPointer(reference: string): string[] {
|
||||
try {
|
||||
return reference
|
||||
.slice(2)
|
||||
.split('/')
|
||||
.map((segment) => decodeURIComponent(segment).replaceAll('~1', '/').replaceAll('~0', '~'))
|
||||
} catch {
|
||||
throw new Error(`Invalid OpenAPI JSON reference: ${reference}`)
|
||||
}
|
||||
}
|
||||
|
||||
function resolvePointer(value: unknown, segments: readonly string[]): unknown {
|
||||
return segments.reduce<unknown>((current, segment) => {
|
||||
if (!isObject(current) && !Array.isArray(current)) return undefined
|
||||
return (current as Record<string, unknown>)[segment]
|
||||
}, value)
|
||||
}
|
||||
|
||||
function requireObject(value: unknown, error: string): OpenApiObject {
|
||||
if (!isObject(value)) throw new Error(error)
|
||||
return value
|
||||
}
|
||||
|
||||
function isObject(value: unknown): value is OpenApiObject {
|
||||
return !!value && typeof value === 'object' && !Array.isArray(value)
|
||||
}
|
||||
|
||||
function jsonEqual(left: unknown, right: unknown): boolean {
|
||||
if (Object.is(left, right)) return true
|
||||
if (Array.isArray(left) || Array.isArray(right)) {
|
||||
return (
|
||||
Array.isArray(left) &&
|
||||
Array.isArray(right) &&
|
||||
left.length === right.length &&
|
||||
left.every((v, i) => jsonEqual(v, right[i]))
|
||||
)
|
||||
}
|
||||
if (!isObject(left) || !isObject(right)) return false
|
||||
const leftKeys = Object.keys(left)
|
||||
const rightKeys = Object.keys(right)
|
||||
return (
|
||||
leftKeys.length === rightKeys.length &&
|
||||
leftKeys.every((key) => Object.hasOwn(right, key) && jsonEqual(left[key], right[key]))
|
||||
)
|
||||
}
|
||||
|
||||
function formatOperation(method: BetterAuthOpenApiMethod, path: string): string {
|
||||
return `${method.toUpperCase()} ${path}`
|
||||
}
|
||||
+104
-8
@@ -19,7 +19,7 @@ describe('global OpenAPI document', () => {
|
||||
expect(doc.paths['/api/objects']?.get?.tags).toContain('Objects')
|
||||
expect(doc.paths['/api/events']?.get?.tags).toContain('Events')
|
||||
expect((doc.tags ?? []).map((t) => t.name)).toEqual(
|
||||
expect.arrayContaining(['Objects', 'Events', 'Download Tasks', 'Downloaders']),
|
||||
expect.arrayContaining(['Objects', 'Events', 'Download Tasks', 'Downloaders', 'Downloader Device Flow']),
|
||||
)
|
||||
// Every resource already converted to `.openapi()` shows up automatically.
|
||||
expect(Object.keys(doc.paths)).toEqual(
|
||||
@@ -788,27 +788,53 @@ describe('global OpenAPI document', () => {
|
||||
})
|
||||
})
|
||||
|
||||
it("merges better-auth's auto-generated schema (incl. the device flow) into the same doc", async () => {
|
||||
it('publishes only the registered Better Auth Downloader Device Flow operations', async () => {
|
||||
const { app } = await createTestApp({ DOWNLOAD_TOKEN_SECRET: 'test-download-token-secret' })
|
||||
const res = await app.request('/api/openapi.json')
|
||||
const doc = (await res.json()) as {
|
||||
paths: Record<
|
||||
string,
|
||||
{
|
||||
get?: Record<string, unknown>
|
||||
post?: {
|
||||
operationId?: string
|
||||
tags?: string[]
|
||||
security?: Record<string, string[]>[]
|
||||
responses?: Record<
|
||||
string,
|
||||
{ content?: { 'application/json'?: { schema?: { properties?: Record<string, unknown> } } } }
|
||||
{
|
||||
content?: {
|
||||
'application/json'?: {
|
||||
schema?: { properties?: Record<string, unknown>; required?: string[] }
|
||||
}
|
||||
}
|
||||
}
|
||||
>
|
||||
}
|
||||
}
|
||||
>
|
||||
components?: {
|
||||
headers?: Record<string, unknown>
|
||||
schemas?: Record<string, unknown>
|
||||
}
|
||||
}
|
||||
// better-auth's device-authorization endpoints come from its openAPI plugin,
|
||||
// not hand-written stubs — prefixed under /api/auth.
|
||||
const authPaths = Object.keys(doc.paths).filter((p) => p.startsWith('/api/auth/'))
|
||||
expect(authPaths.length).toBeGreaterThan(0)
|
||||
expect(authPaths.some((p) => p.includes('/device/'))).toBe(true)
|
||||
|
||||
expect(Object.keys(doc.paths).filter((path) => path.startsWith('/api/auth/device/'))).toEqual([
|
||||
'/api/auth/device/code',
|
||||
'/api/auth/device/token',
|
||||
])
|
||||
expect(Object.keys(doc.paths['/api/auth/device/code'] ?? {})).toEqual(['post'])
|
||||
expect(Object.keys(doc.paths['/api/auth/device/token'] ?? {})).toEqual(['post'])
|
||||
expect(doc.paths['/api/auth/device/code']?.post).toMatchObject({
|
||||
operationId: 'createDeviceAuthorization',
|
||||
tags: ['Downloader Device Flow'],
|
||||
security: [],
|
||||
})
|
||||
expect(doc.paths['/api/auth/device/token']?.post).toMatchObject({
|
||||
operationId: 'createDeviceAccessToken',
|
||||
tags: ['Downloader Device Flow'],
|
||||
security: [],
|
||||
})
|
||||
expect(
|
||||
doc.paths['/api/auth/device/token']?.post?.responses?.['200']?.content?.['application/json']?.schema?.properties,
|
||||
).toMatchObject({
|
||||
@@ -817,5 +843,75 @@ describe('global OpenAPI document', () => {
|
||||
expires_in: { type: 'integer' },
|
||||
scope: { type: 'string' },
|
||||
})
|
||||
expect(
|
||||
doc.paths['/api/auth/device/token']?.post?.responses?.['200']?.content?.['application/json']?.schema?.required,
|
||||
).toEqual(['access_token', 'token_type', 'expires_in'])
|
||||
|
||||
expect(doc.paths['/api/auth/sign-in/email']).toBeUndefined()
|
||||
expect(doc.paths['/api/auth/organization/create']).toBeUndefined()
|
||||
expect(doc.paths['/api/auth/admin/list-users']).toBeUndefined()
|
||||
expect(doc.paths['/api/auth/api-key/create']).toBeUndefined()
|
||||
expect(doc.components?.schemas?.Session).toBeUndefined()
|
||||
expect(doc.components?.schemas?.User).toBeUndefined()
|
||||
// The registered Better Auth operations need no imported components after
|
||||
// their declared normalization. ZPan's later framework-level response
|
||||
// decoration adds only the shared RequestId header reference.
|
||||
const requestIdReference = '#/components/headers/RequestId'
|
||||
for (const path of ['/api/auth/device/code', '/api/auth/device/token']) {
|
||||
const references = collectOpenApiReferences(doc.paths[path])
|
||||
expect(references.length).toBeGreaterThan(0)
|
||||
expect([...new Set(references)]).toEqual([requestIdReference])
|
||||
for (const reference of references) {
|
||||
expect(resolveLocalOpenApiReference(doc, reference)).toBe(doc.components?.headers?.RequestId)
|
||||
}
|
||||
}
|
||||
const operationIds = Object.values(doc.paths).flatMap((path) =>
|
||||
Object.values(path).flatMap((operation) =>
|
||||
operation && typeof operation === 'object' && 'operationId' in operation ? [operation.operationId] : [],
|
||||
),
|
||||
)
|
||||
expect(operationIds.filter((id) => id === 'createDeviceAuthorization')).toHaveLength(1)
|
||||
expect(operationIds.filter((id) => id === 'createDeviceAccessToken')).toHaveLength(1)
|
||||
})
|
||||
|
||||
it('keeps Better Auth runtime login, reference, and schema routes mounted', async () => {
|
||||
const { app } = await createTestApp({ DOWNLOAD_TOKEN_SECRET: 'test-download-token-secret' })
|
||||
const [login, reference, schema] = await Promise.all([
|
||||
app.request('/api/auth/sign-in/email', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ email: 'missing@example.com', password: 'wrong-password' }),
|
||||
}),
|
||||
app.request('/api/auth/reference'),
|
||||
app.request('/api/auth/open-api/generate-schema'),
|
||||
])
|
||||
|
||||
expect(login.status).not.toBe(404)
|
||||
expect(reference.status).toBe(200)
|
||||
expect(reference.headers.get('content-type')).toContain('text/html')
|
||||
expect(schema.status).toBe(200)
|
||||
await expect(schema.json()).resolves.toMatchObject({ paths: expect.any(Object) })
|
||||
})
|
||||
})
|
||||
|
||||
function collectOpenApiReferences(value: unknown): string[] {
|
||||
if (Array.isArray(value)) return value.flatMap(collectOpenApiReferences)
|
||||
if (!value || typeof value !== 'object') return []
|
||||
const object = value as Record<string, unknown>
|
||||
return [
|
||||
...(typeof object.$ref === 'string' ? [object.$ref] : []),
|
||||
...Object.values(object).flatMap(collectOpenApiReferences),
|
||||
]
|
||||
}
|
||||
|
||||
function resolveLocalOpenApiReference(document: unknown, reference: string): unknown {
|
||||
if (!reference.startsWith('#/')) return undefined
|
||||
return reference
|
||||
.slice(2)
|
||||
.split('/')
|
||||
.map((segment) => segment.replaceAll('~1', '/').replaceAll('~0', '~'))
|
||||
.reduce<unknown>((value, segment) => {
|
||||
if (!value || typeof value !== 'object') return undefined
|
||||
return (value as Record<string, unknown>)[segment]
|
||||
}, document)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user