Skip to content

Commit 8afaf12

Browse files
feat(client,runtime): auth.mePermissions() reads GET /auth/me/permissions as MePermissionsResponse (#28766)
<!-- claude-projects-attribution: {"github_login":"os-project-manager"} --> _Requested by **Leehom** · [project thread](https://claude.ai/code/project/chan_01TenzsRopZj4jMbvjX8uz1X?thread=cmsg_01TenzsRopZj4jMbvjX8uz1XFC6SL3DC5erhU84B8GXB6y)_ Fixes #28709 Clause-②: yes (widening) — one new SDK method, `client.auth.mePermissions()`, over the existing `GET /auth/me/permissions`. No wire change, no new route, and no `packages/spec/**` file is touched. Metadata-protocol: none — nothing a metadata author writes is read differently; the method reads a response body already declared in `@objectstack/spec/api` by PR #28704. ## 维护者速读 - 改了什么:`@objectstack/client` 多了一个方法 `client.auth.mePermissions()`,读当前用户的有效权限(`GET /api/v1/auth/me/permissions`),返回类型就是 spec 里已声明的 `MePermissionsResponse`;未登录时返回 `{ authenticated: false }`,不抛错。路由台账给这条路由补了一行。 - 为什么改:#28704 已把这个响应体在 spec 里严格声明,但 SDK 没有方法,下一个读权限的功能(例如 #28428 的队列开关)只能再手写一次裸 fetch。没有台账行时,SDK 的 URL 一致性门禁不认这个方法(只命中 `* /auth/**` 通配行)。 - 风险与回滚:纯加法:一个新方法、一行台账、一个 dogfood 门禁的豁免条目。线上行为不变。回滚即 revert。 - 先看:`packages/runtime/src/route-ledger.ts` 新行(说明它由 plugin-hono-server 的 raw app 提供,不经 `dispatch()`);`packages/qa/dogfood/test/route-ledger-live-mount-parity.dogfood.test.ts` 的新豁免条目;`packages/client/src/index.ts` 的 `mePermissions`。 - 两分钟验证:`pnpm --filter @objectstack/client exec vitest run src/auth-me-permissions.test.ts src/client-url-conformance.test.ts`,预期 `Tests 6 passed (6)`。 - 需要你做的:无,席位落地。 ## What changes - **`packages/client/src/index.ts`**: `auth.mePermissions()` resolves `MePermissionsResponse`; it GETs `${baseUrl}${getRoute('auth')}/me/permissions` through the shared fetch wrapper. The type is imported from `@objectstack/spec/api`, not restated. Its JSDoc says how it differs from `auth.me()`: a signed-out caller resolves `{ authenticated: false }` (the route answers that at 200) rather than rejecting. - **`packages/runtime/src/route-ledger.ts`**: a `GET /auth/me/permissions` row (`domain: '/auth'`, `disposition: 'sdk'`, `client: 'auth.mePermissions'`, `responseSchema: 'MePermissionsResponseSchema'`). Exact rows sort ahead of the `* /auth/**` family in `client-url-conformance`, so the method is credited to this row and not to the wildcard. The note states what serves it: plugin-hono-server's `registerCurrentUserEndpoints` with `rawApp.get()`, ahead of plugin-auth's catch-all, not `dispatch()` (the `/auth` domain only relays to the auth service, which does not serve this path). It names the suite that gives the `responseSchema` its conformance coverage: `current-user-endpoints-has-any-queue.test.ts` drives this handler and parses every body it provokes through the strict schema. The census sentence was regenerated (`check-route-ledger-census.mjs --fix`: 115 → 116). - **`packages/qa/dogfood/test/route-ledger-live-mount-parity.dogfood.test.ts`**: `GET /api/v1/auth/me/permissions` joins `UNEXERCISED_BY_THIS_BOOT`, with its reason. A raw-app mount never passes through the `IHttpServer` port, so without the pin direction 1 ("every ledgered route is reachably mounted") would red on this row. Dogfood does not run in PR CI, so a missing pin would only show up on `main`. - **`.changeset/28709-client-auth-me-permissions.md`**: `@objectstack/client` patch with both declaration lines. `Metadata-protocol: none` grades every entry `patch` (`check-changeset-no-major`). `route-ledger.ts` ships in no package: `ROUTE_LEDGER` / `LEGACY_CHAIN_PREFIXES` get 0 hits in `packages/runtime/dist` (control `createDispatcherPlugin` hits `dist/index.js`), and dogfood is private. ## Tests - New `packages/client/src/auth-me-permissions.test.ts` (3 cases) runs over the **real handler**. plugin-hono-server's `registerCurrentUserEndpoints` is mounted on `new HonoHttpServer(0).getRawApp()`, the app `HonoServerPlugin` mounts it on, and the client's `fetch` hands each request to that app. - Signed in: exactly one `GET http://localhost:3000/api/v1/auth/me/permissions`. The resolved value parses through `MePermissionsResponseSchema` with no issues, and narrows on `authenticated` to the caller's own `userId`, `tenantId`, `hasAnyQueue` and `permissionSets`. - Anonymous: resolves `{ authenticated: false }` and does not throw. - The ledger row names `auth.mePermissions` and the schema these cases parse with. - **Ablation** (`scripts/ablation-replace.mjs`, anchor hit once, blob `3dc5d07f` → `9b262a18`): the row's route is renamed to `GET /auth/me/permissions-ablated`, and `client-url-conformance.test.ts` reds with 1 failed / 2 passed: `auth.mePermissions → GET /api/v1/auth/me/permissions (via * /auth/**): expected 1 to be +0`. That is the failure this card was filed for, and the row is what closes it. Restore: blob equals HEAD's (`3dc5d07f`) and `git diff HEAD` is empty. The ledger is imported as relative source, so no build sits on the path. ## Acceptance notes - **Which ledger.** The order's hypothesis was the runtime ledger. Measured: that file's SCOPE header describes dispatcher routes, and the dispatcher does not serve this path. But the file already holds rows for doors a plugin serves from the raw app, and its own conformance header names raw-app doors as rows it guards by census. plugin-auth's ledger cannot take the row: both halves are held to better-auth's live table and to `auth-plugin.ts`'s own mounts. A new plugin-hono-server ledger would be a sixth member of the client union with its own conformance gate. It would also have to give dispositions to `/auth/me/localization` and `/me/apps`, which no ledger carries today and this card does not ask about. So the row went into the runtime ledger, and its note says plainly that the door is a raw-app mount. - `/auth/me/localization` and `/me/apps` (same registrar) stay in no ledger and have no client method. Not filed: no consumer asks for an SDK method for them. - No console migration onto the method (order ruling); that is a follow-up of its own. - Prior art: `ff0d7c02` (written and then reverted on #28508's branch) was reused for the method and its JSDoc. Its test used a mocked fetch; this one runs over the real handler instead. ## Verification All at head `7ecb0017` (base `7a48bf73`, `origin/main` when the branch was cut). <details><summary>Package suites and typecheck (under the verify lock)</summary> - `pnpm --filter @objectstack/client test`: Test Files 54 passed (54), Tests 693 passed (693). - `pnpm --filter @objectstack/client typecheck` (`tsc --noEmit` plus `check:test-typecheck` over `tsconfig.test.json`, which covers the new test): exit 0. - `pnpm --filter @objectstack/runtime exec vitest run src/route-ledger.conformance.test.ts`: 8 passed (8). `pnpm --filter @objectstack/runtime typecheck`: exit 0. - Targeted: `auth-me-permissions`, `client-url-conformance`, `route-ledger-coverage`, `route-ledger-response-schema`, `auth-route-ledger-coverage`: 5 files, 16 passed (16). - Dogfood (does not run in PR CI, so measured here after `pnpm --workspace-concurrency=2 --filter '@objectstack/dogfood^...' build`): `route-ledger-live-mount-parity.dogfood.test.ts` 8 passed (8). With the new pin's key changed (ablation, blob `43b809da` → `d1fbf06f`): 2 failed / 6 passed. Direction 1 says `runtime/route-ledger.ts: GET /auth/me/permissions — LEDGERED BUT NOT MOUNTED` and the stale-pin check names the changed key. Restore: blob equals HEAD's, `git diff HEAD` empty. - `pnpm exec eslint --no-inline-config` over the four touched source files: exit 0. </details> <details><summary>Derived gates (dispatch-gates --commands, 66 families)</summary> - 65 run with exit 0, 1 NOT MEASURED. `dispatch-gates.mjs --ran`: "66 derived famil(ies) accounted for — 65 run, 1 NOT-MEASURED". - NOT MEASURED: `pnpm check:dual-build-cjs-loads`, exit 3 (PREREQUISITE NOT MET: packages outside this closure have no `dist`). CI runs it. - Three first readings were re-run once their prerequisite was in place. `check-plugin-teardown-shape.mjs --self-test` first exited 3 on the shallow clone; after `git fetch --depth=1 origin 621a487` it exited 0 (48 cases). `spec check:skill-examples` first exited 3 until `@objectstack/client-react` was built, then 0 (279 prose examples, 23 client SDK blocks). `check:dts-closure` first exited 1 because it ran while a closure build was still writing, then 0 (241/241 declaration files across 77 packages). - `check:route-ledger-census`: exit 0 after `--fix` (115 → 116). </details> CI check-runs: read by the seat on this head. 🤖 Generated with [Claude Code](https://claude.com/claude-code) https://claude.ai/code/session_01FEkQ5mKQh9Ew1XG2GQk2UN --- _Generated by [Claude Code](https://claude.ai/code/session_01FEkQ5mKQh9Ew1XG2GQk2UN)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent 850eab2 commit 8afaf12

5 files changed

Lines changed: 179 additions & 1 deletion

File tree

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
---
2+
"@objectstack/client": patch
3+
---
4+
5+
feat(client): `client.auth.mePermissions()` reads the caller's effective permissions, typed `MePermissionsResponse`
6+
7+
Clause-②: yes (widening) — one new SDK method over an existing route; no wire change and no new route.
8+
9+
Metadata-protocol: none
10+
11+
- **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.
12+
- **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()`.
Lines changed: 132 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,132 @@
1+
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
/**
4+
* [#28709] `client.auth.mePermissions()`, the typed read of
5+
* `GET /auth/me/permissions`, whose body `MePermissionsResponseSchema`
6+
* (`@objectstack/spec/api`) declares strictly.
7+
*
8+
* Driven over the REAL handler: plugin-hono-server's
9+
* `registerCurrentUserEndpoints` mounted on the adapter's raw Hono app (the
10+
* one `HonoServerPlugin` mounts it on), with the client's
11+
* transport handing every request to that app. So the URL the method builds is
12+
* proven by the route answering it, and the body it resolves is the one the
13+
* handler actually writes, not a fixture typed to look like it. Same mount
14+
* shape as plugin-hono-server's own has-any-queue suite: the real registrar
15+
* and a locator that THROWS for an unregistered slot, as the kernel's does.
16+
*/
17+
18+
import { describe, it, expect } from 'vitest';
19+
import { MePermissionsResponseSchema } from '@objectstack/spec/api';
20+
import { OWNERSHIP_QUEUE_SERVICE } from '@objectstack/spec/contracts';
21+
import { HonoHttpServer, registerCurrentUserEndpoints } from '@objectstack/plugin-hono-server';
22+
import { ObjectStackClient } from './index';
23+
import { ROUTE_LEDGER } from '../../runtime/src/route-ledger';
24+
25+
const BASE = 'http://localhost:3000';
26+
const USER = 'usr_member';
27+
const ACTIVE_ORG = 'org_active';
28+
29+
type Row = Record<string, any>;
30+
31+
/** The session's organization membership: what the resolver reads to stamp `tenantId`. */
32+
const TABLES: Record<string, Row[]> = {
33+
sys_user: [{ id: USER, email: 'member@example.com' }],
34+
sys_member: [{ user_id: USER, organization_id: ACTIVE_ORG, role: 'member' }],
35+
};
36+
37+
/** Scalar equality only; any combinator is refused rather than misread. */
38+
function matches(row: Row, where: Row | undefined): boolean {
39+
return Object.entries(where ?? {}).every(([key, cond]) => {
40+
if (key.startsWith('$') || (cond !== null && typeof cond === 'object')) {
41+
throw new Error(`fake driver: unsupported predicate on ${key}`);
42+
}
43+
return (row[key] ?? null) === (cond ?? null);
44+
});
45+
}
46+
47+
const ql = {
48+
find: async (object: string, opts: any) => {
49+
const rows = (TABLES[object] ?? []).filter((r) => matches(r, opts?.where));
50+
return typeof opts?.limit === 'number' ? rows.slice(0, opts.limit) : rows;
51+
},
52+
registry: { getAllApps: () => [], getAllObjects: () => [] },
53+
getSchema: () => undefined,
54+
};
55+
56+
/** A client whose every request is answered by the real current-user handlers. */
57+
function mount({ signedIn }: { signedIn: boolean }) {
58+
const services: Record<string, unknown> = {
59+
auth: {
60+
api: {
61+
getSession: async () => (signedIn
62+
? { user: { id: USER, email: 'member@example.com' }, session: { activeOrganizationId: ACTIVE_ORG } }
63+
: null),
64+
},
65+
},
66+
objectql: ql,
67+
metadata: { list: async () => [] as unknown[] },
68+
security: {
69+
resolvePermissionSetsForContext: async () => [
70+
{ name: 'member_default', systemPermissions: [], objects: {}, fields: {} },
71+
],
72+
},
73+
[OWNERSHIP_QUEUE_SERVICE]: { hasAnyQueue: async () => true },
74+
};
75+
// The adapter's own Hono app, never listening: the app the plugin mounts on.
76+
const app = new HonoHttpServer(0).getRawApp();
77+
registerCurrentUserEndpoints({
78+
rawApp: app,
79+
ctx: {
80+
logger: { debug() {}, warn() {} },
81+
getService: <T,>(name: string): T => {
82+
if (!(name in services)) throw new Error(`[Kernel] Service '${name}' not found`);
83+
return services[name] as T;
84+
},
85+
},
86+
});
87+
const requests: Array<{ method: string; url: string }> = [];
88+
const client = new ObjectStackClient({
89+
baseUrl: BASE,
90+
fetch: async (input: RequestInfo | URL, init?: RequestInit) => {
91+
requests.push({ method: (init?.method ?? 'GET').toUpperCase(), url: String(input) });
92+
return app.request(String(input), init);
93+
},
94+
});
95+
return { client, requests };
96+
}
97+
98+
describe('[#28709] client.auth.mePermissions over the real /auth/me/permissions handler', () => {
99+
it('GETs /api/v1/auth/me/permissions and resolves the authenticated body the handler writes', async () => {
100+
const { client, requests } = mount({ signedIn: true });
101+
const answer = await client.auth.mePermissions();
102+
103+
expect(requests).toEqual([{ method: 'GET', url: `${BASE}/api/v1/auth/me/permissions` }]);
104+
// The declared contract holds the resolved value: a key the handler
105+
// sends that the spec does not declare reds here.
106+
const parsed = MePermissionsResponseSchema.safeParse(answer);
107+
expect(parsed.success ? [] : parsed.error.issues).toEqual([]);
108+
// The type narrows on the discriminator, onto the caller's own facts.
109+
if (!answer.authenticated) throw new Error('expected the authenticated branch');
110+
expect(answer.userId).toBe(USER);
111+
expect(answer.tenantId).toBe(ACTIVE_ORG);
112+
expect(answer.hasAnyQueue).toBe(true);
113+
expect(answer.permissionSets).toContain('member_default');
114+
});
115+
116+
it('an anonymous caller resolves { authenticated: false }, not a throw', async () => {
117+
const { client } = mount({ signedIn: false });
118+
const answer = await client.auth.mePermissions();
119+
expect(answer).toEqual({ authenticated: false });
120+
expect(MePermissionsResponseSchema.safeParse(answer).success).toBe(true);
121+
});
122+
123+
it('the route ledger names this method and the schema these cases parse with', () => {
124+
const row = ROUTE_LEDGER.find((r) => r.route === 'GET /auth/me/permissions');
125+
expect(row).toMatchObject({
126+
domain: '/auth',
127+
disposition: 'sdk',
128+
client: 'auth.mePermissions',
129+
responseSchema: 'MePermissionsResponseSchema',
130+
});
131+
});
132+
});

‎packages/client/src/index.ts‎

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,7 @@ import {
3131
PublishPackageDraftsResponse,
3232
LoginRequest,
3333
SessionResponse,
34+
MePermissionsResponse,
3435
GetPresignedUrlRequest,
3536
PresignedUrlResponse,
3637
CompleteUploadRequest,
@@ -4991,6 +4992,26 @@ export class ObjectStackClient {
49914992
return normalizeSessionResponse(await res.json());
49924993
},
49934994

4995+
/**
4996+
* [#28709] The caller's effective permissions: `GET /auth/me/permissions`,
4997+
* the body the console reads once per session for its whole permission
4998+
* layer, declared strictly by `MePermissionsResponseSchema`
4999+
* (`@objectstack/spec/api`). Served by plugin-hono-server, not better-auth.
5000+
*
5001+
* Discriminate on `authenticated`: an anonymous caller RESOLVES
5002+
* `{ authenticated: false }` (the route answers it at 200, so a client can
5003+
* tell "signed out" from a fault), unlike {@link me}, which rejects. The
5004+
* authenticated answer carries `hasAnyQueue` among the rest;
5005+
* `systemPermissions` / `tabPermissions` are absent only on a server
5006+
* without SecurityPlugin, where absent means unknown and an empty array
5007+
* means none.
5008+
*/
5009+
mePermissions: async (): Promise<MePermissionsResponse> => {
5010+
const route = this.getRoute('auth');
5011+
const res = await this.fetch(`${this.baseUrl}${route}/me/permissions`);
5012+
return (await res.json()) as MePermissionsResponse;
5013+
},
5014+
49945015
/**
49955016
* Register a new user account
49965017
* Uses better-auth endpoint: POST /sign-up/email

‎packages/qa/dogfood/test/route-ledger-live-mount-parity.dogfood.test.ts‎

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -140,6 +140,12 @@ function collectLedgerRows(): LedgerRow[] {
140140
const UNEXERCISED_BY_THIS_BOOT: Record<string, string> = {
141141
'* /api/v1/auth/**':
142142
'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',
143+
// [#28709] One door, raw only. Unlike the "two doors" rows below, the
144+
// dispatcher does NOT answer this path: its `/auth` domain relays to the auth
145+
// service, which does not serve it. plugin-hono-server's
146+
// registerCurrentUserEndpoints is the only owner.
147+
'GET /api/v1/auth/me/permissions':
148+
'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',
143149
'* /api/v1/apps/**':
144150
'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)',
145151
// [#22576] Two doors, one path. These rows ledger the DISPATCHER's

‎packages/runtime/src/route-ledger.ts‎

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -301,7 +301,7 @@ export const NON_DISPATCH_MOUNT_PREFIXES = [
301301
/**
302302
* The ledger.
303303
*
304-
* CENSUS (generated): this list holds 115 rows.
304+
* CENSUS (generated): this list holds 116 rows.
305305
*
306306
* ⛔ THAT NUMBER IS WRITTEN BY A TOOL — never by hand.
307307
* `pnpm check:route-ledger-census` counts the rows below and fails when the two
@@ -634,6 +634,13 @@ export const ROUTE_LEDGER: readonly RouteLedgerEntry[] = [
634634
// ── auth (better-auth passthrough) ────────────────────────────────────────
635635
{ route: '* /auth/**', domain: '/auth', disposition: 'sdk', client: 'auth.me',
636636
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' },
637+
// [#28709] Not a better-auth endpoint, so neither half of plugin-auth's ledger
638+
// can carry it: that ledger is held to better-auth's live table and to the
639+
// routes auth-plugin.ts mounts. Without this exact row the SDK method rests on
640+
// the `* /auth/**` family above, which `client-url-conformance` refuses.
641+
{ route: 'GET /auth/me/permissions', domain: '/auth', disposition: 'sdk', client: 'auth.mePermissions',
642+
responseSchema: 'MePermissionsResponseSchema',
643+
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`" },
637644

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

0 commit comments

Comments
 (0)