Skip to content

Commit b1845ed

Browse files
feat(spec/contracts): verifyMcpAccessToken's verified result carries organizationId, the organization an OAuth consent bound (#24189)
Fixes #24165 Clause-②: yes The `packages/spec` contract slice of ruling `6232069920` (letter A, the maintainer's 「同意」), recorded on #24137. #24137 remains open, and #24166 remains open: this PR declares the member only. ## What changed - `packages/spec/src/contracts/auth-service.ts`: `IAuthService.verifyMcpAccessToken`'s verified result, `{ userId: string; scopes: string[]; clientId?: string }`, gains `organizationId?: string`. Its TSDoc says what the member is: - the organization the user's OAuth consent bound (the consenting user's current organization at the moment of consent), read from the access token's `organization_id` claim; - absent for a token minted before consents bound an organization; absent means no organization, so only global grants apply; - a claim, not an authorization: the caller vets it on every request by the rule that vets a session's active organization (`vetOrganizationClaim`, `@objectstack/core`); once no current membership backs it, the principal resolves with no organization (global grants only: tenant reads come back empty, tenant writes are refused), never in another one; never the union of the user's organizations, never the browser session's currently selected organization; under ADR-0090 D10 it is the organization the delegator's grants are resolved in. - No tracker number in the new prose. - `packages/spec/src/contracts/auth-service.test.ts`: one case in the interface's existing test home. Two typed reads (`string | undefined`) of `organizationId`, off a bound result and an unbound one, are the pin; `check:test-typecheck` compiles the file (zero ledger debt for it). - `.changeset/24165-mcp-token-organization-id.md`: `@objectstack/spec` `minor`, `Clause-②: yes (widening)`. - `content/docs/kernel/contracts/auth-service.mdx` (`:41`-`:42`), patch round 1 at `890c170d5`: the interface block's `verifyMcpAccessToken` signature carries `organizationId?: string` as the contract spells the result. Its one-line comment says the optional `organizationId` is the organization the user's OAuth consent bound, absent for a token minted before consents bound an organization. The docs-drift check named five other pages (`kernel/contracts/index.mdx`, `kernel/index.mdx`, `kernel/services-checklist.mdx`, `kernel/services.mdx`, `permissions/authentication.mdx`). Each names `IAuthService` once and none restates the result shape, so they are unchanged. (Added by the dispatching seat from the dev's patch-round report `6236545086`.) - Patch round 2 at `292bf5f32`, after contract review `6236564856` FAILed on one point: the `client_credentials` / machine-to-machine absence clause is removed from all four carriers (the TSDoc, the pin's comment, the changeset's **Absent** bullet and the docs page's comment). The one implementer refuses such a token before any result exists (`auth-manager.ts`, `sub` equal to `client_id` or `azp` answers `null`), so the clause described a result the surface never returns. The clause came from the card body's gloss, through the dispatch. Contract review `6237222227` PASSes on this head. (Added by the dispatching seat.) Ruling items this slice carries, verbatim from `6232069920`: "The access token carries it as the claim **`organization_id`**." · "Every request re-checks membership through the existing session path. ⛔ Never the union of the user's organizations, never a widened global grant, never the browser session's current organization (B), never a D9-style pick (C2)." · "The token falls back to no organization, so only global grants apply: reads come back empty and tenant writes are refused loudly." · "The `packages/spec` contract type for the verified-token result. Spec seat, `Clause-②: yes (widening)`." ## Premise, measured before editing - On `origin/main` `cb8756b00`, `auth-service.ts:297` read `verifyMcpAccessToken?(token: string)` with the three-member result and no `organizationId` (`grep -n verifyMcpAccessToken packages/spec/src` gave that one line; the positive control `clientId?: string` hits the same line). - The one implementer, `packages/plugins/plugin-auth/src/auth-manager.ts` `verifyMcpAccessToken` (`:6786`), is not typed against the interface, and the runtime reader (`packages/runtime/src/security/resolve-execution-context.ts:159`) reaches it through `any`. The member is optional, so neither changes. ## Verification (all at `06210ccf8`) - `pnpm --filter @objectstack/spec build`: exit 0; the tree stayed clean (`git status --porcelain` empty), so no generated artifact moved. The rebuilt `dist/contracts/index.d.ts` and `index.d.mts` carry the member (1 hit each); `index.js` and `index.mjs` carry 0, because an interface is erased, so no emitted JavaScript byte changed. - `pnpm --filter @objectstack/spec check:generated`: "All 14 generated artifacts are up to date". The `api-surface` shard does not move: it records export names, and no export was added. - `pnpm --filter @objectstack/spec exec vitest run --project local --maxWorkers=2`: 692 files passed, 20975 tests passed, 1 todo. - `pnpm --filter @objectstack/spec typecheck`: exit 0 (build program, scripts program, test layer). - Downstream consumers of `@objectstack/spec` that hold `IAuthService` (dependency closures built with `turbo run build --filter='PKG^...' --concurrency=2`), each `typecheck` exit 0: `@objectstack/mcp`, `@objectstack/plugin-auth`, `@objectstack/runtime`, `@objectstack/cloud-connection`. - Reverse verification (one-off, not committed): a temporary `packages/mcp/src` file read `organizationId` as `string | undefined` and assigned `organizationId: 123` on an annotated result. `tsc --noEmit` gave exactly one error, `TS2322: Type 'number' is not assignable to type 'string'` on the second line. Against the old declaration the read would be TS2339 and the literal TS2353, so consumers read the rebuilt `.d.ts`. File deleted; tree clean. - Ablation (one-off, through `scripts/ablation-replace.mjs`, with an EXIT/INT/TERM restore): deleting the `organizationId?: string;` line made `check:test-typecheck` red with "src/contracts/auth-service.test.ts: 2 type error(s)". Restore proven: blob equals HEAD (`e2d1d8138861`) and `git diff HEAD` is empty. - Derived gate families: `node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack` gave 84 commands at `06210ccf8`. All 84 ran with exit codes recorded. Four first exited 3 (prerequisites: a pinned fixture commit outside the shallow clone, and unbuilt dists). After fetching that commit and building, all four re-ran exit 0. `--ran` reconciliation: "84 derived famil(ies) accounted for — 84 run, 0 NOT-MEASURED". ## Acceptance notes - `packages/runtime/src/security/resolve-execution-context.ts:154` says `verifyMcpAccessToken` is "implemented (auth-manager.ts) and undeclared". That was already false on `origin/main` (the contract declares the method) and stays false. It is runtime's file and the runtime slice's lane, so it is not edited here. Carrier: #24137's runtime slice, which edits that block. - The implementer answers `null` where the contract's type says `undefined`. The contract's prose ("a falsy value") covers both, and nothing types the implementer against the interface, so it is dormant. Noted, not filed. - `main` moved three commits after the base (`727091c82`). None touches this diff's files or any generated artifact this diff could move. CI runs on the merge ref. --- _Generated by [Claude Code](https://claude.ai/code/session_01TyRhWbVbbQpQJTDbW8VahJ)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent 8a88901 commit b1845ed

