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
12 changes: 12 additions & 0 deletions .changeset/28709-client-auth-me-permissions.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
---
"@objectstack/client": patch
---

feat(client): `client.auth.mePermissions()` reads the caller's effective permissions, typed `MePermissionsResponse`

Clause-②: yes (widening) — one new SDK method over an existing route; no wire change and no new route.

Metadata-protocol: none

- **Before.** `GET /auth/me/permissions` had a strict response contract in `@objectstack/spec/api` (`MePermissionsResponseSchema`) but no client method, so a consumer fetched the route by hand and typed the body itself.
- **Now.** `client.auth.mePermissions()` GETs `/api/v1/auth/me/permissions` and resolves `MePermissionsResponse`. Discriminate on `authenticated`: a signed-out caller resolves `{ authenticated: false }` instead of throwing, unlike `client.auth.me()`.
132 changes: 132 additions & 0 deletions packages/client/src/auth-me-permissions.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,132 @@
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.

/**
* [#28709] `client.auth.mePermissions()`, the typed read of
* `GET /auth/me/permissions`, whose body `MePermissionsResponseSchema`
* (`@objectstack/spec/api`) declares strictly.
*
* Driven over the REAL handler: plugin-hono-server's
* `registerCurrentUserEndpoints` mounted on the adapter's raw Hono app (the
* one `HonoServerPlugin` mounts it on), with the client's
* transport handing every request to that app. So the URL the method builds is
* proven by the route answering it, and the body it resolves is the one the
* handler actually writes, not a fixture typed to look like it. Same mount
* shape as plugin-hono-server's own has-any-queue suite: the real registrar
* and a locator that THROWS for an unregistered slot, as the kernel's does.
*/

import { describe, it, expect } from 'vitest';
import { MePermissionsResponseSchema } from '@objectstack/spec/api';
import { OWNERSHIP_QUEUE_SERVICE } from '@objectstack/spec/contracts';
import { HonoHttpServer, registerCurrentUserEndpoints } from '@objectstack/plugin-hono-server';
import { ObjectStackClient } from './index';
import { ROUTE_LEDGER } from '../../runtime/src/route-ledger';

const BASE = 'http://localhost:3000';
const USER = 'usr_member';
const ACTIVE_ORG = 'org_active';

type Row = Record<string, any>;

/** The session's organization membership: what the resolver reads to stamp `tenantId`. */
const TABLES: Record<string, Row[]> = {
sys_user: [{ id: USER, email: 'member@example.com' }],
sys_member: [{ user_id: USER, organization_id: ACTIVE_ORG, role: 'member' }],
};

/** Scalar equality only; any combinator is refused rather than misread. */
function matches(row: Row, where: Row | undefined): boolean {
return Object.entries(where ?? {}).every(([key, cond]) => {
if (key.startsWith('$') || (cond !== null && typeof cond === 'object')) {
throw new Error(`fake driver: unsupported predicate on ${key}`);
}
return (row[key] ?? null) === (cond ?? null);
});
}

const ql = {
find: async (object: string, opts: any) => {
const rows = (TABLES[object] ?? []).filter((r) => matches(r, opts?.where));
return typeof opts?.limit === 'number' ? rows.slice(0, opts.limit) : rows;
},
registry: { getAllApps: () => [], getAllObjects: () => [] },
getSchema: () => undefined,
};

