mcp-coordinator 0.6.1 → 0.7.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.
Files changed (66) hide show
  1. package/README.md +24 -0
  2. package/dist/src/agent-activity.d.ts +13 -9
  3. package/dist/src/agent-activity.js +45 -24
  4. package/dist/src/agent-registry.d.ts +7 -7
  5. package/dist/src/agent-registry.js +19 -18
  6. package/dist/src/announce-workflow.d.ts +1 -0
  7. package/dist/src/announce-workflow.js +13 -12
  8. package/dist/src/auth/providers/registry.d.ts +4 -0
  9. package/dist/src/auth/providers/registry.js +7 -0
  10. package/dist/src/auth/providers/types.d.ts +11 -0
  11. package/dist/src/auth/providers/types.js +1 -0
  12. package/dist/src/auth.d.ts +24 -5
  13. package/dist/src/auth.js +172 -23
  14. package/dist/src/conflict-detector.d.ts +1 -0
  15. package/dist/src/conflict-detector.js +4 -4
  16. package/dist/src/consultation.d.ts +28 -14
  17. package/dist/src/consultation.js +101 -68
  18. package/dist/src/context-provider.d.ts +2 -2
  19. package/dist/src/context-provider.js +3 -4
  20. package/dist/src/database.js +203 -4
  21. package/dist/src/dependency-map.d.ts +25 -4
  22. package/dist/src/dependency-map.js +49 -11
  23. package/dist/src/file-tracker.d.ts +5 -4
  24. package/dist/src/file-tracker.js +16 -14
  25. package/dist/src/git-cochange-builder.d.ts +11 -2
  26. package/dist/src/git-cochange-builder.js +15 -7
  27. package/dist/src/http/handle-health.d.ts +9 -5
  28. package/dist/src/http/handle-health.js +22 -8
  29. package/dist/src/http/handle-rest.d.ts +3 -0
  30. package/dist/src/http/handle-rest.js +56 -55
  31. package/dist/src/http/utils.d.ts +4 -0
  32. package/dist/src/http/utils.js +7 -1
  33. package/dist/src/impact-scorer.d.ts +3 -0
  34. package/dist/src/impact-scorer.js +65 -51
  35. package/dist/src/introspection.d.ts +13 -7
  36. package/dist/src/introspection.js +34 -11
  37. package/dist/src/metrics.js +2 -1
  38. package/dist/src/mqtt-bridge.d.ts +3 -2
  39. package/dist/src/mqtt-bridge.js +33 -23
  40. package/dist/src/mqtt-broker.d.ts +16 -7
  41. package/dist/src/mqtt-broker.js +57 -15
  42. package/dist/src/security/audit.d.ts +11 -0
  43. package/dist/src/security/audit.js +7 -0
  44. package/dist/src/security/encryption.d.ts +17 -0
  45. package/dist/src/security/encryption.js +5 -0
  46. package/dist/src/serve-http.js +136 -57
  47. package/dist/src/server-setup.d.ts +12 -2
  48. package/dist/src/server-setup.js +33 -15
  49. package/dist/src/sse-emitter.d.ts +7 -4
  50. package/dist/src/sse-emitter.js +27 -21
  51. package/dist/src/tools/agents-tools.d.ts +2 -1
  52. package/dist/src/tools/agents-tools.js +36 -12
  53. package/dist/src/tools/consultation-tools.d.ts +2 -1
  54. package/dist/src/tools/consultation-tools.js +102 -36
  55. package/dist/src/tools/dependencies-tools.d.ts +2 -1
  56. package/dist/src/tools/dependencies-tools.js +25 -7
  57. package/dist/src/tools/files-tools.d.ts +2 -1
  58. package/dist/src/tools/files-tools.js +25 -7
  59. package/dist/src/tools/mqtt-tools.d.ts +7 -1
  60. package/dist/src/tools/mqtt-tools.js +27 -4
  61. package/dist/src/tools/status-tools.d.ts +7 -1
  62. package/dist/src/tools/status-tools.js +26 -9
  63. package/dist/src/types.d.ts +2 -0
  64. package/dist/src/working-files-tracker.d.ts +21 -11
  65. package/dist/src/working-files-tracker.js +32 -21
  66. package/package.json +1 -1
@@ -1,7 +1,28 @@
1
1
  import type { ModuleInfo, DependencyMap, BlastRadius } from "./types.js";
