diff --git a/.changeset/28709-client-auth-me-permissions.md b/.changeset/28709-client-auth-me-permissions.md new file mode 100644 index 00000000000..69526b6f153 --- /dev/null +++ b/.changeset/28709-client-auth-me-permissions.md @@ -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()`. diff --git a/packages/client/src/auth-me-permissions.test.ts b/packages/client/src/auth-me-permissions.test.ts new file mode 100644 index 00000000000..073b5d3b40d --- /dev/null +++ b/packages/client/src/auth-me-permissions.test.ts @@ -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; + +/** The session's organization membership: what the resolver reads to stamp `tenantId`. */ +const TABLES: Record = { + 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 = { + 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: (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', + }); + }); +}); diff --git a/packages/client/src/index.ts b/packages/client/src/index.ts index 027ab0283d1..de8859359b8 100644 --- a/packages/client/src/index.ts +++ b/packages/client/src/index.ts @@ -31,6 +31,7 @@ import { PublishPackageDraftsResponse, LoginRequest, SessionResponse, + MePermissionsResponse, GetPresignedUrlRequest, PresignedUrlResponse, CompleteUploadRequest, @@ -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 => { + 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 diff --git a/packages/qa/dogfood/test/route-ledger-live-mount-parity.dogfood.test.ts b/packages/qa/dogfood/test/route-ledger-live-mount-parity.dogfood.test.ts index 3c9489083dc..43b809da4de 100644 --- a/packages/qa/dogfood/test/route-ledger-live-mount-parity.dogfood.test.ts +++ b/packages/qa/dogfood/test/route-ledger-live-mount-parity.dogfood.test.ts @@ -140,6 +140,12 @@ function collectLedgerRows(): LedgerRow[] { const UNEXERCISED_BY_THIS_BOOT: Record = { '* /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 diff --git a/packages/runtime/src/route-ledger.ts b/packages/runtime/src/route-ledger.ts index 74b1d3cf29e..3dc5d07f620 100644 --- a/packages/runtime/src/route-ledger.ts +++ b/packages/runtime/src/route-ledger.ts @@ -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 @@ -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',