/** A client whose every request is answered by the real current-user handlers. */
function mount({ signedIn }: { signedIn: boolean }) {
const services: Record<string, unknown> = {
auth: {
api: {
getSession: async () => (signedIn
? { user: { id: USER, email: 'member@example.com' }, session: { activeOrganizationId: ACTIVE_ORG } }
: null),
},
},
objectql: ql,
metadata: { list: async () => [] as unknown[] },
security: {
resolvePermissionSetsForContext: async () => [
{ name: 'member_default', systemPermissions: [], objects: {}, fields: {} },
],
},
[OWNERSHIP_QUEUE_SERVICE]: { hasAnyQueue: async () => true },
};
// The adapter's own Hono app, never listening: the app the plugin mounts on.
const app = new HonoHttpServer(0).getRawApp();
registerCurrentUserEndpoints({
rawApp: app,
ctx: {
logger: { debug() {}, warn() {} },
getService: <T,>(name: string): T => {
if (!(name in services)) throw new Error(`[Kernel] Service '${name}' not found`);
return services[name] as T;
},
},
});
const requests: Array<{ method: string; url: string }> = [];
const client = new ObjectStackClient({
baseUrl: BASE,
fetch: async (input: RequestInfo | URL, init?: RequestInit) => {
requests.push({ method: (init?.method ?? 'GET').toUpperCase(), url: String(input) });
return app.request(String(input), init);
},
});
return { client, requests };
}