4 files changed

Lines changed: 61 additions & 3 deletions

File tree

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
---
2+
'@objectstack/spec': minor
3+
---
4+
5+
feat(spec/contracts): `IAuthService.verifyMcpAccessToken`'s verified result gains an optional `organizationId`, the organization the user's OAuth consent bound
6+
7+
Clause-②: yes (widening)
8+
9+
- **What changed.** The verified result of `verifyMcpAccessToken` (`@objectstack/spec/contracts`) is now `{ userId: string; scopes: string[]; clientId?: string; organizationId?: string }`. `organizationId` is the organization the user's OAuth consent bound (the user's current organization at the moment of consent), read from the access token's `organization_id` claim.
10+
- **Absent** when no consent bound one: a token minted before consents bound an organization. Absent means no organization, so only the user's global grants apply.
11+
- **A claim, not an authorization.** A caller vets it on every request by the rule that vets a session's active organization (`vetOrganizationClaim`, `@objectstack/core`). Once no current membership backs it, the principal resolves with no organization (global grants only: tenant reads come back empty and tenant writes are refused), never in another organization. The token acts in that one organization only: never the union of the user's organizations, and never the organization the user's browser session has selected now.
12+
- **Type only, and additive.** The member is optional, so an implementation that returns no `organizationId` still satisfies the contract, and nothing this package does at runtime changes. This package declares the member only: writing the claim at consent is the auth plugin's, and reading it into the OAuth session is the runtime's.

