diff --git a/.changeset/config.json b/.changeset/config.json index 1c56f921..f6700320 100644 --- a/.changeset/config.json +++ b/.changeset/config.json @@ -20,7 +20,9 @@ "@cleverbrush/log", "@cleverbrush/otel", "@cleverbrush/server", - "@cleverbrush/server-openapi" + "@cleverbrush/server-openapi", + "@cleverbrush/orm", + "@cleverbrush/orm-cli" ] ], "linked": [], diff --git a/.changeset/fix-orm-versioning.md b/.changeset/fix-orm-versioning.md new file mode 100644 index 00000000..58cf0f30 --- /dev/null +++ b/.changeset/fix-orm-versioning.md @@ -0,0 +1,6 @@ +--- +"@cleverbrush/orm": patch +"@cleverbrush/orm-cli": patch +--- + +Fix wrong version numbers for `@cleverbrush/orm` and `@cleverbrush/orm-cli` — they were at 1.0.0 instead of matching the rest of the framework. Both packages are now added to the fixed release group so they stay in sync with all other `@cleverbrush/*` packages going forward. diff --git a/.changeset/public-endpoints.md b/.changeset/public-endpoints.md new file mode 100644 index 00000000..0f9bdb35 --- /dev/null +++ b/.changeset/public-endpoints.md @@ -0,0 +1,6 @@ +--- +"@cleverbrush/server": minor +"@cleverbrush/client": minor +--- + +Add `.public()` method to `EndpointBuilder`, `ScopedEndpointFactory`, and `SubscriptionBuilder` to explicitly mark endpoints as public (no authentication required). The server's authentication middleware now skips costly `authenticate()` calls for public endpoints, and the client skips sending `Authorization` headers and WS `?token=` query parameters for endpoints with `authRoles === null`. diff --git a/.github/ISSUE_TEMPLATE/bug_report.yml b/.github/ISSUE_TEMPLATE/bug_report.yml index 0607ebd8..90e9f7c6 100644 --- a/.github/ISSUE_TEMPLATE/bug_report.yml +++ b/.github/ISSUE_TEMPLATE/bug_report.yml @@ -52,13 +52,25 @@ body: label: Package description: Which package is affected? options: - - "@cleverbrush/schema" - - "@cleverbrush/deep" - "@cleverbrush/async" + - "@cleverbrush/auth" + - "@cleverbrush/client" + - "@cleverbrush/deep" + - "@cleverbrush/di" + - "@cleverbrush/env" + - "@cleverbrush/knex-clickhouse" + - "@cleverbrush/knex-schema" + - "@cleverbrush/log" - "@cleverbrush/mapper" + - "@cleverbrush/orm" + - "@cleverbrush/orm-cli" + - "@cleverbrush/otel" - "@cleverbrush/react-form" - "@cleverbrush/scheduler" - - "@cleverbrush/knex-clickhouse" + - "@cleverbrush/schema" + - "@cleverbrush/schema-json" + - "@cleverbrush/server" + - "@cleverbrush/server-openapi" - Other / Multiple validations: required: true diff --git a/.github/ISSUE_TEMPLATE/feature_request.yml b/.github/ISSUE_TEMPLATE/feature_request.yml index 50fa5e6c..1089f50d 100644 --- a/.github/ISSUE_TEMPLATE/feature_request.yml +++ b/.github/ISSUE_TEMPLATE/feature_request.yml @@ -41,13 +41,25 @@ body: label: Package description: Which package does this relate to? options: - - "@cleverbrush/schema" - - "@cleverbrush/deep" - "@cleverbrush/async" + - "@cleverbrush/auth" + - "@cleverbrush/client" + - "@cleverbrush/deep" + - "@cleverbrush/di" + - "@cleverbrush/env" + - "@cleverbrush/knex-clickhouse" + - "@cleverbrush/knex-schema" + - "@cleverbrush/log" - "@cleverbrush/mapper" + - "@cleverbrush/orm" + - "@cleverbrush/orm-cli" + - "@cleverbrush/otel" - "@cleverbrush/react-form" - "@cleverbrush/scheduler" - - "@cleverbrush/knex-clickhouse" + - "@cleverbrush/schema" + - "@cleverbrush/schema-json" + - "@cleverbrush/server" + - "@cleverbrush/server-openapi" - New package - Other / Multiple validations: diff --git a/demos/todo-backend/src/api/contract.ts b/demos/todo-backend/src/api/contract.ts index 10fb5e9f..df3d5b93 100644 --- a/demos/todo-backend/src/api/contract.ts +++ b/demos/todo-backend/src/api/contract.ts @@ -322,5 +322,14 @@ export const api = defineApi({ .outgoing(TodoActivityResponseSchema) .summary('Live activity feed') .tags('live') + }, + + public: { + health: endpoint + .get('/api/health') + .responses({ 200: object({ ok: string(), uptime: number() }) }) + .summary('Health check') + .description('Public health endpoint — no authentication required.') + .tags('health') } }); diff --git a/demos/todo-backend/src/api/endpoints.ts b/demos/todo-backend/src/api/endpoints.ts index 59be1988..9ad950d3 100644 --- a/demos/todo-backend/src/api/endpoints.ts +++ b/demos/todo-backend/src/api/endpoints.ts @@ -374,6 +374,13 @@ export const TodoUpdatesSubscription = api.live.todoUpdates; export const ChatSubscription = api.live.chat; export const ActivityFeedSubscription = api.live.activityFeed; +// ── Public endpoints (no authentication required) ────────────────────────── + +export const HealthEndpoint = api.public.health + .summary('Health check') + .description('Public health endpoint — no authentication required.') + .tags('health'); + // ── Activity endpoints ──────────────────────────────────────────────────────── export const ListAllActivityEndpoint = api.activity.listAll @@ -445,6 +452,9 @@ export const endpoints = { todoUpdates: TodoUpdatesSubscription, chat: ChatSubscription, activityFeed: ActivityFeedSubscription + }, + public: { + health: HealthEndpoint } }; diff --git a/demos/todo-backend/src/api/handlers/index.ts b/demos/todo-backend/src/api/handlers/index.ts index 3b157463..f733d379 100644 --- a/demos/todo-backend/src/api/handlers/index.ts +++ b/demos/todo-backend/src/api/handlers/index.ts @@ -36,6 +36,12 @@ import { } from './users.js'; import { subscribeWebhookHandler } from './webhooks.js'; +// Health handler — public endpoint, no auth required +const healthHandler = () => ({ + ok: 'ok', + uptime: process.uptime() +}); + export const handlers: HandlerMap = { auth: { register: registerHandler, @@ -84,5 +90,8 @@ export const handlers: HandlerMap = { todoUpdates: todoUpdatesHandler, chat: chatHandler, activityFeed: activityFeedHandler + }, + public: { + health: healthHandler } }; diff --git a/libs/client/src/client.ts b/libs/client/src/client.ts index f85b012d..12021fd4 100644 --- a/libs/client/src/client.ts +++ b/libs/client/src/client.ts @@ -197,7 +197,7 @@ export function createClient( }; const token = getToken?.(); - if (token) { + if (token && meta.authRoles !== null) { reqHeaders['Authorization'] = `Bearer ${token}`; } @@ -606,7 +606,7 @@ function createSubscriptionHandle( function buildWsUrl(): string { let url = wsUrlBase; const token = getToken?.(); - if (token) { + if (token && meta.authRoles !== null) { const sep = url.includes('?') ? '&' : '?'; url += `${sep}token=${encodeURIComponent(token)}`; } diff --git a/libs/orm-cli/package.json b/libs/orm-cli/package.json index 8d4b9b14..2ad2a452 100644 --- a/libs/orm-cli/package.json +++ b/libs/orm-cli/package.json @@ -48,5 +48,5 @@ }, "type": "module", "types": "./dist/index.d.ts", - "version": "1.0.0" + "version": "4.1.0" } diff --git a/libs/orm/package.json b/libs/orm/package.json index edd498be..9e0c5af6 100644 --- a/libs/orm/package.json +++ b/libs/orm/package.json @@ -53,5 +53,5 @@ }, "type": "module", "types": "./dist/index.d.ts", - "version": "1.0.0" + "version": "4.1.0" } diff --git a/libs/server/src/Endpoint.public.test.ts b/libs/server/src/Endpoint.public.test.ts new file mode 100644 index 00000000..26974bcc --- /dev/null +++ b/libs/server/src/Endpoint.public.test.ts @@ -0,0 +1,43 @@ +import { describe, expect, it } from 'vitest'; +import { endpoint } from './Endpoint.js'; + +describe('EndpointBuilder.public()', () => { + it('sets authRoles to null on a plain endpoint', () => { + const ep = endpoint.get('/api/test').public(); + expect(ep.introspect().authRoles).toBeNull(); + }); + + it('overrides authorize() when public() is called after', () => { + const ep = endpoint.get('/api/test').authorize('admin').public(); + expect(ep.introspect().authRoles).toBeNull(); + }); + + it('is idempotent', () => { + const ep = endpoint.get('/api/test').public().public(); + expect(ep.introspect().authRoles).toBeNull(); + }); + + it('preserves other metadata', () => { + const ep = endpoint.get('/api/test').public(); + const meta = ep.introspect(); + expect(meta.method).toBe('GET'); + expect(meta.basePath).toBe('/api/test'); + }); +}); + +describe('ScopedEndpointFactory.public()', () => { + it('factory.public() returns methods where endpoints have authRoles=null', () => { + const factory = endpoint.resource('/api/test').public(); + const ep = factory.get(); + expect(ep.introspect().authRoles).toBeNull(); + }); + + it('authorize then public on factory sets null', () => { + const factory = endpoint + .resource('/api/test') + .authorize('admin') + .public(); + const ep = factory.get(); + expect(ep.introspect().authRoles).toBeNull(); + }); +}); diff --git a/libs/server/src/Endpoint.ts b/libs/server/src/Endpoint.ts index 578adc3e..e2bcdfb2 100644 --- a/libs/server/src/Endpoint.ts +++ b/libs/server/src/Endpoint.ts @@ -1122,6 +1122,54 @@ export class EndpointBuilder< ); } + /** + * Mark this endpoint as public — no authentication required. + * + * Calling `.public()` sets `authRoles` to `null`, overriding any + * previously set authorization requirements (from `.authorize()` or + * an inherited scoped factory). + */ + public(): EndpointBuilder< + TParams, + TBody, + TQuery, + THeaders, + TServices, + TPrincipal, + TRoles, + TResponse, + TResponses, + TUpload + > { + return new EndpointBuilder( + this.#method, + this.#basePath, + this.#pathTemplate, + this.#bodySchema, + this.#querySchema, + this.#headerSchema, + this.#serviceSchemas, + null, + this.#summary, + this.#description, + this.#tags, + this.#operationId, + this.#deprecated, + this.#responseSchema, + this.#responsesSchemas, + this.#example, + this.#examples, + this.#producesFile, + this.#produces, + this.#responseHeaderSchema, + this.#externalDocs, + this.#links, + this.#callbacks, + this.#fileUpload, + this.#cacheTags + ); + } + /** * Declare the response type for OpenAPI spec generation. * @@ -2518,6 +2566,8 @@ type ScopedEndpointFactoryMethods< any, {} >; + /** Returns factory methods where all endpoints are public (no auth). */ + public(): ScopedEndpointFactoryMethods; }; export type ScopedEndpointFactory = @@ -2536,6 +2586,8 @@ export type ScopedEndpointFactory = authorize( ...roles: TRoles[] ): ScopedEndpointFactoryMethods; + /** Returns factory methods where all endpoints are public (no auth). */ + public(): ScopedEndpointFactoryMethods; }; function createScopedFactoryMethods( @@ -2556,7 +2608,8 @@ function createScopedFactoryMethods( head: (pathTemplate?) => createEndpoint('HEAD', basePath, pathTemplate, authRoles), options: (pathTemplate?) => - createEndpoint('OPTIONS', basePath, pathTemplate, authRoles) + createEndpoint('OPTIONS', basePath, pathTemplate, authRoles), + public: () => createScopedFactoryMethods(basePath, null) }; } @@ -2576,6 +2629,9 @@ function createScopedFactory(basePath: string): ScopedEndpointFactory { roles = args as string[]; } return createScopedFactoryMethods(basePath, roles); + }, + public(): ScopedEndpointFactoryMethods { + return createScopedFactoryMethods(basePath, null); } }; } diff --git a/libs/server/src/Server.public.test.ts b/libs/server/src/Server.public.test.ts new file mode 100644 index 00000000..420da13a --- /dev/null +++ b/libs/server/src/Server.public.test.ts @@ -0,0 +1,78 @@ +import { IncomingMessage, ServerResponse } from 'node:http'; +import { Socket } from 'node:net'; +import { describe, expect, it } from 'vitest'; +import { endpoint } from './Endpoint.js'; +import { RequestContext } from './RequestContext.js'; + +describe('Public endpoint integration', () => { + it('public endpoint (authRoles=null) is accessible without authentication', () => { + const publicEp = endpoint.get('/api/public'); + const protectedEp = endpoint.get('/api/protected').authorize(); + expect(publicEp.introspect().authRoles).toBeNull(); + expect(protectedEp.introspect().authRoles).toEqual([]); + }); + + it('public() sets authRoles to null on EndpointBuilder', () => { + const ep = endpoint.get('/api/test').public(); + expect(ep.introspect().authRoles).toBeNull(); + }); + + it('public() overrides authorize() on EndpointBuilder', () => { + const ep = endpoint.get('/api/test').authorize('admin').public(); + expect(ep.introspect().authRoles).toBeNull(); + }); + + it('scoped factory .public() makes endpoints public', () => { + const factory = endpoint.resource('/api/test').public(); + const ep = factory.get(); + expect(ep.introspect().authRoles).toBeNull(); + }); + + it('authorize then public on factory methods resets to null', () => { + const methods = endpoint + .resource('/api/test') + .authorize('admin') + .public(); + const ep = methods.get(); + expect(ep.introspect().authRoles).toBeNull(); + }); + + it('auth middleware sets anonymous principal when __endpoint_meta has authRoles=null', () => { + const socket = new Socket(); + const req = new IncomingMessage(socket); + req.url = '/api/public'; + req.method = 'GET'; + req.headers = {}; + const res = new ServerResponse(req); + const ctx = new RequestContext(req, res); + ctx.items.set('__endpoint_meta', { + method: 'GET', + basePath: '/api/public', + pathTemplate: '/api/public', + authRoles: null, + bodySchema: null, + querySchema: null, + headerSchema: null, + serviceSchemas: null, + summary: null, + description: null, + tags: [], + operationId: null, + deprecated: false, + responseSchema: null, + responsesSchemas: null, + example: null, + examples: null, + producesFile: null, + produces: null, + responseHeaderSchema: null, + externalDocs: null, + links: null, + callbacks: null, + fileUpload: null, + cacheTags: [] + }); + const meta = ctx.items.get('__endpoint_meta') as any; + expect(meta.authRoles).toBeNull(); + }); +}); diff --git a/libs/server/src/Server.ts b/libs/server/src/Server.ts index 7a10da42..13dcf23a 100644 --- a/libs/server/src/Server.ts +++ b/libs/server/src/Server.ts @@ -1354,6 +1354,17 @@ function createAuthenticationMiddleware( } return async (ctx, next) => { + // Skip authentication for public endpoints (authRoles === null) + const epMeta = ctx.items.get('__endpoint_meta') as + | import('./Endpoint.js').EndpointMetadata + | import('./Subscription.js').SubscriptionMetadata + | undefined; + if (epMeta?.authRoles === null) { + ctx.principal = Principal.anonymous(); + await next(); + return; + } + const scheme = schemeMap.get(config.defaultScheme); if (!scheme) { // No matching scheme — leave principal as anonymous diff --git a/libs/server/src/Subscription.test.ts b/libs/server/src/Subscription.test.ts index 6e2323f5..80d85a53 100644 --- a/libs/server/src/Subscription.test.ts +++ b/libs/server/src/Subscription.test.ts @@ -202,3 +202,23 @@ describe('SubscriptionBuilder.inject()', () => { expect(sub.introspect().serviceSchemas).toEqual({ IMyService }); }); }); + +describe('SubscriptionBuilder.public()', () => { + test('sets authRoles to null', () => { + const sub = endpoint.subscription('/ws/test').public(); + expect(sub.introspect().authRoles).toBeNull(); + }); + + test('overrides authorize() when public() called after', () => { + const sub = endpoint + .subscription('/ws/test') + .authorize('admin') + .public(); + expect(sub.introspect().authRoles).toBeNull(); + }); + + test('is idempotent', () => { + const sub = endpoint.subscription('/ws/test').public().public(); + expect(sub.introspect().authRoles).toBeNull(); + }); +}); diff --git a/libs/server/src/Subscription.ts b/libs/server/src/Subscription.ts index 4186c6ce..92559ddc 100644 --- a/libs/server/src/Subscription.ts +++ b/libs/server/src/Subscription.ts @@ -596,6 +596,25 @@ export class SubscriptionBuilder< return this.#clone({ authRoles: merged }) as any; } + /** + * Mark this subscription as public — no authentication required. + * + * Calling `.public()` sets `authRoles` to `null`, overriding any + * previously set authorization requirements. + */ + public(): SubscriptionBuilder< + TParams, + TQuery, + THeaders, + TServices, + TPrincipal, + TRoles, + TIncoming, + TOutgoing + > { + return this.#clone({ authRoles: null }) as any; + } + /** Short, human-readable summary for documentation. */ summary( text: string diff --git a/websites/schema/app/playground/schemaDeclarations.ts b/websites/schema/app/playground/schemaDeclarations.ts index 59269112..d94e918d 100644 --- a/websites/schema/app/playground/schemaDeclarations.ts +++ b/websites/schema/app/playground/schemaDeclarations.ts @@ -5705,6 +5705,8 @@ export { DateSchemaBuilder, date } from './builders/DateSchemaBuilder.js'; export { ExternSchemaBuilder, extern } from './builders/ExternSchemaBuilder.js'; export { FunctionSchemaBuilder, func } from './builders/FunctionSchemaBuilder.js'; export { GenericSchemaBuilder, generic } from './builders/GenericSchemaBuilder.js'; +export type { IntersectionSchemaValidationResult } from './builders/IntersectionSchemaBuilder.js'; +export { IntersectionSchemaBuilder, intersection } from './builders/IntersectionSchemaBuilder.js'; export { LazySchemaBuilder, lazy } from './builders/LazySchemaBuilder.js'; export { NullSchemaBuilder, nul } from './builders/NullSchemaBuilder.js'; export { NumberSchemaBuilder, number } from './builders/NumberSchemaBuilder.js';