describe('[#28709] client.auth.mePermissions over the real /auth/me/permissions handler', () => {
it('GETs /api/v1/auth/me/permissions and resolves the authenticated body the handler writes', async () => {
const { client, requests } = mount({ signedIn: true });
const answer = await client.auth.mePermissions();

expect(requests).toEqual([{ method: 'GET', url: `${BASE}/api/v1/auth/me/permissions` }]);
// The declared contract holds the resolved value: a key the handler
// sends that the spec does not declare reds here.
const parsed = MePermissionsResponseSchema.safeParse(answer);
expect(parsed.success ? [] : parsed.error.issues).toEqual([]);
// The type narrows on the discriminator, onto the caller's own facts.
if (!answer.authenticated) throw new Error('expected the authenticated branch');
expect(answer.userId).toBe(USER);
expect(answer.tenantId).toBe(ACTIVE_ORG);
expect(answer.hasAnyQueue).toBe(true);
expect(answer.permissionSets).toContain('member_default');
});

it('an anonymous caller resolves { authenticated: false }, not a throw', async () => {
const { client } = mount({ signedIn: false });
const answer = await client.auth.mePermissions();
expect(answer).toEqual({ authenticated: false });
expect(MePermissionsResponseSchema.safeParse(answer).success).toBe(true);
});

it('the route ledger names this method and the schema these cases parse with', () => {
const row = ROUTE_LEDGER.find((r) => r.route === 'GET /auth/me/permissions');
expect(row).toMatchObject({
domain: '/auth',
disposition: 'sdk',
client: 'auth.mePermissions',
responseSchema: 'MePermissionsResponseSchema',
});
});
});
21 changes: 21 additions & 0 deletions packages/client/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,7 @@ import {
PublishPackageDraftsResponse,
LoginRequest,
SessionResponse,
MePermissionsResponse,
GetPresignedUrlRequest,
PresignedUrlResponse,
CompleteUploadRequest,
Expand Down Expand Up @@ -4991,6 +4992,26 @@ export class ObjectStackClient {
return normalizeSessionResponse(await res.json());
},

/**
* [#28709] The caller's effective permissions: `GET /auth/me/permissions`,
* the body the console reads once per session for its whole permission
* layer, declared strictly by `MePermissionsResponseSchema`
* (`@objectstack/spec/api`). Served by plugin-hono-server, not better-auth.
*
* Discriminate on `authenticated`: an anonymous caller RESOLVES
* `{ authenticated: false }` (the route answers it at 200, so a client can
* tell "signed out" from a fault), unlike {@link me}, which rejects. The
* authenticated answer carries `hasAnyQueue` among the rest;
* `systemPermissions` / `tabPermissions` are absent only on a server
* without SecurityPlugin, where absent means unknown and an empty array
* means none.
*/
mePermissions: async (): Promise<MePermissionsResponse> => {
const route = this.getRoute('auth');
const res = await this.fetch(`${this.baseUrl}${route}/me/permissions`);
return (await res.json()) as MePermissionsResponse;
},

/**
* Register a new user account
* Uses better-auth endpoint: POST /sign-up/email
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -140,6 +140,12 @@ function collectLedgerRows(): LedgerRow[] {
const UNEXERCISED_BY_THIS_BOOT: Record<string, string> = {
'* /api/v1/auth/**':
'plugin-auth mounts one rawApp.all() catch-all on Hono directly, so no auth route ever passes through the IHttpServer port. Audited against better-auth\'s live auth.api table by auth-route-ledger.conformance.test.ts instead',
// [#28709] One door, raw only. Unlike the "two doors" rows below, the
// dispatcher does NOT answer this path: its `/auth` domain relays to the auth
// service, which does not serve it. plugin-hono-server's
// registerCurrentUserEndpoints is the only owner.
'GET /api/v1/auth/me/permissions':
'plugin-hono-server mounts this with rawApp.get() directly on Hono (registerCurrentUserEndpoints), ahead of plugin-auth\'s catch-all, so it never passes through the IHttpServer port. Driven on the real handler, every body parsed through MePermissionsResponseSchema, by packages/plugins/plugin-hono-server/src/current-user-endpoints-has-any-queue.test.ts',
'* /api/v1/apps/**':
'the ADR-0121 declarative-endpoint carve-out is a setFallbackHandler seam, not a route — being invisible to a route table is the property that makes it incapable of shadowing one (#5040 §1-C)',
// [#22576] Two doors, one path. These rows ledger the DISPATCHER's
Expand Down
9 changes: 8 additions & 1 deletion packages/runtime/src/route-ledger.ts
Original file line number Diff line number Diff line change
Expand Up @@ -301,7 +301,7 @@ export const NON_DISPATCH_MOUNT_PREFIXES = [
/**
* The ledger.
*
* CENSUS (generated): this list holds 115 rows.
* CENSUS (generated): this list holds 116 rows.
*
* ⛔ THAT NUMBER IS WRITTEN BY A TOOL — never by hand.
* `pnpm check:route-ledger-census` counts the rows below and fails when the two
Expand Down Expand Up @@ -634,6 +634,13 @@ export const ROUTE_LEDGER: readonly RouteLedgerEntry[] = [
// ── auth (better-auth passthrough) ────────────────────────────────────────
{ route: '* /auth/**', domain: '/auth', disposition: 'sdk', client: 'auth.me',
note: 'wholesale delegate to the auth service. Not enumerable route-by-route HERE, but no longer unenumerated: plugin-auth/src/auth-route-ledger.ts carries the 55 SDK-reached routes plus the full mounted inventory, read off better-auth\'s live auth.api table' },
// [#28709] Not a better-auth endpoint, so neither half of plugin-auth's ledger
// can carry it: that ledger is held to better-auth's live table and to the
// routes auth-plugin.ts mounts. Without this exact row the SDK method rests on
// the `* /auth/**` family above, which `client-url-conformance` refuses.
{ route: 'GET /auth/me/permissions', domain: '/auth', disposition: 'sdk', client: 'auth.mePermissions',
responseSchema: 'MePermissionsResponseSchema',
note: "The caller's effective permissions, the body the console reads once per session for its whole permission layer: the whole body (no `{ success, data }` envelope), discriminated on `authenticated` (an anonymous caller is `{ authenticated: false }` at 200). ⚠️ Served by plugin-hono-server, NOT by `dispatch()`: `registerCurrentUserEndpoints` mounts it with `rawApp.get()` on the Hono app, ahead of plugin-auth's `/auth/*` catch-all, and the `/auth` domain here only relays a request to the auth service, which does not serve this path. So it never passes through the `IHttpServer` port and the live-mount parity gate pins it as unobservable. Conformance: `plugin-hono-server/src/current-user-endpoints-has-any-queue.test.ts` drives THIS handler and parses every body it provokes (full, degraded, closed, anonymous) through the strict `MePermissionsResponseSchema`; the client method over the real handler in `packages/client/src/auth-me-permissions.test.ts`" },

// ── approvals action page (ADR-0043, the hosted door) ─────────────────────
{ route: 'GET /approvals/act', domain: '/approvals/act', disposition: 'public',
Expand Down
Loading