‎content/docs/kernel/contracts/auth-service.mdx‎

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -38,8 +38,8 @@ export interface IAuthService {
3838
/** Whether this deployment's auth gate is live. Optional. */
3939
isAuthGateActive?(): boolean;
4040

41-
/** Verify an OAuth 2.1 access token from the embedded AS (MCP surface). Optional. */
42-
verifyMcpAccessToken?(token: string): Promise<{ userId: string; scopes: string[]; clientId?: string } | undefined>;
41+
/** Verify an OAuth 2.1 access token from the embedded AS (MCP surface); the optional `organizationId` is the organization the user's OAuth consent bound, absent for a token minted before consents bound an organization. Optional. */
42+
verifyMcpAccessToken?(token: string): Promise<{ userId: string; scopes: string[]; clientId?: string; organizationId?: string } | undefined>;
4343

4444
/** The MCP resource identifier (RFC 8707 `resource` / token `aud`). Optional. */
4545
getMcpResourceUrl?(): string;

‎packages/spec/src/contracts/auth-service.test.ts‎

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -115,6 +115,27 @@ describe('Auth Service Contract', () => {
115115
expect(service.getMcpResourceUrl).toBeUndefined();
116116
expect(service.getMcpResourceMetadataUrl).toBeUndefined();
117117
});
118+
// The verified MCP access-token result names the organization the user's
119+
// OAuth consent bound, as an OPTIONAL member: a token minted before consents
120+
// bound one verifies with none. The typed
121+
// reads below are the pin; `check:test-typecheck` compiles this file.
122+
it('should carry the consent-bound organization on a verified MCP access token, optionally', async () => {
123+
const base: IAuthService = { handleRequest: async () => new Response('OK'), verify: async () => ({ success: true }) };
124+
const bound: IAuthService = {
125+
...base,
126+
verifyMcpAccessToken: async () => ({ userId: 'u1', scopes: ['data:read'], clientId: 'c1', organizationId: 'org_alpha' }),
127+
};
128+
const unbound: IAuthService = {
129+
...base,
130+
verifyMcpAccessToken: async () => ({ userId: 'u1', scopes: ['data:read'], clientId: 'c1' }),
131+
};
132+
133+
const boundOrganization: string | undefined = (await bound.verifyMcpAccessToken!('t'))?.organizationId;
134+
const unboundOrganization: string | undefined = (await unbound.verifyMcpAccessToken!('t'))?.organizationId;
135+
expect(boundOrganization).toBe('org_alpha');
136+
expect(unboundOrganization).toBeUndefined();
137+
});
138+
118139
// [#23772] The principal-session mint is OPTIONAL: an auth service that
119140
// cannot mint a session keeps satisfying the contract unchanged, and a
120141
// consumer reads its absence as "no mint here", never as a failed mint.

‎packages/spec/src/contracts/auth-service.ts‎

Lines changed: 26 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -294,7 +294,32 @@ export interface IAuthService {
294294
* [#4127 batch 4] Implemented by `AuthManager`; the execution-context
295295
* resolver has always called it through `any`.
296296
*/
297-
verifyMcpAccessToken?(token: string): Promise<{ userId: string; scopes: string[]; clientId?: string } | undefined>;
297+
verifyMcpAccessToken?(token: string): Promise<{
298+
userId: string;
299+
scopes: string[];
300+
clientId?: string;
301+
/**
302+
* The organization the user's OAuth consent bound (the consenting
303+
* user's current organization at the moment of consent), read from
304+
* the access token's `organization_id` claim.
305+
*
306+
* Absent when no consent bound one: a token minted before consents
307+
* bound an organization. Absent means the principal resolves with no
308+
* organization, so only its global grants apply.
309+
*
310+
* A claim, not an authorization. The caller vets it on every request
311+
* by the rule that vets a session's active organization
312+
* (`vetOrganizationClaim`, `@objectstack/core`): once no current
313+
* membership backs it, the principal resolves with no organization
314+
* (global grants only, so tenant reads come back empty and tenant
315+
* writes are refused), never in another one. The token acts in this
316+
* one organization: ⛔ never the union of the user's organizations,
317+
* and ⛔ never the organization the user's browser session has
318+
* selected now. Under ADR-0090 D10 it is the organization the
319+
* delegator's grants are resolved in.
320+
*/
321+
organizationId?: string;
322+
} | undefined>;
298323

299324
/**
300325
* [#23772] Mint a short-lived session for the requesting user's OWN

0 commit comments

Comments
 (0)