2
2
  export declare class DependencyMapper {
3
- getMap(): DependencyMap;
4
- setMap(map: DependencyMap): void;
5
- getModuleInfo(moduleId: string): ModuleInfo | null;
6
- getBlastRadius(moduleId: string): BlastRadius;
3
+ /**
4
+ * Set a single module's dependencies, scoped by org_id.
5
+ * After Task 5.5, dependency_map PK is (org_id, module_id). Composite conflict target.
6
+ */
7
+ setDependencies(orgId: string, moduleId: string, params: {
8
+ depends_on: string[];
9
+ exports: string[];
10
+ owners: string[];
11
+ }): void;
12
+ /**
13
+ * Get a single module's dependencies, scoped by org_id.
14
+ */
15
+ getDependencies(orgId: string, moduleId: string): {
16
+ depends_on: string[];
17
+ exports: string[];
18
+ owners: string[];
19
+ } | null;
20
+ /**
21
+ * List all owners in an org's dependency map.
22
+ */
23
+ listOwners(orgId: string): string[];
24
+ getMap(orgId: string): DependencyMap;
25
+ setMap(orgId: string, map: DependencyMap): void;
26
+ getModuleInfo(orgId: string, moduleId: string): ModuleInfo | null;
27
+ getBlastRadius(orgId: string, moduleId: string): BlastRadius;
7
28
  }
@@ -1,9 +1,47 @@
1
1
  import { getDb } from "./database.js";
2
2
  import { withTransaction } from "./db-adapter.js";
