vantage-peers-mcp 2.18.0 → 2.19.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -1,5 +1,28 @@
1
1
  # Changelog
2
2
 
3
+ ## [Unreleased] — grant-aware mission/mandate visibility + bind JWT audience
4
+
5
+ ### Fixed
6
+
7
+ - **`check_messages` now renders `stuckInProgress` / `peersStuckOnYou` from the envelope (Day-156 reader-first; Eta REVISE PR #1218 dual-shape).** Missing / null / undefined keys default via `asCappedStuckList` so old Convex prod cannot throw `.length`. Accepts a raw `{taskId,title,age}[]` (already-deployed MCP) **or** `{entries,total,truncated}`. Empty unread + a stuck SIGNAL (`entries.length > 0` OR `truncated === true`) surfaces the block, never `"No new messages."` / Vide — a truncated empty list is a signal, not rest. `messages` / `staleInProgress` are unchanged; `pendingOnYou` is not revived.
8
+
9
+ ### Added
10
+
11
+ - **`fail_task` tool — the third task terminal state (`failed`), distinct from `done`/`cancelled` (mission `k576mw0smxeqsg9wp7957njfsn8crey4`, commit `8c70e18`).** New tool, mandatory `failureNote`, output schema `{taskId, status: "failed"}`. `update_task`'s `updateTaskStatusSchema` now excludes both `"blocked"` and `"failed"` (client refused at the tool-input layer, mirroring the server-side `FAILED_VIA_UPDATE_REFUSED` gate). `taskStatusValues`/`taskStatusFilterSchema` gained `"failed"`. `createTaskOutputSchema.status` now reuses the canonical `taskStatusValues` array (was hand-typed and missing `"cancelled"`/`"failed"`). A blocker task reaching `failed` does NOT auto-release its waiters — see `convex`-side CHANGELOG for the reciprocal-unblock fix.
12
+ - **`block_task` gained an optional `blockedCause` arg** (`"peer_task" | "human" | "authorisation" | "other"`) naming WHAT the task is waiting on. Backward-compatible: optional, defaults server-side to `"other"`. `block_task`'s output JSON now always carries `blockedCause`.
13
+
14
+ ### Security
15
+
16
+ - **Consult per-row grants on missions, mandates, and tasks (task `k174y9ra7pp8zed3bcczk6xaed8cpynp`, `@vantageos/cloud-identity` 0.5.0).** The shared scope filter was structurally blind to every per-row grant: a scoped identity named on a mission (`pilot`/`agents`), a mandate (`requestedBy`/`fulfilledBy`), or a task (`assignedTo`) could not read the row it was named on — the multi-org separation failing at exactly what it is sold for. Upgraded the dependency `^0.3.0` → `^0.5.0` (the old caret stopped at the 0.3 minor and never admitted 0.4.0) and passed `grantFields` at the read sites: `get_mission` → `["pilot","agents"]`; `list_mandates`/`get_mandate` → `["requestedBy","fulfilledBy"]`; `get_task`/`list_tasks_by_mission`/`search_tasks_by_keyword` → `["assignedTo"]` (mirrors `convex/tasks.ts` L88-89's `createdBy===caller || assignedTo===caller` OR), replacing the two-sided `createdBy`-remap workaround (the local-variant pattern the shared filter now subsumes). Fail-closed unchanged: a table declaring no grant fields behaves byte-identically to before. Eta REVISE on the first pass of PR #1204 (the original test suite called the package predicate directly instead of the real handlers — litmus test false); rewritten to drive `registerTools()` end-to-end via the same duck-typed McpServer/Convex harness `test/scope-aware-filter-wave-c1.test.ts` uses, RED→GREEN reproduced against the real `get_task`/`search_tasks_by_keyword` call sites. `briefingNotes.participants` stays deferred (task `k175ga65p654z200ydj7s8qv5s8cnxfc`); `recurringTasks.assignedTo`, `missionTemplates.steps[].assignedTo`, `businessUnits.coreTeam.agents`, and `diaries.orchestrator` remain unwired (out of this PR's scope — see PR #1204 `CLASS:` block). TDD strict — GREEN 13/13 under scoped non-creator identity `alice`; full mcp-server suite 1087 passed / 12 skipped, build clean + boot-check 4/4.
17
+
18
+
19
+
20
+ - **Bind the `audience` claim where the Clerk session JWT is verified (MCP server standard Critical Rule 14, element 5).** `tryVerifyClerkJwt` (`src/auth.ts`) verified the token with `issuer` bound but not `audience`, so a token minted for one audience/client was accepted on another — cross-tenant / cross-resource replay (the single exploitable finding of the mcp-doctor conformance audit, `projects/vantage-peers/audits/mcp-server-conformance-audit.md`). Fix binds `audience: CLERK_JWT_AUDIENCE` (env, default `"convex"`) at the single `jwtVerify` site — the value mirrors `convex/auth.config.ts` `applicationID: "convex"` and the `CLERK_JWT_TEMPLATE` default, and the verified token is forwarded verbatim to Convex which already requires `aud === "convex"`, so no correctly-minted production token is rejected. Minimal by design: only the one verification site; the auth-layer/DCR/PRM flow and the three other (non-exploitable) audit findings are untouched. TDD strict — RED both poles (wrong-audience refused / correct-audience accepted / no-`aud` refused) reproduced firsthand, GREEN 3/3; full suite 1074 passed / 0 failed, `tsc --noEmit` exit 0.
21
+
22
+ ### Fixed
23
+
24
+ - **Regenerate `mcp-server/bun.lock` so the grant-aware read actually reaches the customer-facing server (task `k170n5az8xt95gg0b6thrzgej98crnw3`).** PR #1204 (`f51bd709`) bumped `mcp-server/package.json` `@vantageos/cloud-identity` `^0.3.0` → `^0.5.0` and the root `bun.lock`, but never regenerated `mcp-server/bun.lock` — which stayed pinned at `@vantageos/cloud-identity@0.3.0`. The Railway build runs `bun install --frozen-lockfile`, which refuses a lockfile that would have to change (`error: lockfile had changes, but lockfile is frozen`), so the deploy failed **silently**: the previous container kept serving and nothing disagreed. Result: the grant-aware filter was ACTIVE on Convex but INACTIVE on the MCP server — the path a customer actually uses. Repair regenerates the lockfile **in an isolated tree** (never in the workspace, which would resolve against the workspace and reproduce the locally-passing / Railway-failing file), touching `mcp-server/bun.lock` only; the manifest is untouched (dependency scope did not move). The drift named: `@vantageos/cloud-identity` `0.3.0` → `0.5.0`. Proven pair, both poles firsthand: RED = stale lockfile + `^0.5.0` manifest under `--frozen-lockfile` → exit 1 (the exact Railway message); GREEN = repaired lockfile under `--frozen-lockfile` → exit 0. Activation (separate from merge) is a successful Railway deploy read back from the platform, then a scoped-identity probe of the grant filter — never the master credential, which bypasses the very check.
25
+
3
26
  ## [2.18.0] — 2026-08-11
4
27
 
5
28
  ### Changed
package/README.md CHANGED
@@ -227,17 +227,20 @@ The full registered list ships in `mcp-server/src/tools.ts` and is enumerated be
227
227
  - `update_profile` — mutate an orchestrator profile (master-gated)
228
228
  - `list_peers` — page through registered peers
229
229
 
230
- ### Tasks (14)
230
+ ### Tasks (17)
231
231
  - `create_task` — create a new task with VERIFICATION + TESTS blocks
232
232
  - `list_tasks` — page through tasks with filters + `excludeAutoGenerated`
233
233
  - `list_tasks_by_mission` — page through tasks for a single mission
234
234
  - `get_task` — fetch a single task by id
235
235
  - `update_task` — patch task fields (incl. cancel: `status="cancelled"` + `cancelReason`, creator-only)
236
- - `start_task` — transition to `in_progress`
236
+ - `start_task` — transition to `in_progress`; resumes rather than restarts when the task already carries worked time, and refuses when a segment is already open, naming the verb to call instead
237
+ - `pause_task` — close the open work segment and stop the clock without ending the task; paused is not blocked
238
+ - `resume_task` — open a new work segment and put the task back in `in_progress`
237
239
  - `complete_task` — close with evidence-bound `completionNote`
238
240
  - `checkout_task` — claim a task without starting
239
241
  - `delete_task` — destructive delete (master-gated, blocked in prod; to retire an erroneous task use `update_task status="cancelled"` + `cancelReason`, not `complete_task`)
240
- - `block_task` — mark blocked with reason
242
+ - `block_task` — mark blocked with reason and optional `blockedCause` (`peer_task`|`human`|`authorisation`|`other`) naming WHAT is being waited on
243
+ - `fail_task` — close a task as **failed**, a terminal state distinct from `done`/`cancelled`, with a mandatory `failureNote`
241
244
  - `add_task_dependency` — add a predecessor
242
245
  - `bulk_complete_tasks` — dry-run-default bulk close (cron-spam cleanup)
243
246
  - `validate_task_payload` — client-side payload validation
@@ -278,6 +281,44 @@ Example — Pi queue cleaned of cron-spam:
278
281
 
279
282
  Returns `{ items: Task[], nextCursor: string | null }`. `nextCursor` is `null` on the last page.
280
283
 
284
+ #### `block_task` — `blockedCause` discriminator (T1, PR #1208)
285
+
286
+ ```
287
+ block_task(taskId, reason?, blockedOnTaskId?, blockedCause?, callerOrchestrator?)
288
+ ```
289
+
290
+ `blockedCause` is optional (defaults server-side to `"other"`) and states WHAT the task is waiting on:
291
+
292
+ | Value | Meaning |
293
+ |-------|---------|
294
+ | `human` | Waiting on a human answer/decision — an operator has to reply before work resumes. |
295
+ | `authorisation` | Waiting on a merge/publish/approval gate (e.g. Eta review, Pi merge sign-off). |
296
+ | `peer_task` | A plain upstream dependency on another live task — requires a cited `blockedOnTaskId` (refused otherwise: `BLOCKED_CAUSE_PEER_TASK_REQUIRES_LINK`). |
297
+ | `other` | None of the above, or the default when omitted. |
298
+
299
+ `blockedCause` is orthogonal input data, not a caller-written presentation state — the reader-facing "waiting-on" label is always DERIVED from `{status, blockedCause}`, never written directly. Pre-existing blocked rows (created before this field existed) read back as `"other"`.
300
+
301
+ #### `fail_task` — the third terminal state (T1, PR #1208)
302
+
303
+ ```
304
+ fail_task(taskId, failureNote, callerOrchestrator?)
305
+ ```
306
+
307
+ Marks a task **failed** — a terminal status distinct from `done` (succeeded) and `cancelled` (retired before/without attempting the work). Use it when the work was genuinely attempted and did not succeed.
308
+
309
+ - `failureNote` is **mandatory** and non-empty — describes how the work ended in failure.
310
+ - This is the **only** door to the failed state: `update_task status="failed"` is refused server-side (`FAILED_VIA_UPDATE_REFUSED`), the same way `update_task status="blocked"` is refused. There is no field for a closer to default past — only a distinct named tool to call.
311
+ - A task already `done`/`cancelled`/`failed` cannot be re-terminated as failed (`CANNOT_FAIL_CLOSED_TASK`).
312
+ - **Waiters are NOT auto-released when a blocker fails.** Any task blocked on the failed one stays `blocked`, with `blockedOnTaskId` intact — the fleet does not silently treat a failed prerequisite as though it held. The waiter's owner instead receives a `BLOCKER_FAILED:` notification naming the failure; the block must be re-routed by a human/orchestrator decision, never auto-cleared. (Contrast: when a blocker reaches `done`, its waiters ARE swept to `todo` automatically — only success auto-releases.)
313
+
314
+ Example:
315
+ ```json
316
+ {
317
+ "tool": "fail_task",
318
+ "arguments": { "taskId": "k178d3ns...", "failureNote": "Migration errored on row 4102, rolled back cleanly", "callerOrchestrator": "beta" }
319
+ }
320
+ ```
321
+
281
322
  #### `bulk_complete_tasks` — args schema + dry-run-default safety (PR-F)
282
323
 
283
324
  ```
@@ -35,7 +35,7 @@ import { WebStandardStreamableHTTPServerTransport } from "@modelcontextprotocol/
35
35
  import { timingSafeEqual } from "@vantageos/cloud-identity";
36
36
  import { Hono } from "hono";
37
37
  import { cors } from "hono/cors";
38
- import { bearerAuthMiddleware, internalClient, masterOnlyMiddleware, sha256Base64Url, sha256Hex, } from "./src/auth.js";
38
+ import { bearerAuthMiddleware, internalClient, isMasterScope, masterOnlyMiddleware, sha256Base64Url, sha256Hex, } from "./src/auth.js";
39
39
  import { selectConvexClientForRequest } from "./src/authenticatedConvexClient.js";
40
40
  import { registerTools } from "./src/tools.js";
41
41
  import { listUiResources, readUiResource } from "./src/ui-resources/index.js";
@@ -228,8 +228,8 @@ app.get("/.well-known/oauth-authorization-server", (c) => {
228
228
  });
229
229
  });
230
230
  const registerRateBuckets = new Map();
231
- const REGISTER_RATE_LIMIT = 5;
232
- const REGISTER_RATE_WINDOW_MS = 60_000;
231
+ const REGISTER_RATE_LIMIT = 5; // maxPerWindow: 5
232
+ const REGISTER_RATE_WINDOW_MS = 60_000; // windowMs: 60_000
233
233
  function checkRegisterRateLimit(ip) {
234
234
  const now = Date.now();
235
235
  const bucket = registerRateBuckets.get(ip);
@@ -555,6 +555,7 @@ app.post("/token", async (c) => {
555
555
  namespaceWritePrefixes: profile.namespaceWritePrefixes,
556
556
  expiresAt: now + ACCESS_TOKEN_TTL_SECONDS * 1000,
557
557
  refreshTokenHash,
558
+ clerkOrgSlug: profile.clerkOrgSlug,
558
559
  });
559
560
  await internalClient().mutation(
560
561
  // biome-ignore lint/suspicious/noExplicitAny: Convex string API
@@ -641,6 +642,7 @@ app.post("/token", async (c) => {
641
642
  namespaceWritePrefixes: profile.namespaceWritePrefixes,
642
643
  expiresAt: now + ACCESS_TOKEN_TTL_SECONDS * 1000,
643
644
  refreshTokenHash,
645
+ clerkOrgSlug: profile.clerkOrgSlug,
644
646
  });
645
647
  return c.json({
646
648
  access_token: accessToken,
@@ -743,6 +745,61 @@ admin.post("/oauth/clients", async (c) => {
743
745
  redirect_uris: redirectUris,
744
746
  }, 201);
745
747
  });
748
+ // POST /admin/organizations — one-shot org + orchestrator seats
749
+ admin.post("/organizations", async (c) => {
750
+ const masterToken = process.env.BEARER_SECRET_MASTER;
751
+ if (!masterToken) {
752
+ return c.json({ error: "server_misconfigured" }, 500);
753
+ }
754
+ let body = {};
755
+ try {
756
+ body = await c.req.json();
757
+ }
758
+ catch {
759
+ return c.json({ error: "invalid_request" }, 400);
760
+ }
761
+ const clerkOrgSlug = typeof body.clerkOrgSlug === "string" ? body.clerkOrgSlug : null;
762
+ const displayName = typeof body.displayName === "string" ? body.displayName : null;
763
+ const orchestratorsRaw = Array.isArray(body.orchestrators)
764
+ ? body.orchestrators
765
+ : null;
766
+ if (!clerkOrgSlug || !displayName || !orchestratorsRaw) {
767
+ return c.json({
768
+ error: "invalid_request",
769
+ error_description: "clerkOrgSlug, displayName, and orchestrators are required",
770
+ }, 400);
771
+ }
772
+ const orchestrators = orchestratorsRaw
773
+ .map((row) => {
774
+ if (row && typeof row === "object" && "name" in row) {
775
+ const name = row.name;
776
+ return typeof name === "string" ? { name } : null;
777
+ }
778
+ return null;
779
+ })
780
+ .filter((row) => row !== null);
781
+ const scopes = Array.isArray(body.scopes)
782
+ ? body.scopes.filter((s) => typeof s === "string")
783
+ : undefined;
784
+ try {
785
+ const result = await internalClient().mutation(
786
+ // biome-ignore lint/suspicious/noExplicitAny: Convex string API
787
+ "oauth:provisionOrganization", {
788
+ callerToken: masterToken,
789
+ clerkOrgSlug,
790
+ displayName,
791
+ orchestrators,
792
+ scopes,
793
+ });
794
+ return c.json(result, result.replay ? 200 : 201);
795
+ }
796
+ catch (err) {
797
+ const message = err instanceof Error ? err.message : String(err);
798
+ console.error("[admin] provisionOrganization failed:", message);
799
+ const status = message.includes("Unauthorized") ? 401 : 400;
800
+ return c.json({ error: "provision_failed", detail: message }, status);
801
+ }
802
+ });
746
803
  // GET /admin/oauth/clients — list (no secrets)
747
804
  admin.get("/oauth/clients", async (c) => {
748
805
  const masterToken = process.env.BEARER_SECRET_MASTER;
@@ -981,6 +1038,7 @@ admin.post("/oauth/access-tokens", async (c) => {
981
1038
  namespaceReadPrefixes,
982
1039
  namespaceWritePrefixes,
983
1040
  expiresAt,
1041
+ clerkOrgSlug: profile.clerkOrgSlug,
984
1042
  });
985
1043
  }
986
1044
  catch (err) {
@@ -1172,7 +1230,17 @@ app.all("/mcp", bearerAuthMiddleware(), async (c) => {
1172
1230
  // biome-ignore lint/suspicious/noExplicitAny: Convex string API
1173
1231
  return convex.query(functionName, args);
1174
1232
  };
1175
- return await readUiResource(uri.toString(), fetchConvex);
1233
+ // Day-165-parity — thread the caller identity into the ui-resource
1234
+ // primitives the same way tools.ts does (mirrors the
1235
+ // master/callerIdentities computation at tools.ts:5712/5808/5934), so
1236
+ // briefingNotes:get/list resolve visibility inside Convex instead of
1237
+ // falling into the `callerIdentities === undefined` legacy-open branch.
1238
+ const master = oauthCtx === undefined || isMasterScope(oauthCtx);
1239
+ const callerIdentities = master ? undefined : oauthCtx.fromAllowList;
1240
+ return await readUiResource(uri.toString(), fetchConvex, {
1241
+ master,
1242
+ callerIdentities,
1243
+ });
1176
1244
  });
1177
1245
  const transport = new WebStandardStreamableHTTPServerTransport();
1178
1246
  await server.connect(transport);
package/dist/server.d.ts CHANGED
@@ -2,8 +2,9 @@
2
2
  /**
3
3
  * VantagePeers MCP Server — stdio transport (Self-host / local Claude Code path).
4
4
  *
5
- * Thin bootstrap: resolves CONVEX_URL, instantiates McpServer + ConvexHttpClient,
6
- * delegates ALL tool registration to the shared `registerTools(server, convex)`
5
+ * Thin bootstrap: resolves CONVEX_URL, instantiates McpServer + a
6
+ * service-account-identified Convex client, delegates ALL tool registration
7
+ * to the shared `registerTools(server, convex)`
7
8
  * surface in src/tools.ts. This guarantees stdio and HTTP transports expose the
8
9
  * same tool set (parity locked by src/__tests__/stdio-http-parity.test.ts).
9
10
  *
package/dist/server.js CHANGED
@@ -2,8 +2,9 @@
2
2
  /**
3
3
  * VantagePeers MCP Server — stdio transport (Self-host / local Claude Code path).
4
4
  *
5
- * Thin bootstrap: resolves CONVEX_URL, instantiates McpServer + ConvexHttpClient,
6
- * delegates ALL tool registration to the shared `registerTools(server, convex)`
5
+ * Thin bootstrap: resolves CONVEX_URL, instantiates McpServer + a
6
+ * service-account-identified Convex client, delegates ALL tool registration
7
+ * to the shared `registerTools(server, convex)`
7
8
  * surface in src/tools.ts. This guarantees stdio and HTTP transports expose the
8
9
  * same tool set (parity locked by src/__tests__/stdio-http-parity.test.ts).
9
10
  *
@@ -19,10 +20,23 @@
19
20
  */
20
21
  import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
21
22
  import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
22
- import { ConvexHttpClient } from "convex/browser";
23
- import { readFileSync } from "fs";
24
- import { resolve } from "path";
23
+ import { readFileSync } from "node:fs";
24
+ import { resolve } from "node:path";
25
+ import { createServiceAccountConvexClient } from "./src/authenticatedConvexClient.js";
26
+ import { LOCAL_STDIO_TRUST_CTX } from "./src/auth.js";
25
27
  import { registerTools } from "./src/tools.js";
28
+ // Advertised version is derived from the manifest, same pattern as
29
+ // server-http.ts, so both transports report identical values in source and
30
+ // dist mode.
31
+ let pkg;
32
+ try {
33
+ // Source mode: server.ts → ./package.json = mcp-server/package.json
34
+ pkg = JSON.parse(readFileSync(new URL("./package.json", import.meta.url), "utf-8"));
35
+ }
36
+ catch {
37
+ // Dist mode: dist/server.js → ../package.json = mcp-server/package.json
38
+ pkg = JSON.parse(readFileSync(new URL("../package.json", import.meta.url), "utf-8"));
39
+ }
26
40
  // ─────────────────────────────────────────────────────────────────────────────
27
41
  // Bootstrap: resolve CONVEX_URL from env or .env.local
28
42
  // ─────────────────────────────────────────────────────────────────────────────
@@ -54,15 +68,20 @@ function loadConvexUrl() {
54
68
  // Server setup
55
69
  // ─────────────────────────────────────────────────────────────────────────────
56
70
  const convexUrl = loadConvexUrl();
57
- const convex = new ConvexHttpClient(convexUrl);
71
+ // Grants the stdio path master, via the service-account carve-out —
72
+ // the local-trust posture claimed below, now backed by an actual identity.
73
+ const convex = createServiceAccountConvexClient(convexUrl);
58
74
  const server = new McpServer({
59
75
  name: "vantage-peers",
60
- version: "2.18.0",
76
+ version: pkg.version,
61
77
  });
62
- // stdio transport has no OAuth identity → pass oauthCtx=undefined to opt into
63
- // the legacy bearer / system-scope code path inside registerTools (same path
64
- // server-http.ts uses for unauthenticated requests).
65
- registerTools(server, convex);
78
+ // stdio runs on the operator's own machine against their own CONVEX_URL, so it
79
+ // is trusted with full LOCAL authority. That authority is now PRESENTED as an
80
+ // explicit, named context (LOCAL_STDIO_TRUST_CTX) rather than inferred from an
81
+ // absent oauthCtx — the scope guards in registerTools refuse on undefined, so
82
+ // the missing-argument path is no longer a max-authority backdoor
83
+ // (.claude/rules/one-identity-layer.md clause 3).
84
+ registerTools(server, convex, LOCAL_STDIO_TRUST_CTX);
66
85
  // ─────────────────────────────────────────────────────────────────────────────
67
86
  // Start server on stdio transport
68
87
  // ─────────────────────────────────────────────────────────────────────────────
@@ -50,6 +50,20 @@ export type OAuthContext = {
50
50
  * there for why that is fail-closed-safe.
51
51
  */
52
52
  clerkJwt?: string;
53
+ /**
54
+ * SHA-256 hex of the OAuth access token presented on this request —
55
+ * set ONLY on auth path (2) after oauth:getAccessTokenByHash hits.
56
+ * The roster query derives the organisation from the token ROW keyed
57
+ * by this hash. It is a credential locator, not an organisation id.
58
+ */
59
+ accessTokenHash?: string;
60
+ /**
61
+ * Organisation slug snapshotted onto the access-token row at mint.
62
+ * Presence selects the token-derived roster path; absence on a
63
+ * non-Clerk caller is the #1215 refuse. Never passed as a Convex
64
+ * query argument.
65
+ */
66
+ clerkOrgSlug?: string;
53
67
  };
54
68
  declare module "hono" {
55
69
  interface ContextVariableMap {
@@ -57,6 +71,22 @@ declare module "hono" {
57
71
  oauthContext: OAuthContext;
58
72
  }
59
73
  }
74
+ /**
75
+ * Local-machine-trust context for the stdio transport (server.ts).
76
+ *
77
+ * The stdio server runs on the OPERATOR'S OWN machine (Self-host / local Claude
78
+ * Code via `npx vantage-peers-mcp`) against a CONVEX_URL the operator already
79
+ * holds. There is no network-facing bearer to scope on this transport, so the
80
+ * caller is trusted with full local authority.
81
+ *
82
+ * The point of naming it: this authority is now PRESENT and greppable, not
83
+ * inferred from an ABSENT oauthContext. server.ts passes it EXPLICITLY (see
84
+ * `.claude/rules/one-identity-layer.md` clause 3 — "a right is presented, never
85
+ * inferred from an absence"). Every guard below now REFUSES on undefined; the
86
+ * stdio full-access path keeps working only because this named context is
87
+ * handed in, never because a missing argument is read as master.
88
+ */
89
+ export declare const LOCAL_STDIO_TRUST_CTX: OAuthContext;
60
90
  export declare function internalClient(): ConvexHttpClient;
61
91
  export declare function _setInternalClientForTest(client: ConvexHttpClient | null): void;
62
92
  export declare function sha256Hex(input: string): Promise<string>;
@@ -71,26 +101,66 @@ export declare function sha256Base64Url(input: string): Promise<string>;
71
101
  *
72
102
  * Delegates the actual master/wildcard decision to
73
103
  * `@vantageos/cloud-identity`'s `isMasterScope` (0.3.0+) via `toPackageOAuthCtx`
74
- * above — this repo no longer reimplements that check locally. `undefined` is
75
- * handled here (returns false) because several call sites in tools.ts pass an
76
- * optional `OAuthContext` (legacy bearer path has no oauthContext at all);
77
- * the package's own guard requires a non-null `OAuthCtx` and throws otherwise
78
- * (0.3.0's "never grant by absence" contract), so the undefined check must
79
- * happen before delegating, not be silently absorbed by the package call.
104
+ * above — this repo no longer reimplements that check locally. `undefined`
105
+ * returns FALSE here (absence is never master): callers pass an optional
106
+ * `OAuthContext`, and the package's own guard requires a non-null `OAuthCtx`
107
+ * and throws otherwise (0.3.0's "never grant by absence" contract), so the
108
+ * undefined check must happen before delegating, not be silently absorbed by
109
+ * the package call. Note the direction: undefined → NOT master (this fn) AND
110
+ * undefined → REFUSE (the check* predicates above); never undefined → grant.
80
111
  */
81
112
  export declare function isMasterScope(ctx: OAuthContext | undefined): boolean;
82
113
  /**
83
114
  * Checks that `from` is allowed by the current OAuth context.
84
115
  * Returns null when allowed, an error message string otherwise.
85
116
  *
86
- * `ctx` is only undefined in tests that call these predicates directly
87
- * without going through bearerAuthMiddleware — every real auth path
88
- * (master, OAuth, Clerk, DCR, legacy mcpTenants bearer) sets an oauthContext.
89
- * The legacy mcpTenants bearer path resolves to a deny-by-default
90
- * "legacy-tenant-generic" scope (empty allowlist/prefixes) — see auth.ts
91
- * path (4).
117
+ * A missing `ctx` REFUSES (returns the refusal string), it never passes.
118
+ * Every real auth path sets an oauthContext — the HTTP transport via
119
+ * bearerAuthMiddleware (master, OAuth, Clerk, DCR, and the legacy mcpTenants
120
+ * bearer path, which resolves to a deny-by-default "legacy-tenant-generic"
121
+ * scope), and the stdio transport via the explicit LOCAL_STDIO_TRUST_CTX
122
+ * server.ts hands to registerTools. So a `!ctx` here is a misconfiguration,
123
+ * and it fails closed rather than granting max authority (clause 3,
124
+ * `.claude/rules/one-identity-layer.md`).
92
125
  */
93
126
  export declare function checkFromAllowed(ctx: OAuthContext | undefined, from: string): string | null;
127
+ /**
128
+ * Answers the DELEGATION question — is `assignedTo` a member of the CALLER'S
129
+ * own organisation? — as distinct from `checkFromAllowed`, which answers a
130
+ * different question (may this client SPEAK AS `from`). Conflating the two
131
+ * meant every non-master client could only delegate to the one identity in
132
+ * its own `fromAllowList`, making cross-station dispatch impossible for any
133
+ * non-master client (Day-... delegation-same-org-predicate incident).
134
+ *
135
+ * Rules:
136
+ * - master scope (isMasterScope) → allowed (null), no roster read needed.
137
+ * - non-master AND `ctx.clerkJwt` present (the Clerk-team org-scoped path,
138
+ * auth.ts case 2.5, ~line 507) → `getOrgRoster` is called and resolves
139
+ * via Convex `withOrgScope`/`client_org_mapping`, forwarding THIS
140
+ * caller's own verified Clerk JWT (`selectConvexClientForRequest`,
141
+ * authenticatedConvexClient.ts). This is a genuine per-caller org lookup.
142
+ * Allowed iff `assignedTo` is in that roster, OR the roster itself is
143
+ * `["*"]` — a CALLER org whose `client_org_mapping.allowedOrchestrators`
144
+ * is itself the wildcard (a genuinely open org, same semantics as
145
+ * `isMaster = allowedOrchestrators.includes("*")` elsewhere in Convex,
146
+ * convex/lib/auth.ts).
147
+ * - non-master AND `ctx.clerkJwt` ABSENT AND the access token carries
148
+ * `accessTokenHash` + `clerkOrgSlug` → `getOrgRoster` MUST resolve via
149
+ * `orgRoster:getForAccessToken({ tokenHash })` (org derived inside
150
+ * Convex from that token row). Never `getMyOrgRoster` (service-account
151
+ * `["*"]` is ETA-M15).
152
+ * - non-master AND `ctx.clerkJwt` ABSENT AND no token org claim
153
+ * (DCR client-generic, legacy mcpTenants, unattached OAuth profiles)
154
+ * → REFUSE LOUDLY, `getOrgRoster` is NEVER called. ETA-M15: these paths
155
+ * route Convex through the MCP service-account; treating its `["*"]`
156
+ * as the caller org is the leak.
157
+ *
158
+ * Async because it performs a data read (clerkJwt path or token-hash path).
159
+ * `ctx` undefined REFUSES (returns the refusal string), matching the other
160
+ * check* predicates' fail-closed no-context convention — an absent identity
161
+ * is never allowed to delegate.
162
+ */
163
+ export declare function checkDelegationAllowed(ctx: OAuthContext | undefined, assignedTo: string, getOrgRoster: () => Promise<string[]>): Promise<string | null>;
94
164
  /**
95
165
  * Checks namespace against prefix list. A prefix of "*" means any namespace.
96
166
  * Otherwise the target namespace must start with one of the prefixes.