Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 3 additions & 1 deletion .changeset/config.json
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,9 @@
"@cleverbrush/log",
"@cleverbrush/otel",
"@cleverbrush/server",
"@cleverbrush/server-openapi"
"@cleverbrush/server-openapi",
"@cleverbrush/orm",
"@cleverbrush/orm-cli"
]
],
"linked": [],
Expand Down
6 changes: 6 additions & 0 deletions .changeset/fix-orm-versioning.md
Original file line number Diff line number Diff line change
@@ -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.
6 changes: 6 additions & 0 deletions .changeset/public-endpoints.md
Original file line number Diff line number Diff line change
@@ -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`.
18 changes: 15 additions & 3 deletions .github/ISSUE_TEMPLATE/bug_report.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
18 changes: 15 additions & 3 deletions .github/ISSUE_TEMPLATE/feature_request.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
9 changes: 9 additions & 0 deletions demos/todo-backend/src/api/contract.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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')
}
});
10 changes: 10 additions & 0 deletions demos/todo-backend/src/api/endpoints.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -445,6 +452,9 @@ export const endpoints = {
todoUpdates: TodoUpdatesSubscription,
chat: ChatSubscription,
activityFeed: ActivityFeedSubscription
},
public: {
health: HealthEndpoint
}
};

Expand Down
9 changes: 9 additions & 0 deletions demos/todo-backend/src/api/handlers/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<typeof endpoints> = {
auth: {
register: registerHandler,
Expand Down Expand Up @@ -84,5 +90,8 @@ export const handlers: HandlerMap<typeof endpoints> = {
todoUpdates: todoUpdatesHandler,
chat: chatHandler,
activityFeed: activityFeedHandler
},
public: {
health: healthHandler
}
};
4 changes: 2 additions & 2 deletions libs/client/src/client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -197,7 +197,7 @@ export function createClient<T extends ApiContract>(
};

const token = getToken?.();
if (token) {
if (token && meta.authRoles !== null) {
reqHeaders['Authorization'] = `Bearer ${token}`;
}

Expand Down Expand Up @@ -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)}`;
}
Expand Down
2 changes: 1 addition & 1 deletion libs/orm-cli/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -48,5 +48,5 @@
},
"type": "module",
"types": "./dist/index.d.ts",
"version": "1.0.0"
"version": "4.1.0"
}
2 changes: 1 addition & 1 deletion libs/orm/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -53,5 +53,5 @@
},
"type": "module",
"types": "./dist/index.d.ts",
"version": "1.0.0"
"version": "4.1.0"
}
43 changes: 43 additions & 0 deletions libs/server/src/Endpoint.public.test.ts
Original file line number Diff line number Diff line change
@@ -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();
});
});
58 changes: 57 additions & 1 deletion libs/server/src/Endpoint.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.
*
Expand Down Expand Up @@ -2518,6 +2566,8 @@ type ScopedEndpointFactoryMethods<
any,
{}
>;
/** Returns factory methods where all endpoints are public (no auth). */
public(): ScopedEndpointFactoryMethods<TPrincipal, TRoles>;
};

export type ScopedEndpointFactory<TRoles extends string = string> =
Expand All @@ -2536,6 +2586,8 @@ export type ScopedEndpointFactory<TRoles extends string = string> =
authorize(
...roles: TRoles[]
): ScopedEndpointFactoryMethods<unknown, TRoles>;
/** Returns factory methods where all endpoints are public (no auth). */
public(): ScopedEndpointFactoryMethods<TRoles>;
};

function createScopedFactoryMethods(
Expand All @@ -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)
};
}

Expand All @@ -2576,6 +2629,9 @@ function createScopedFactory(basePath: string): ScopedEndpointFactory {
roles = args as string[];
}
return createScopedFactoryMethods(basePath, roles);
},
public(): ScopedEndpointFactoryMethods<any> {
return createScopedFactoryMethods(basePath, null);
}
};
}
Expand Down
78 changes: 78 additions & 0 deletions libs/server/src/Server.public.test.ts
Original file line number Diff line number Diff line change
@@ -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();
});
});
Loading
Loading