3
3
  export class DependencyMapper {
4
- getMap() {
4
+ /**
5
+ * Set a single module's dependencies, scoped by org_id.
6
+ * After Task 5.5, dependency_map PK is (org_id, module_id). Composite conflict target.
7
+ */
8
+ setDependencies(orgId, moduleId, params) {
5
9
  const db = getDb();
6
- const rows = db.prepare("SELECT * FROM dependency_map").all();
10
+ db.prepare(`INSERT INTO dependency_map (org_id, module_id, depends_on, exports, owners) VALUES (?, ?, ?, ?, ?)
11
+ ON CONFLICT(org_id, module_id) DO UPDATE SET
12
+ depends_on = excluded.depends_on,
13
+ exports = excluded.exports,
14
+ owners = excluded.owners`).run(orgId, moduleId, JSON.stringify(params.depends_on), JSON.stringify(params.exports), JSON.stringify(params.owners));
15
+ }
16
+ /**
17
+ * Get a single module's dependencies, scoped by org_id.
18
+ */
19
+ getDependencies(orgId, moduleId) {
20
+ const db = getDb();
21
+ const row = db.prepare("SELECT depends_on, exports, owners FROM dependency_map WHERE org_id = ? AND module_id = ?").get(orgId, moduleId);
22
+ if (!row)
23
+ return null;
24
+ return {
25
+ depends_on: JSON.parse(row.depends_on || "[]"),
26
+ exports: JSON.parse(row.exports || "[]"),
27
+ owners: JSON.parse(row.owners || "[]"),
28
+ };
29
+ }
30
+ /**
31
+ * List all owners in an org's dependency map.
32
+ */
33
+ listOwners(orgId) {
34
+ const db = getDb();
35
+ const rows = db.prepare("SELECT owners FROM dependency_map WHERE org_id = ?").all(orgId);
36
+ const all = new Set();
37
+ for (const r of rows)
38
+ JSON.parse(r.owners || "[]").forEach((o) => all.add(o));
39
+ return Array.from(all);
40
+ }
41
+ // Bulk-shaped helpers (org-scoped)
42
+ getMap(orgId) {
43
+ const db = getDb();
44
+ const rows = db.prepare("SELECT * FROM dependency_map WHERE org_id = ?").all(orgId);
7
45
  const map = {};
8
46
  for (const row of rows) {
9
47
  map[row.module_id] = {
@@ -15,21 +53,21 @@ export class DependencyMapper {
15
53
  }
16
54
  return map;
17
55
  }
18
- setMap(map) {
56
+ setMap(orgId, map) {
19
57
  const db = getDb();
20
- const stmt = db.prepare(`INSERT INTO dependency_map (module_id, depends_on, exports, owners)
21
- VALUES (?, ?, ?, ?)
22
- ON CONFLICT(module_id) DO UPDATE SET
58
+ const stmt = db.prepare(`INSERT INTO dependency_map (org_id, module_id, depends_on, exports, owners)
59
+ VALUES (?, ?, ?, ?, ?)
60
+ ON CONFLICT(org_id, module_id) DO UPDATE SET
23
61
  depends_on = excluded.depends_on, exports = excluded.exports, owners = excluded.owners`);
24
62
  withTransaction(db, () => {
25
63
  for (const [id, info] of Object.entries(map)) {
26
- stmt.run(id, JSON.stringify(info.depends_on), JSON.stringify(info.exports), JSON.stringify(info.owners));
64
+ stmt.run(orgId, id, JSON.stringify(info.depends_on), JSON.stringify(info.exports), JSON.stringify(info.owners));
27
65
  }
28
66
  });
29
67
  }
30
- getModuleInfo(moduleId) {
68
+ getModuleInfo(orgId, moduleId) {
31
69
  const db = getDb();
32
- const row = db.prepare("SELECT * FROM dependency_map WHERE module_id = ?").get(moduleId);
70
+ const row = db.prepare("SELECT * FROM dependency_map WHERE org_id = ? AND module_id = ?").get(orgId, moduleId);
33
71
  if (!row)
34
72
  return null;
35
73
  return {
@@ -39,8 +77,8 @@ export class DependencyMapper {
39
77
  owners: JSON.parse(row.owners),
40
78
  };
41
79
  }
42
- getBlastRadius(moduleId) {
43
- const map = this.getMap();
80
+ getBlastRadius(orgId, moduleId) {
81
+ const map = this.getMap(orgId);
44
82
  const direct = [];
45
83
  const indirect = [];
46
84
  const visited = new Set();
@@ -1,6 +1,7 @@
1
1
  import type { FileActivity } from "./types.js";
2
2
  export declare class FileTracker {
3
3
  log(params: {
4
+ org_id: string;
4
5
  session_id: string;
5
6
  agent_id: string;
6
7
  agent_name?: string;
@@ -9,13 +10,13 @@ export declare class FileTracker {
9
10
  content_hash?: string | null;
10
11
  symbols_touched?: string[] | null;
11
12
  }): void;
12
- getBySession(sessionId: string): FileActivity[];
13
- getHotFiles(sinceMinutes?: number): {
13
+ getBySession(orgId: string, sessionId: string): FileActivity[];
14
+ getHotFiles(orgId: string, sinceMinutes?: number): {
14
15
  file_path: string;
15
16
  agent_count: number;
16
17
  agents: string[];
17
18
  }[];
18
- checkFileConflict(filePath: string, agentId: string, withinMinutes?: number): {
19
+ checkFileConflict(orgId: string, filePath: string, agentId: string, withinMinutes?: number): {
19
20
  conflict: boolean;
20
21
  agents: string[];
21
22
  };
@@ -28,6 +29,6 @@ export declare class FileTracker {
28
29
  * Excludes the calling agent (so the scorer doesn't flag the announcer
29
30
  * against themselves). Returns Map<file_path, Set<agent_id>>.
30
31
  */
31
- getFileToAgentsIndex(filePaths: string[], excludeAgentId: string, withinMinutes?: number): Map<string, Set<string>>;
32
+ getFileToAgentsIndex(orgId: string, filePaths: string[], excludeAgentId: string, withinMinutes?: number): Map<string, Set<string>>;
32
33
  fileToModule(filePath: string): string;
33
34
  }
@@ -4,32 +4,33 @@ export class FileTracker {
4
4
  const db = getDb();
5
5
  const module = this.fileToModule(params.file_path);
6
6
  db.prepare(`INSERT INTO file_activity
7
- (session_id, agent_id, agent_name, tool_name, file_path, module, content_hash, symbols_touched)
8
- VALUES (?, ?, ?, ?, ?, ?, ?, ?)`).run(params.session_id, params.agent_id, params.agent_name || null, params.tool_name, params.file_path, module, params.content_hash || null, params.symbols_touched ? JSON.stringify(params.symbols_touched) : null);
7
+ (org_id, session_id, agent_id, agent_name, tool_name, file_path, module, content_hash, symbols_touched)
8
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`).run(params.org_id, params.session_id, params.agent_id, params.agent_name || null, params.tool_name, params.file_path, module, params.content_hash || null, params.symbols_touched ? JSON.stringify(params.symbols_touched) : null);
9
9
  }
10
- getBySession(sessionId) {
10
+ getBySession(orgId, sessionId) {
11
11
  const db = getDb();
12
- return db.prepare("SELECT * FROM file_activity WHERE session_id = ? ORDER BY created_at").all(sessionId);
12
+ return db.prepare("SELECT * FROM file_activity WHERE org_id = ? AND session_id = ? ORDER BY created_at").all(orgId, sessionId);
13
13
  }
14
- getHotFiles(sinceMinutes = 30) {
14
+ getHotFiles(orgId, sinceMinutes = 30) {
15
15
  const db = getDb();
16
16
  const rows = db.prepare(`SELECT file_path, COUNT(DISTINCT agent_id) as agent_count, GROUP_CONCAT(DISTINCT agent_id) as agents
17
17
  FROM file_activity
18
- WHERE created_at > datetime('now', '-' || ? || ' minutes')
18
+ WHERE org_id = ?
19
+ AND created_at > datetime('now', '-' || ? || ' minutes')
19
20
  GROUP BY file_path
20
21
  HAVING COUNT(DISTINCT agent_id) > 1
21
- ORDER BY agent_count DESC`).all(sinceMinutes);
22
+ ORDER BY agent_count DESC`).all(orgId, sinceMinutes);
22
23
  return rows.map((r) => ({
23
24
  file_path: r.file_path,
24
25
  agent_count: r.agent_count,
25
26
  agents: r.agents.split(","),
26
27
  }));
27
28
  }
28
- checkFileConflict(filePath, agentId, withinMinutes = 30) {
29
+ checkFileConflict(orgId, filePath, agentId, withinMinutes = 30) {
29
30
  const db = getDb();
30
31
  const rows = db.prepare(`SELECT DISTINCT agent_id FROM file_activity
31
- WHERE file_path = ? AND agent_id != ?
32
- AND created_at > datetime('now', '-' || ? || ' minutes')`).all(filePath, agentId, withinMinutes);
32
+ WHERE org_id = ? AND file_path = ? AND agent_id != ?
33
+ AND created_at > datetime('now', '-' || ? || ' minutes')`).all(orgId, filePath, agentId, withinMinutes);
33
34
  return { conflict: rows.length > 0, agents: rows.map((r) => r.agent_id) };
34
35
  }
35
36
  /**
@@ -41,7 +42,7 @@ export class FileTracker {
41
42
  * Excludes the calling agent (so the scorer doesn't flag the announcer
42
43
  * against themselves). Returns Map<file_path, Set<agent_id>>.
43
44
  */
44
- getFileToAgentsIndex(filePaths, excludeAgentId, withinMinutes = 30) {
45
+ getFileToAgentsIndex(orgId, filePaths, excludeAgentId, withinMinutes = 30) {
45
46
  const index = new Map();
46
47
  if (filePaths.length === 0)
47
48
  return index;
@@ -51,9 +52,10 @@ export class FileTracker {
51
52
  // a handful of files per announce_work call).
52
53
  const placeholders = filePaths.map(() => "?").join(",");
53
54
  const rows = db.prepare(`SELECT DISTINCT file_path, agent_id FROM file_activity
54
- WHERE file_path IN (${placeholders})
55
- AND agent_id != ?
56
- AND created_at > datetime('now', '-' || ? || ' minutes')`).all(...filePaths, excludeAgentId, withinMinutes);
55
+ WHERE org_id = ?
56
+ AND file_path IN (${placeholders})
57
+ AND agent_id != ?
58
+ AND created_at > datetime('now', '-' || ? || ' minutes')`).all(orgId, ...filePaths, excludeAgentId, withinMinutes);
57
59
  for (const r of rows) {
58
60
  let set = index.get(r.file_path);
59
61
  if (!set) {
@@ -22,11 +22,20 @@ export declare class GitCochangeBuilder {
22
22
  private timer;
23
23
  constructor(opts: BuilderOpts);
24
24
  /** Build once. Resolves after persistence. */
25
- build(): Promise<void>;
25
+ build(orgId: string): Promise<void>;
26
+ /** Query co-change partners for a file, scoped to the given org. */
27
+ query(orgId: string, filePath: string): Array<{
28
+ org_id: string;
29
+ file_a: string;
30
+ file_b: string;
31
+ count: number;
32
+ total_commits: number;
33
+ computed_at: string;
34
+ }>;
26
35
  private runGitLog;
27
36
  private parseLog;
28
37
  /** Schedule a refresh loop. unref() so it doesn't keep the loop alive. */
29
- startScheduler(): void;
38
+ startScheduler(orgId: string): void;
30
39
  stopScheduler(): void;
31
40
  }
32
41
  export {};
@@ -30,9 +30,9 @@ export class GitCochangeBuilder {
30
30
  this.metrics = opts.metrics;
31
31
  }
32
32
  /** Build once. Resolves after persistence. */
33
- async build() {
33
+ async build(orgId) {
34
34
  const db = getDb();
35
- const setMeta = (k, v) => db.prepare("INSERT OR REPLACE INTO git_cochange_meta (k, v) VALUES (?, ?)").run(k, v);
35
+ const setMeta = (k, v) => db.prepare("INSERT OR REPLACE INTO git_cochange_meta (org_id, k, v) VALUES (?, ?, ?)").run(orgId, k, v);
36
36
  if (!existsSync(path.join(this.repoRoot, ".git"))) {
37
37
  this.log.info({}, "Layer 4 unavailable: no .git");
38
38
  setMeta("available", "false");
@@ -85,8 +85,9 @@ export class GitCochangeBuilder {
85
85
  if (promiscuous.size > 0) {
86
86
  this.log.info({ count: promiscuous.size, files: Array.from(promiscuous) }, "Layer 4 dynamic predictor cap excluded files");
87
87
  }
88
- db.exec("DELETE FROM git_cochange");
89
- const stmt = db.prepare("INSERT INTO git_cochange (file_a, file_b, count, total_commits, computed_at) VALUES (?, ?, ?, ?, datetime('now'))");
88
+ // Scope the DELETE to this org only — never wipe another org's rows.
89
+ db.prepare("DELETE FROM git_cochange WHERE org_id = ?").run(orgId);
90
+ const stmt = db.prepare("INSERT INTO git_cochange (org_id, file_a, file_b, count, total_commits, computed_at) VALUES (?, ?, ?, ?, ?, datetime('now'))");
90
91
  const insertMany = db.transaction(() => {
91
92
  for (const [key, count] of pairs.entries()) {
92
93
  const [a, b] = key.split("|");
@@ -96,7 +97,7 @@ export class GitCochangeBuilder {
96
97
  if (promiscuous.has(a) || promiscuous.has(b))
97
98
  continue;
98
99
  if (a < b)
99
- stmt.run(a, b, count, totalCommits);
100
+ stmt.run(orgId, a, b, count, totalCommits);
100
101
  }
101
102
  });
102
103
  insertMany();
@@ -105,6 +106,13 @@ export class GitCochangeBuilder {
105
106
  this.metrics?.gitCochangeBuilds.inc({ outcome: "success" });
106
107
  this.metrics?.gitCochangePairs.set(pairs.size);
107
108
  }
109
+ /** Query co-change partners for a file, scoped to the given org. */
110
+ query(orgId, filePath) {
111
+ const db = getDb();
112
+ return db.prepare(`SELECT org_id, file_a, file_b, count, total_commits, computed_at FROM git_cochange
113
+ WHERE org_id = ? AND (file_a = ? OR file_b = ?)
114
+ ORDER BY count DESC LIMIT 50`).all(orgId, filePath, filePath);
115
+ }
108
116
  runGitLog() {
109
117
  return new Promise((resolve, reject) => {
110
118
  const args = [
@@ -211,10 +219,10 @@ export class GitCochangeBuilder {
211
219
  return { pairs, totalCommits };
212
220
  }
213
221
  /** Schedule a refresh loop. unref() so it doesn't keep the loop alive. */
214
- startScheduler() {
222
+ startScheduler(orgId) {
215
223
  const tick = async () => {
216
224
  try {
217
- await this.build();
225
+ await this.build(orgId);
218
226
  this.timer = setTimeout(tick, this.refreshMs);
219
227
  }
220
228
  catch (err) {
@@ -14,10 +14,14 @@ export declare function handleLivez(_req: IncomingMessage, res: ServerResponse):
14
14
  * identical between 200 and 503 so consumers can parse uniformly.
15
15
  */
16
16
  export declare function handleReadyz(_req: IncomingMessage, res: ServerResponse, services: Pick<CoordinatorServices, "mqttBridge" | "treeSitter" | "gitCochange">): void;
17
+ export interface HealthOptions {
18
+ authEnabled?: boolean;
19
+ jwtSecretSet?: boolean;
20
+ }
17
21
  /**
18
- * Backwards-compatible alias. The original /health route returned a fixed
19
- * {status:"ok",version} payload with no dep checks; semantically that is a
20
- * liveness probe, so we delegate. Anything that polled /health for "is the
21
- * process up" continues to work without changes.
22
+ * Backwards-compatible alias extended with auth config status reporting.
23
+ * The original /health route returned a fixed {status:"ok",version} payload;
24
+ * we preserve that shape and ADD auth_enabled, jwt_secret_set, and warnings
25
+ * so operators can verify auth config without inspecting logs.
22
26
  */
23
- export declare function handleHealth(req: IncomingMessage, res: ServerResponse): void;
27
+ export declare function handleHealth(_req: IncomingMessage, res: ServerResponse, options?: HealthOptions): Promise<void>;
@@ -83,8 +83,8 @@ export function handleReadyz(_req, res, services) {
83
83
  // Optional: git_cochange availability (does NOT gate readiness — Layer 4 degrades gracefully)
84
84
  try {
85
85
  const row = getDb()
86
- .prepare("SELECT v FROM git_cochange_meta WHERE k = ?")
87
- .get("available");
86
+ .prepare("SELECT v FROM git_cochange_meta WHERE org_id = ? AND k = ?")
87
+ .get("default", "available");
88
88
  checks.git_cochange = {
89
89
  available: row?.v === "true",
90
90
  status: row?.v ?? "unavailable",
@@ -102,11 +102,25 @@ export function handleReadyz(_req, res, services) {
102
102
  }, allOk ? 200 : 503);
103
103
  }
104
104
  /**
105
- * Backwards-compatible alias. The original /health route returned a fixed
106
- * {status:"ok",version} payload with no dep checks; semantically that is a
107
- * liveness probe, so we delegate. Anything that polled /health for "is the
108
- * process up" continues to work without changes.
105
+ * Backwards-compatible alias extended with auth config status reporting.
106
+ * The original /health route returned a fixed {status:"ok",version} payload;
107
+ * we preserve that shape and ADD auth_enabled, jwt_secret_set, and warnings
108
+ * so operators can verify auth config without inspecting logs.
109
109
  */
110
- export function handleHealth(req, res) {
111
- return handleLivez(req, res);
110
+ export async function handleHealth(_req, res, options = {}) {
111
+ const authEnabled = options.authEnabled ?? false;
112
+ const jwtSecretSet = options.jwtSecretSet ?? false;
113
+ const warnings = [];
114
+ if (authEnabled && !jwtSecretSet) {
115
+ warnings.push("AUTH_ENABLED=true but COORDINATOR_JWT_SECRET is unset — sessions invalidate on restart");
116
+ }
117
+ const body = {
118
+ status: "alive",
119
+ uptime_seconds: uptimeSeconds(),
120
+ version: VERSION,
121
+ auth_enabled: authEnabled,
122
+ jwt_secret_set: jwtSecretSet,
123
+ warnings,
124
+ };
125
+ json(res, body);
112
126
  }
@@ -1,6 +1,7 @@
1
1
  import type { IncomingMessage, ServerResponse } from "http";
2
2
  import type { CoordinatorServices } from "../server-setup.js";
3
3
  import type { Logger } from "../logger.js";
4
+ import type { AuthClaims } from "../auth.js";
4
5
  /**
5
6
  * S1: REST router extracted from serve-http.ts. Was a 382-line `handleRest`
6
7
  * function inside startServer's closure with module-scope captures for
@@ -17,6 +18,8 @@ export interface RestContext {
17
18
  services: CoordinatorServices;
18
19
  httpLog: Logger;
19
20
  authEnabled: boolean;
21
+ /** Authenticated identity for this request. Synthetic legacy claims when AUTH_ENABLED=false and no Bearer. */
22
+ claims: AuthClaims;
20
23
  getRunConfig: () => Record<string, unknown> | null;
21
24
  setRunConfig: (cfg: Record<string, unknown> | null) => void;
22
25
  }