vantage-peers-mcp 2.4.14 → 2.5.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,44 @@
1
1
  # Changelog
2
2
 
3
+ ## [2.5.0] — 2026-06-06 — Day 92 VP MCP quality overhaul (mission k57a36y8)
4
+
5
+ Day 92 mission `k57a36y8w5t085bqr23dsmvb2d882506` ships a fleet-wide VP MCP quality bump
6
+ across audit, docs, hooks, security, and consistency dimensions. 15 PRs merged to main.
7
+
8
+ ### Phase A — Audit + new tools
9
+ - **A1** Day 92 VP MCP tools audit matrix (85 tools, 14 P0 zero-auth gaps) — `docs/test-reports/day92-vp-mcp-audit-matrix.md`.
10
+ - **A2** Consistency analysis report — `docs/test-reports/day92-vp-mcp-consistency-analysis.md`.
11
+ - **A3** New `whoami` LECTURE tool — first per-tool `outputSchema` export precedent.
12
+ - **A4** Consolidated gap matrix — `docs/test-reports/day92-vp-mcp-gap-matrix-consolidated.md`.
13
+
14
+ ### Phase B — Documentation
15
+ - **B1** `docs/cloud/security-multi-tenant.md` §4 scope-aware filter framework rewrite.
16
+ - **B2** `docs/cloud/tools-quality-standard.md` (NEW) — 12-section bilingual quality standard.
17
+ - **B3** `docs/cloud/onboarding-customer.md` (NEW) — customer onboarding guide (bilingual FR+EN).
18
+
19
+ ### Phase C — Consistency
20
+ - **C0** 14 P0 zero-auth write tools secured with `guardMasterOnly` (C0.1 → C0.6, 6 PRs).
21
+ - **C1** 87 Zod `outputSchema` exports per per-family envelope standard (B2 §3).
22
+ - **C2** Orchestrator-id NFC normalization + case-insensitive matching; idempotent prod migration `convex/migrations/c2-normalize-orchestrator-ids.ts` (7 tables).
23
+ - **C3** 97 tool descriptions standardized + 10 canonical aliases gated through `guardMasterOnly` (security regression fixed in iter 2) + alias-c0-gate-coverage test (15/15 PASS).
24
+ - **C4** Legacy `claude-peers` references removed repo-wide + `grep-gate` CI workflow.
25
+
26
+ ### Phase F — Hooks + plugin
27
+ - **F1** New consolidated `validate_task_payload` MCP tool + TypeScript validator library (replaces 5 single-axis hooks).
28
+ - **F2** Plugin propagation runbook + `plugin-vs-workspace-hooks.md` doctrine.
29
+
30
+ ### Scope-aware filtering
31
+ - `list_tasks` `fromAllowList[]` + case-insensitive matching (PR #654, #661).
32
+ - 3 admin endpoints reinstated for Marie cohort (prior session).
33
+
34
+ ### Tenant trio
35
+ - Persistent test tenant trio (alpha/beta/gamma) seeded on prod with bearers, scope_profiles, and seed data for cross-orchestrator E2E.
36
+
37
+ ### Deploy authorization
38
+ - `PI_AUTHORIZED_TASK_ID=k1751nfs27t9f9mpvg3ppd6xad884r59` (Day 82 doctrine).
39
+ - Mission: `k57a36y8w5t085bqr23dsmvb2d882506`.
40
+ - Branch: `release/v2.5.0` opened against `main` at HEAD `18a5530`.
41
+
3
42
  ## [2.4.13] — 2026-06-02 — Post-public republish: attribution + CHANGELOG day-numbers + RULE #7 narrative scrub
4
43
 
5
44
  Repository visibility flip to PUBLIC on 2026-06-02 (mission D62 `k57e4t21sr55rhz8ng554eseb987wvh3`). This patch republishes the npm package so the published README + CHANGELOG + attribution match the now-public source.
package/README.md CHANGED
@@ -3,11 +3,11 @@
3
3
  [![npm version](https://img.shields.io/npm/v/vantage-peers-mcp)](https://www.npmjs.com/package/vantage-peers-mcp)
4
4
  [![npm downloads](https://img.shields.io/npm/dm/vantage-peers-mcp)](https://www.npmjs.com/package/vantage-peers-mcp)
5
5
  [![License: FSL-1.1-Apache-2.0](https://img.shields.io/badge/license-FSL--1.1--Apache--2.0-blue)](https://github.com/vantageos-agency/vantage-peers/blob/main/LICENSE)
6
- [![Tests: 84/84](https://img.shields.io/badge/MCP_tools-84_registered-green)]()
6
+ [![Tests: 97/97](https://img.shields.io/badge/MCP_tools-97_registered-green)]()
7
7
 
8
8
  MCP server for [VantagePeers](https://vantagepeers.com) — shared memory, messaging, and task coordination for AI agent teams.
9
9
 
10
- 84 tools across 18 categories: memory, profiles, tasks, missions, mission templates, messages, diary, briefing notes, search (RAG), issues, fix patterns, error monitoring, deployments, business units, components, mandates, recurring tasks, and session. All tools ship with ChatGPT Apps SDK annotations (`readOnlyHint`, `openWorldHint`, `destructiveHint`) for native UX in ChatGPT custom connectors.
10
+ 97 tools across 18 categories: memory, profiles, tasks, missions, mission templates, messages, diary, briefing notes, search (RAG), issues, fix patterns, error monitoring, deployments, business units, components, mandates, recurring tasks, and session. All tools ship with ChatGPT Apps SDK annotations (`readOnlyHint`, `openWorldHint`, `destructiveHint`) for native UX in ChatGPT custom connectors.
11
11
 
12
12
  ## Quick start
13
13
 
@@ -17,6 +17,20 @@ npx vantage-peers-mcp
17
17
 
18
18
  Requires `CONVEX_URL` pointing to your VantagePeers Convex deployment.
19
19
 
20
+ ## What's new in v2.5.0
21
+
22
+ Day 92 VP MCP quality overhaul (mission `k57a36y8w5t085bqr23dsmvb2d882506`, PR #678):
23
+
24
+ - **C0 — 14 P0 zero-auth write tools secured** with master-only gates (`guardMasterOnly` / `checkFromAllowed`); all 14 tools identified in the A1 audit matrix (commit `d03d2d7`) now require an explicit scope gate before any mutation reaches Convex.
25
+ - **C1 — 87 Zod `outputSchema` exports** following the per-family envelope standard (`create_*` → `{id,...}`, `list_*` → `{items,cursor}`, `delete_*` → `{id,deleted:true}`, etc.) based on the `whoamiOutputSchema` precedent (commit `5231811`).
26
+ - **C2 — Unicode NFC normalization + case-insensitive orchestrator-ID matching** applied at all write paths and filter comparisons; closes the NFD/NFC silent mismatch class discovered in the Hélios/helios production regression.
27
+ - **C3 — 97 tool descriptions standardized** (1-line summary + WHEN clause + concrete EXAMPLE, 80–500 chars) + 10 canonical aliases aligned to the `verb_noun_snake` whitelist.
28
+ - **C4 — `claude-peers` legacy references removed** from source and docs + grep-gate CI check to prevent reintroduction.
29
+ - **A3 — `whoami` LECTURE tool** (PR #661, commit `5231811`) — returns `suggested_orchestrator_id`, `scope_profile`, and `namespace_read_prefixes` so skills auto-resolve identity without prompting the user.
30
+ - **F1 — `validate_task_payload` validator tool** (commit `cf6c961`) — client-side payload validation before any write reaches Convex.
31
+
32
+ See `mcp-server/CHANGELOG.md` for the full per-PR list.
33
+
20
34
  ## Install
21
35
 
22
36
  ### Option 1: npx (no install)
@@ -74,7 +88,7 @@ VantagePeers ships a built-in OAuth 2.1 authorization server so Claude.ai web ca
74
88
 
75
89
  The server also reads `CONVEX_URL` from `.env.local` in the parent directory if not set via environment.
76
90
 
77
- ## Tools (84)
91
+ ## Tools (97)
78
92
 
79
93
  ### Memory (6)
80
94
  `store_memory`, `recall`, `list_memories`, `soft_delete_memory`, `get_memory`, `store_episode`
@@ -37,4 +37,5 @@ export declare function parseBasicAuthSecret(authHeader: string | undefined, bod
37
37
  clientId: string | null;
38
38
  clientSecret: string | null;
39
39
  };
40
+ export declare function redirectUriMatches(registeredUri: string, presentedUri: string): boolean;
40
41
  export declare const app: Hono<import("hono/types").BlankEnv, import("hono/types").BlankSchema, "/">;
@@ -31,7 +31,7 @@ import { ConvexHttpClient } from "convex/browser";
31
31
  import { Hono } from "hono";
32
32
  import { cors } from "hono/cors";
33
33
  import { bearerAuthMiddleware, internalClient, masterOnlyMiddleware, sha256Base64Url, sha256Hex, } from "./src/auth.js";
34
- import { timingSafeEqual } from "./src/crypto.js";
34
+ import { timingSafeEqual } from "@vantageos/cloud-identity";
35
35
  import { registerTools } from "./src/tools.js";
36
36
  import { listUiResources, readUiResource } from "./src/ui-resources/index.js";
37
37
  let pkg;
@@ -120,6 +120,54 @@ async function loadScopeProfile(profileId) {
120
120
  "oauth:getScopeProfile", { profileId }));
121
121
  }
122
122
  // ─────────────────────────────────────────────────────────────────────────────
123
+ // D7 wildcard redirect_uri matcher
124
+ //
125
+ // RFC 6749 §3.1.2.3/§3.1.2.4 mandates that the authorization server validate
126
+ // the inbound `redirect_uri` against the URIs registered for the client and
127
+ // reject anything that does not match. The default match is byte-exact.
128
+ //
129
+ // Some MCP clients (notably ChatGPT's custom connector flow as of Day 92,
130
+ // 2026-06-04) issue per-session callbacks under a stable path prefix with a
131
+ // dynamic trailing segment, e.g. `https://chatgpt.com/connector/oauth/<id>`
132
+ // where `<id>` rotates per connector instance. A pure exact-match policy
133
+ // blocks every such flow after the first registration.
134
+ //
135
+ // To allow these flows without re-opening the open-redirect attack surface
136
+ // that the exact-match rule was designed to close, a registered URI may
137
+ // embed exactly one `*` token. When present, the URI is treated as a glob:
138
+ // - every other character is matched literally (regex-escaped),
139
+ // - the `*` is expanded to `[a-zA-Z0-9_-]+` — at least one char, no slash,
140
+ // no dot, no path separator, no host-bracketing punctuation,
141
+ // - the result is anchored with `^` and `$`.
142
+ //
143
+ // Lookalike attacks are still rejected because:
144
+ // - the host portion is literal, so `chatgpt.com.evil.io` does not match
145
+ // `https://chatgpt.com/connector/oauth/*`,
146
+ // - the path prefix is literal, so `/connector/oauth/../admin` does not
147
+ // match (`.` and `/` are not in the dynamic char class),
148
+ // - the dynamic segment requires at least one allowed character, so a
149
+ // trailing-slash variant (`.../oauth/`) does not match either.
150
+ //
151
+ // URIs without a `*` keep the original exact-match semantics — this helper
152
+ // is a strict superset of the prior behavior.
153
+ export function redirectUriMatches(registeredUri, presentedUri) {
154
+ if (!registeredUri.includes("*")) {
155
+ return registeredUri === presentedUri;
156
+ }
157
+ // Escape regex metacharacters EXCEPT `*`, then expand `*`.
158
+ const escaped = registeredUri.replace(/[.+?^${}()|[\]\\]/g, "\\$&");
159
+ const pattern = `^${escaped.replace(/\*/g, "[a-zA-Z0-9_-]+")}$`;
160
+ try {
161
+ return new RegExp(pattern).test(presentedUri);
162
+ }
163
+ catch {
164
+ return false;
165
+ }
166
+ }
167
+ function redirectUriMatchesAny(registeredUris, presentedUri) {
168
+ return registeredUris.some((u) => redirectUriMatches(u, presentedUri));
169
+ }
170
+ // ─────────────────────────────────────────────────────────────────────────────
123
171
  // App
124
172
  // ─────────────────────────────────────────────────────────────────────────────
125
173
  export const app = new Hono();
@@ -300,7 +348,8 @@ app.get("/authorize", async (c) => {
300
348
  // registered URI. Defense against open-redirect / token-exfiltration via
301
349
  // attacker-controlled redirect. No partial / prefix / wildcard match.
302
350
  const registeredUris = client.redirectUris ?? [];
303
- if (registeredUris.length === 0 || !registeredUris.includes(redirectUri)) {
351
+ if (registeredUris.length === 0 ||
352
+ !redirectUriMatchesAny(registeredUris, redirectUri)) {
304
353
  return c.json({
305
354
  error: "invalid_request",
306
355
  error_description: "redirect_uri does not match a registered redirect URI for this client",
@@ -370,7 +419,7 @@ app.post("/token", async (c) => {
370
419
  if (Date.now() > record.expiresAt) {
371
420
  return c.json({ error: "invalid_grant", error_description: "code expired" }, 400);
372
421
  }
373
- if (redirectUri && redirectUri !== record.redirectUri) {
422
+ if (redirectUri && !redirectUriMatches(record.redirectUri, redirectUri)) {
374
423
  return c.json({
375
424
  error: "invalid_grant",
376
425
  error_description: "redirect_uri mismatch",
@@ -409,8 +458,9 @@ app.post("/token", async (c) => {
409
458
  }, 401);
410
459
  }
411
460
  const presentedHash = await sha256Hex(clientSecret);
461
+ const _enc = new TextEncoder();
412
462
  if (!client.clientSecretHash ||
413
- !(await timingSafeEqual(presentedHash, client.clientSecretHash))) {
463
+ !(await timingSafeEqual(_enc.encode(presentedHash), _enc.encode(client.clientSecretHash)))) {
414
464
  return c.json({
415
465
  error: "invalid_client",
416
466
  error_description: "client_secret mismatch",
@@ -497,8 +547,9 @@ app.post("/token", async (c) => {
497
547
  }, 401);
498
548
  }
499
549
  const presentedHash = await sha256Hex(clientSecret);
550
+ const _enc = new TextEncoder();
500
551
  if (!refreshClient.clientSecretHash ||
501
- !(await timingSafeEqual(presentedHash, refreshClient.clientSecretHash))) {
552
+ !(await timingSafeEqual(_enc.encode(presentedHash), _enc.encode(refreshClient.clientSecretHash)))) {
502
553
  return c.json({
503
554
  error: "invalid_client",
504
555
  error_description: "client_secret mismatch",
@@ -657,6 +708,367 @@ admin.post("/oauth/seed-profiles", async (c) => {
657
708
  "oauth:seedDefaultProfiles", { callerToken: masterToken });
658
709
  return c.json({ created });
659
710
  });
711
+ // ─────────────────────────────────────────────────────────────────────────────
712
+ // S2.2 D5 — PATCH /admin/scope-profiles/:id
713
+ //
714
+ // HTTP wrapper around Convex mutation `oauth:patchScopeProfileEmergency`
715
+ // (S1.2-mutation + S2.1 cascade + audit log).
716
+ //
717
+ // Auth: BEARER_SECRET_MASTER via masterOnlyMiddleware (already mounted on
718
+ // the `admin` Hono sub-app). The mutation itself does a second constant-time
719
+ // master-token check via `requireMasterAuth` at the Convex layer.
720
+ //
721
+ // Body schema:
722
+ // {
723
+ // rename?: string,
724
+ // fromAllowList?: string[],
725
+ // namespaceReadPrefixes?: string[],
726
+ // namespaceWritePrefixes?: string[],
727
+ // cascadeRevokeTokens: boolean, // REQUIRED
728
+ // reason: string, // REQUIRED, Convex enforces ≥40
729
+ // }
730
+ //
731
+ // Response (200):
732
+ // { patchedProfileId, cascadeRevokedCount, clientsRetargeted, auditLogId }
733
+ //
734
+ // Error mapping (Convex throw → HTTP status):
735
+ // "profile not found" → 404
736
+ // "D4 violation" → 400
737
+ // "reason must be" → 400 (reason length guard)
738
+ // anything else → 500
739
+ // ─────────────────────────────────────────────────────────────────────────────
740
+ admin.patch("/scope-profiles/:id", async (c) => {
741
+ const masterToken = process.env.BEARER_SECRET_MASTER;
742
+ if (!masterToken)
743
+ return c.json({ error: "server_misconfigured" }, 500);
744
+ const profileId = c.req.param("id");
745
+ if (!profileId) {
746
+ return c.json({ error: "invalid_request", detail: "missing :id" }, 400);
747
+ }
748
+ let body = {};
749
+ try {
750
+ body = await c.req.json();
751
+ }
752
+ catch {
753
+ return c.json({ error: "invalid_request", detail: "body must be valid JSON" }, 400);
754
+ }
755
+ // Required: cascadeRevokeTokens (boolean), reason (string)
756
+ if (typeof body.cascadeRevokeTokens !== "boolean") {
757
+ return c.json({
758
+ error: "invalid_request",
759
+ detail: "cascadeRevokeTokens (boolean) is required",
760
+ }, 400);
761
+ }
762
+ if (typeof body.reason !== "string" || body.reason.length === 0) {
763
+ return c.json({ error: "invalid_request", detail: "reason (string) is required" }, 400);
764
+ }
765
+ // Optional fields — typed coercion / validation
766
+ const mutationArgs = {
767
+ callerToken: masterToken,
768
+ profileId,
769
+ cascadeRevokeTokens: body.cascadeRevokeTokens,
770
+ reason: body.reason,
771
+ };
772
+ if (body.rename !== undefined) {
773
+ if (typeof body.rename !== "string") {
774
+ return c.json({ error: "invalid_request", detail: "rename must be a string" }, 400);
775
+ }
776
+ mutationArgs.rename = body.rename;
777
+ }
778
+ for (const key of [
779
+ "fromAllowList",
780
+ "namespaceReadPrefixes",
781
+ "namespaceWritePrefixes",
782
+ ]) {
783
+ const v = body[key];
784
+ if (v !== undefined) {
785
+ if (!Array.isArray(v) || v.some((x) => typeof x !== "string")) {
786
+ return c.json({ error: "invalid_request", detail: `${key} must be string[]` }, 400);
787
+ }
788
+ mutationArgs[key] = v;
789
+ }
790
+ }
791
+ try {
792
+ const result = await internalClient().mutation(
793
+ // biome-ignore lint/suspicious/noExplicitAny: Convex string API
794
+ "oauth:patchScopeProfileEmergency", mutationArgs);
795
+ return c.json(result, 200);
796
+ }
797
+ catch (err) {
798
+ const message = err instanceof Error ? err.message : String(err);
799
+ // Map known Convex throws to HTTP status
800
+ if (/profile not found/i.test(message)) {
801
+ return c.json({ error: "not_found", detail: message }, 404);
802
+ }
803
+ if (/D4 violation/i.test(message)) {
804
+ return c.json({ error: "D4 violation", detail: message }, 400);
805
+ }
806
+ if (/reason must be at least/i.test(message)) {
807
+ return c.json({ error: "invalid_request", detail: message }, 400);
808
+ }
809
+ console.error("[admin] patchScopeProfileEmergency failed:", message);
810
+ return c.json({ error: "server_error", detail: message }, 500);
811
+ }
812
+ });
813
+ // ─────────────────────────────────────────────────────────────────────────────
814
+ // POST /admin/oauth/access-tokens — direct mint (bypass full OAuth flow)
815
+ //
816
+ // Wraps Convex mutation `oauth:createAccessToken` so operators with the
817
+ // master bearer can mint a scoped access token in a single call without
818
+ // running the DCR → /authorize → /token dance.
819
+ //
820
+ // Use case: provisioning isolated test workspaces for manual cross-tenant
821
+ // e2e verification, or onboarding a paying user when the dashboard does
822
+ // not yet exist (cloud-launch-v1 close-out window).
823
+ //
824
+ // Auth: BEARER_SECRET_MASTER via masterOnlyMiddleware (mounted on /admin).
825
+ //
826
+ // Body schema:
827
+ // {
828
+ // scopeProfile: string, // REQUIRED — must exist in oauth_scope_profiles
829
+ // userId: string, // REQUIRED — caller-supplied user identifier
830
+ // clientId?: string, // optional — defaults to "admin-mint:<random>"
831
+ // scopes?: string[], // optional — defaults to ["mcp:full"]
832
+ // fromAllowList?: string[], // optional — defaults to profile.fromAllowList
833
+ // namespaceReadPrefixes?: string[], // optional — defaults to profile.namespaceReadPrefixes
834
+ // namespaceWritePrefixes?: string[], // optional — defaults to profile.namespaceWritePrefixes
835
+ // expiresInSec?: number, // optional — defaults to 86400 (24h), max 30d
836
+ // }
837
+ //
838
+ // Response (201):
839
+ // {
840
+ // access_token: <raw token, 64 hex chars — returned ONCE, never again>,
841
+ // token_type: "Bearer",
842
+ // expires_at: <unix ms>,
843
+ // expires_in: <seconds>,
844
+ // clientId: <effective>,
845
+ // userId: <effective>,
846
+ // scopes: <effective array>,
847
+ // scopeProfile: <effective>,
848
+ // fromAllowList: <effective array>,
849
+ // namespaceReadPrefixes: <effective array>,
850
+ // namespaceWritePrefixes: <effective array>
851
+ // }
852
+ // ─────────────────────────────────────────────────────────────────────────────
853
+ admin.post("/oauth/access-tokens", async (c) => {
854
+ const masterToken = process.env.BEARER_SECRET_MASTER;
855
+ if (!masterToken)
856
+ return c.json({ error: "server_misconfigured" }, 500);
857
+ let body = {};
858
+ try {
859
+ body = await c.req.json();
860
+ }
861
+ catch {
862
+ return c.json({ error: "invalid_request", detail: "body must be valid JSON" }, 400);
863
+ }
864
+ const scopeProfileArg = typeof body.scopeProfile === "string" ? body.scopeProfile : null;
865
+ const userId = typeof body.userId === "string" ? body.userId : null;
866
+ if (!scopeProfileArg || !userId) {
867
+ return c.json({
868
+ error: "invalid_request",
869
+ detail: "scopeProfile and userId are required",
870
+ }, 400);
871
+ }
872
+ const loadedProfile = await loadScopeProfile(scopeProfileArg);
873
+ if (!loadedProfile) {
874
+ return c.json({ error: "invalid_scope_profile", scopeProfile: scopeProfileArg }, 400);
875
+ }
876
+ const profile = loadedProfile;
877
+ const clientId = typeof body.clientId === "string"
878
+ ? body.clientId
879
+ : `admin-mint:${crypto.randomUUID()}`;
880
+ const scopes = Array.isArray(body.scopes)
881
+ ? body.scopes.filter((x) => typeof x === "string")
882
+ : ["mcp:full"];
883
+ const arrayOrProfile = (key) => {
884
+ const v = body[key];
885
+ if (Array.isArray(v) && v.every((x) => typeof x === "string")) {
886
+ return v;
887
+ }
888
+ return profile[key];
889
+ };
890
+ const fromAllowList = arrayOrProfile("fromAllowList");
891
+ const namespaceReadPrefixes = arrayOrProfile("namespaceReadPrefixes");
892
+ const namespaceWritePrefixes = arrayOrProfile("namespaceWritePrefixes");
893
+ const expiresInSecRaw = typeof body.expiresInSec === "number" ? body.expiresInSec : 86400;
894
+ const MAX_EXPIRES_IN_SEC = 30 * 86400;
895
+ if (expiresInSecRaw <= 0 || expiresInSecRaw > MAX_EXPIRES_IN_SEC) {
896
+ return c.json({
897
+ error: "invalid_request",
898
+ detail: `expiresInSec must be in (0, ${MAX_EXPIRES_IN_SEC}]`,
899
+ }, 400);
900
+ }
901
+ const expiresAt = Date.now() + expiresInSecRaw * 1000;
902
+ const accessToken = randomOpaqueToken();
903
+ const tokenHash = await sha256Hex(accessToken);
904
+ try {
905
+ await internalClient().mutation(
906
+ // biome-ignore lint/suspicious/noExplicitAny: Convex string API
907
+ "oauth:createAccessToken", {
908
+ callerToken: masterToken,
909
+ tokenHash,
910
+ clientId,
911
+ userId,
912
+ scopes,
913
+ scopeProfile: scopeProfileArg,
914
+ fromAllowList,
915
+ namespaceReadPrefixes,
916
+ namespaceWritePrefixes,
917
+ expiresAt,
918
+ });
919
+ }
920
+ catch (err) {
921
+ const message = err instanceof Error ? err.message : String(err);
922
+ console.error("[admin] createAccessToken failed:", message);
923
+ return c.json({ error: "server_error", detail: message }, 500);
924
+ }
925
+ return c.json({
926
+ access_token: accessToken,
927
+ token_type: "Bearer",
928
+ expires_at: expiresAt,
929
+ expires_in: expiresInSecRaw,
930
+ clientId,
931
+ userId,
932
+ scopes,
933
+ scopeProfile: scopeProfileArg,
934
+ fromAllowList,
935
+ namespaceReadPrefixes,
936
+ namespaceWritePrefixes,
937
+ }, 201);
938
+ });
939
+ // ─────────────────────────────────────────────────────────────────────────────
940
+ // POST /admin/oauth/clients/:clientId/patch-scope — Day 92 LIVE
941
+ //
942
+ // Wraps Convex mutation `oauth:patchClientScopeAndRefreshTokens`. Re-targets
943
+ // the client to a new scope_profile and propagates the new
944
+ // `fromAllowList` + namespace prefixes into every live access token row
945
+ // for that clientId WITHOUT revoking refresh tokens — the bearer the
946
+ // operator already pasted into their MCP host keeps working, immediately
947
+ // gaining the new profile's allow list. Eliminates the customer
948
+ // re-paste step that profile rotation would otherwise force.
949
+ //
950
+ // Auth: BEARER_SECRET_MASTER via masterOnlyMiddleware.
951
+ //
952
+ // Body schema: { newScopeProfile: string, reason: string (≥20 chars) }
953
+ //
954
+ // Response (200): {
955
+ // clientPatched, previousScopeProfile, newScopeProfile,
956
+ // accessTokensRefreshed, auditLogId
957
+ // }
958
+ //
959
+ // Error mapping (Convex throw → HTTP status):
960
+ // "client not found" → 404
961
+ // "client is revoked" → 410 (Gone — re-mint required)
962
+ // "scope_profile not found" → 400
963
+ // "reason must be at least 20" → 400
964
+ // anything else → 500
965
+ // ─────────────────────────────────────────────────────────────────────────────
966
+ admin.post("/oauth/clients/:clientId/patch-scope", async (c) => {
967
+ const masterToken = process.env.BEARER_SECRET_MASTER;
968
+ if (!masterToken)
969
+ return c.json({ error: "server_misconfigured" }, 500);
970
+ const clientId = c.req.param("clientId");
971
+ if (!clientId) {
972
+ return c.json({ error: "invalid_request", detail: "missing :clientId" }, 400);
973
+ }
974
+ let body = {};
975
+ try {
976
+ body = await c.req.json();
977
+ }
978
+ catch {
979
+ return c.json({ error: "invalid_request", detail: "body must be valid JSON" }, 400);
980
+ }
981
+ const newScopeProfile = typeof body.newScopeProfile === "string" ? body.newScopeProfile : null;
982
+ const reason = typeof body.reason === "string" ? body.reason : null;
983
+ if (!newScopeProfile || !reason) {
984
+ return c.json({
985
+ error: "invalid_request",
986
+ detail: "newScopeProfile and reason are required",
987
+ }, 400);
988
+ }
989
+ try {
990
+ const result = await internalClient().mutation(
991
+ // biome-ignore lint/suspicious/noExplicitAny: Convex string API
992
+ "oauth:patchClientScopeAndRefreshTokens", {
993
+ callerToken: masterToken,
994
+ clientId,
995
+ newScopeProfile,
996
+ reason,
997
+ });
998
+ return c.json(result, 200);
999
+ }
1000
+ catch (err) {
1001
+ const message = err instanceof Error ? err.message : String(err);
1002
+ if (/client not found/i.test(message)) {
1003
+ return c.json({ error: "not_found", detail: message }, 404);
1004
+ }
1005
+ if (/client is revoked/i.test(message)) {
1006
+ return c.json({ error: "gone", detail: message }, 410);
1007
+ }
1008
+ if (/scope_profile not found/i.test(message)) {
1009
+ return c.json({ error: "invalid_scope_profile", detail: message }, 400);
1010
+ }
1011
+ if (/reason must be at least/i.test(message)) {
1012
+ return c.json({ error: "invalid_request", detail: message }, 400);
1013
+ }
1014
+ console.error("[admin] patchClientScopeAndRefreshTokens failed:", message);
1015
+ return c.json({ error: "server_error", detail: message }, 500);
1016
+ }
1017
+ });
1018
+ // ─────────────────────────────────────────────────────────────────────────────
1019
+ // POST /admin/oauth/clients/:clientId/revoke-access-tokens-only — Day 92 LIVE
1020
+ //
1021
+ // Wraps Convex mutation `oauth:revokeAccessTokensOnly`. Force-rotates every
1022
+ // live access token for the client by setting `revokedAt`, while leaving
1023
+ // refresh tokens untouched. The next API call from the connector hits 401
1024
+ // → connector silently runs the OAuth refresh-flow → fresh access token
1025
+ // minted from the current client scope_profile + catalog. Combined with
1026
+ // `patchClientScopeAndRefreshTokens` (which retargeted
1027
+ // refresh_tokens.scopeProfile in commit 40413bd) the next mint observes
1028
+ // the new profile end-to-end with zero customer re-paste.
1029
+ //
1030
+ // Auth: BEARER_SECRET_MASTER via masterOnlyMiddleware.
1031
+ //
1032
+ // Body schema: { reason: string (≥20 chars) }
1033
+ // Response (200): { clientId, accessTokensRevoked, refreshTokensPreserved }
1034
+ // ─────────────────────────────────────────────────────────────────────────────
1035
+ admin.post("/oauth/clients/:clientId/revoke-access-tokens-only", async (c) => {
1036
+ const masterToken = process.env.BEARER_SECRET_MASTER;
1037
+ if (!masterToken)
1038
+ return c.json({ error: "server_misconfigured" }, 500);
1039
+ const clientId = c.req.param("clientId");
1040
+ if (!clientId) {
1041
+ return c.json({ error: "invalid_request", detail: "missing :clientId" }, 400);
1042
+ }
1043
+ let body = {};
1044
+ try {
1045
+ body = await c.req.json();
1046
+ }
1047
+ catch {
1048
+ return c.json({ error: "invalid_request", detail: "body must be valid JSON" }, 400);
1049
+ }
1050
+ const reason = typeof body.reason === "string" ? body.reason : null;
1051
+ if (!reason) {
1052
+ return c.json({ error: "invalid_request", detail: "reason is required" }, 400);
1053
+ }
1054
+ try {
1055
+ const result = await internalClient().mutation(
1056
+ // biome-ignore lint/suspicious/noExplicitAny: Convex string API
1057
+ "oauth:revokeAccessTokensOnly", { callerToken: masterToken, clientId, reason });
1058
+ return c.json(result, 200);
1059
+ }
1060
+ catch (err) {
1061
+ const message = err instanceof Error ? err.message : String(err);
1062
+ if (/client not found/i.test(message)) {
1063
+ return c.json({ error: "not_found", detail: message }, 404);
1064
+ }
1065
+ if (/reason must be at least/i.test(message)) {
1066
+ return c.json({ error: "invalid_request", detail: message }, 400);
1067
+ }
1068
+ console.error("[admin] revokeAccessTokensOnly failed:", message);
1069
+ return c.json({ error: "server_error", detail: message }, 500);
1070
+ }
1071
+ });
660
1072
  app.route("/admin", admin);
661
1073
  // ─────────────────────────────────────────────────────────────────────────────
662
1074
  // MCP endpoint — authenticated, stateless per-request server
package/dist/server.js CHANGED
@@ -102,7 +102,7 @@ const convexUrl = loadConvexUrl();
102
102
  const convex = new ConvexHttpClient(convexUrl);
103
103
  const server = new McpServer({
104
104
  name: "vantage-peers",
105
- version: "2.0.0",
105
+ version: "2.5.0",
106
106
  });
107
107
  // ─────────────────────────────────────────────────────────────────────────────
108
108
  // Helper: structured error response for MCP tool handlers
@@ -493,7 +493,7 @@ server.tool("list_memories", "List active memories for a namespace, ordered newe
493
493
  // ─────────────────────────────────────────────────────────────────────────────
494
494
  server.tool("send_message", "Send a message to one, many, or all orchestrators. " +
495
495
  "channel: 'broadcast' = all, 'tau' = role DM, 'pi-vps' = instance DM, 'tau,phi' = multi. " +
496
- "Creates message + one receipt per recipient. Replaces claude-peers send_message.", {
496
+ "Creates message + one receipt per recipient. Supersedes legacy send_message (pre-VantagePeers).", {
497
497
  from: creatorSchema.describe("Sender role (e.g. pi, tau, phi, sigma, omega, zeta, eta, kappa, alpha, lambda, victor, or any custom role)"),
498
498
  fromInstanceId: z
499
499
  .string()
@@ -540,7 +540,7 @@ server.tool("send_message", "Send a message to one, many, or all orchestrators.
540
540
  // ─────────────────────────────────────────────────────────────────────────────
541
541
  server.tool("check_messages", "Check for unread messages. Returns messages with receiptIds for marking as read. " +
542
542
  "If recipientInstanceId is provided, returns instance-targeted + role-level messages. " +
543
- "Replaces claude-peers check_messages.", {
543
+ "Supersedes legacy check_messages (pre-VantagePeers).", {
544
544
  recipient: creatorSchema.describe("Orchestrator role (e.g. pi, tau, phi, sigma, omega, zeta, eta, kappa, alpha, lambda, victor, or any custom role)"),
545
545
  recipientInstanceId: z
546
546
  .string()
@@ -679,7 +679,7 @@ server.tool("set_summary", "Set a brief summary of what you are currently workin
679
679
  // Tool: list_peers
680
680
  // ─────────────────────────────────────────────────────────────────────────────
681
681
  server.tool("list_peers", "List all orchestrator profiles with their current status and summary. " +
682
- "Replaces claude-peers list_peers.", {}, async () => {
682
+ "Supersedes legacy list_peers (pre-VantagePeers).", {}, async () => {
683
683
  try {
684
684
  const profiles = await convex.query("profiles:listProfiles", {});
685
685
  const peers = profiles.map((p) => ({
package/dist/src/auth.js CHANGED
@@ -17,6 +17,7 @@
17
17
  * 401 is returned with a WWW-Authenticate header per RFC 6750 §3 so Claude.ai's
18
18
  * OAuth connector can bootstrap discovery.
19
19
  */
20
+ import { validateMasterBearer } from "@vantageos/cloud-identity";
20
21
  import { ConvexHttpClient } from "convex/browser";
21
22
  // ─────────────────────────────────────────────────────────────────────────────
22
23
  // Internal Convex client (reads mcpTenants + oauth_* tables)
@@ -329,16 +330,22 @@ export function bearerAuthMiddleware() {
329
330
  // ─────────────────────────────────────────────────────────────────────────────
330
331
  export function masterOnlyMiddleware() {
331
332
  return async (c, next) => {
332
- const authHeader = c.req.header("Authorization");
333
333
  const masterToken = process.env.BEARER_SECRET_MASTER;
334
334
  if (!masterToken) {
335
335
  return c.json({ error: "Server misconfigured: BEARER_SECRET_MASTER not set" }, 500);
336
336
  }
337
- if (!authHeader?.startsWith("Bearer ")) {
338
- return c.json({ error: "Missing Authorization header" }, 401);
339
- }
340
- const token = authHeader.slice("Bearer ".length).trim();
341
- if (token !== masterToken) {
337
+ // SECURITY UPGRADE (S2.3 D8): validateMasterBearer from
338
+ // @vantageos/cloud-identity sha256-hashes both the presented token and
339
+ // the configured master secret, then constant-time-compares the digests.
340
+ // Replaces the previous non-constant-time `token !== masterToken`
341
+ // string compare.
342
+ const authHeader = c.req.header("Authorization");
343
+ const result = await validateMasterBearer(authHeader, masterToken);
344
+ if (!result.ok) {
345
+ if (result.error === "missing" || result.error === "malformed") {
346
+ return c.json({ error: "Missing Authorization header" }, 401);
347
+ }
348
+ // "mismatch"
342
349
  return c.json({ error: "Forbidden: admin endpoints require master token" }, 403);
343
350
  }
344
351
  await next();