@opengeni/core 2.9.4-canary.3 → 2.10.0-canary.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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@opengeni/core",
3
- "version": "2.9.4-canary.3",
3
+ "version": "2.10.0-canary.0",
4
4
  "description": "OpenGeni framework-agnostic core: the domain, access, and billing layers (neutral access, off-HTTP V2 surface). Behavior-preserving extraction from apps/api — keeps Hono's HTTPException for error throwing (typed-errors cleanup deferred).",
5
5
  "license": "Apache-2.0",
6
6
  "repository": {
@@ -58,17 +58,17 @@
58
58
  },
59
59
  "dependencies": {
60
60
  "@modelcontextprotocol/sdk": "^1.29.0",
61
- "@opengeni/capabilities": "^0.3.3-canary.11",
62
- "@opengeni/codex": "^0.2.22-canary.11",
63
- "@opengeni/config": "^1.1.2-canary.3",
64
- "@opengeni/contracts": "^3.0.2-canary.3",
65
- "@opengeni/db": "^4.3.3-canary.3",
66
- "@opengeni/documents": "^0.8.27-canary.3",
67
- "@opengeni/events": "^0.4.25-canary.3",
68
- "@opengeni/network": "^0.3.1-canary.11",
69
- "@opengeni/observability": "^0.8.26-canary.3",
70
- "@opengeni/runtime": "^2.5.3-canary.3",
71
- "@opengeni/storage": "^0.2.127-canary.3",
61
+ "@opengeni/capabilities": "^0.3.3-canary.12",
62
+ "@opengeni/codex": "^0.2.22-canary.12",
63
+ "@opengeni/config": "^1.2.0-canary.0",
64
+ "@opengeni/contracts": "^3.1.0-canary.0",
65
+ "@opengeni/db": "^4.4.0-canary.0",
66
+ "@opengeni/documents": "^0.8.28-canary.0",
67
+ "@opengeni/events": "^0.4.26-canary.0",
68
+ "@opengeni/network": "^0.3.1-canary.12",
69
+ "@opengeni/observability": "^0.8.27-canary.0",
70
+ "@opengeni/runtime": "^2.6.0-canary.0",
71
+ "@opengeni/storage": "^0.2.128-canary.0",
72
72
  "hono": "^4.12.18",
73
73
  "zod": "^4.2.1"
74
74
  },
@@ -0,0 +1,174 @@
1
+ import { createHmac } from "node:crypto";
2
+ import { RotateSessionMcpCredentialsRequest, type AccessGrant } from "@opengeni/contracts";
3
+ import {
4
+ encryptVariableSetValue,
5
+ getWorkspaceGrant,
6
+ subjectHasLiveWorkspaceAuthorityInScope,
7
+ type Database,
8
+ } from "@opengeni/db";
9
+ import {
10
+ rotateSessionMcpCredentialsAtomically,
11
+ SessionMcpCredentialRotationError,
12
+ } from "@opengeni/db/session-mcp-credential-rotation";
13
+ import { HTTPException } from "hono/http-exception";
14
+ import type { Settings } from "@opengeni/config";
15
+ import {
16
+ requirePermission,
17
+ requireResolvedAccessGrantAuthorization,
18
+ type AccessGrantAuthorization,
19
+ } from "../access";
20
+ import {
21
+ grantHasAgentAttemptAuthority,
22
+ requireSessionAuthorization,
23
+ SessionAuthorizationDeniedError,
24
+ SessionAuthorizationUnavailableError,
25
+ type SessionAuthorizationDependencies,
26
+ } from "../session-authorization";
27
+ import { requireVariableSetEncryption } from "../domain/environments";
28
+ import { normalizedSessionMcpCredentialHeaders } from "../domain/sessions";
29
+ import { externalContinuationCommitAuthorizer } from "./external-continuation";
30
+
31
+ /** HMAC domains separate request identity from key availability. The key tag
32
+ * carries no request material and neither fingerprint is returned publicly. */
33
+ export function sessionMcpRotationFingerprint(
34
+ key: Uint8Array,
35
+ scope: { accountId: string; workspaceId: string; sessionId: string; subjectId: string },
36
+ request: RotateSessionMcpCredentialsRequest,
37
+ ) {
38
+ return {
39
+ digestKeyTag: createHmac("sha256", key)
40
+ .update("opengeni:session-mcp-rotation:key:v1")
41
+ .digest("hex"),
42
+ requestDigest: createHmac("sha256", key)
43
+ .update("opengeni:session-mcp-rotation:request:v1\0")
44
+ .update(JSON.stringify({ ...scope, request }))
45
+ .digest("hex"),
46
+ };
47
+ }
48
+
49
+ function requireRotationGrant(grant: AccessGrant) {
50
+ if (grantHasAgentAttemptAuthority(grant))
51
+ throw new HTTPException(403, { message: "host authorization required" });
52
+ requirePermission(grant, "sessions:control");
53
+ requirePermission(grant, "mcp_servers:attach");
54
+ }
55
+
56
+ /** HTTP/SDK host boundary only. The mandatory callback re-resolves request
57
+ * authentication with the transaction handle, not a cached route-time grant. */
58
+ export async function rotateSessionMcpCredentialsForRequest(
59
+ deps: SessionAuthorizationDependencies & { settings: Settings },
60
+ authorization: AccessGrantAuthorization,
61
+ sessionId: string,
62
+ raw: unknown,
63
+ reauthorize: (tx: Database) => Promise<AccessGrant>,
64
+ ) {
65
+ const grant = requireResolvedAccessGrantAuthorization(
66
+ authorization,
67
+ authorization.grant.workspaceId,
68
+ );
69
+ requireRotationGrant(grant);
70
+ const parsed = RotateSessionMcpCredentialsRequest.safeParse(raw);
71
+ if (!parsed.success)
72
+ throw new HTTPException(422, { message: "invalid credential rotation request" });
73
+ const encryptionKey = requireVariableSetEncryption(deps.settings);
74
+ let request: RotateSessionMcpCredentialsRequest;
75
+ try {
76
+ request = {
77
+ operationKey: parsed.data.operationKey,
78
+ updates: parsed.data.updates
79
+ .map((update) => ({
80
+ id: update.id,
81
+ expectedCredentialVersion: update.expectedCredentialVersion,
82
+ expectedServerUrl: update.expectedServerUrl,
83
+ headers: Object.fromEntries(
84
+ Object.entries(normalizedSessionMcpCredentialHeaders(update.headers))
85
+ .map(([name, value]) => [name.toLowerCase(), value] as const)
86
+ .sort(([left], [right]) => (left < right ? -1 : left > right ? 1 : 0)),
87
+ ),
88
+ }))
89
+ .sort((left, right) => (left.id < right.id ? -1 : left.id > right.id ? 1 : 0)),
90
+ };
91
+ if (new Set(request.updates.map((update) => update.id)).size !== request.updates.length)
92
+ throw new Error();
93
+ } catch {
94
+ // Header names and validation paths are caller-controlled secret channels.
95
+ throw new HTTPException(422, { message: "invalid credential rotation headers or server IDs" });
96
+ }
97
+ const scope = {
98
+ accountId: grant.accountId,
99
+ workspaceId: grant.workspaceId,
100
+ sessionId,
101
+ subjectId: grant.subjectId,
102
+ actorType: grant.principalKind === "service" ? ("service" as const) : ("human" as const),
103
+ };
104
+ const externalAuthorize = externalContinuationCommitAuthorizer(authorization);
105
+ try {
106
+ return await rotateSessionMcpCredentialsAtomically(deps.db, {
107
+ ...scope,
108
+ operationKey: request.operationKey,
109
+ ...sessionMcpRotationFingerprint(encryptionKey, scope, request),
110
+ updates: request.updates.map(({ headers, ...update }) => ({
111
+ ...update,
112
+ headersEncrypted: Object.fromEntries(
113
+ Object.entries(headers).map(([name, value]) => [
114
+ name,
115
+ encryptVariableSetValue(encryptionKey, value),
116
+ ]),
117
+ ),
118
+ })),
119
+ authorize: async (tx) => {
120
+ await externalAuthorize?.(tx);
121
+ const fresh = await reauthorize(tx);
122
+ if (
123
+ fresh.accountId !== grant.accountId ||
124
+ fresh.workspaceId !== grant.workspaceId ||
125
+ fresh.subjectId !== grant.subjectId ||
126
+ fresh.principalKind !== grant.principalKind
127
+ )
128
+ throw new HTTPException(403, { message: "credential rotation authority changed" });
129
+ requireRotationGrant(fresh);
130
+ if (fresh.principalKind === "human_session" && !externalAuthorize) {
131
+ // Signed host grants may narrow a named human's permissions, never
132
+ // outlive removal or expand that human's current workspace grant.
133
+ const live = await getWorkspaceGrant(tx, fresh.subjectId, fresh.workspaceId);
134
+ if (live) {
135
+ requireRotationGrant(live);
136
+ } else if (!authorization.canonicalManagedHumanSession) {
137
+ throw new HTTPException(403, { message: "credential rotation authority changed" });
138
+ }
139
+ if (!(await subjectHasLiveWorkspaceAuthorityInScope(tx, scope))) {
140
+ throw new HTTPException(403, { message: "credential rotation authority changed" });
141
+ }
142
+ }
143
+ await requireSessionAuthorization({ ...deps, db: tx }, fresh, {
144
+ sessionId,
145
+ operation: "session.mcp.credentials.rotate",
146
+ surface: "http",
147
+ });
148
+ },
149
+ });
150
+ } catch (error) {
151
+ if (error instanceof HTTPException)
152
+ throw new HTTPException(error.status, { message: "credential rotation access denied" });
153
+ if (error instanceof SessionAuthorizationDeniedError)
154
+ throw new HTTPException(403, { message: "credential rotation access denied" });
155
+ if (error instanceof SessionAuthorizationUnavailableError)
156
+ throw new HTTPException(503, { message: "credential rotation authorization unavailable" });
157
+ if (error instanceof SessionMcpCredentialRotationError) {
158
+ const status =
159
+ error.code === "receipt_key_unavailable"
160
+ ? 503
161
+ : error.code === "authority_revoked"
162
+ ? 403
163
+ : error.code === "not_found"
164
+ ? 404
165
+ : error.code === "invalid_request" || error.code === "brokered_server"
166
+ ? 422
167
+ : 409;
168
+ throw new HTTPException(status, { message: `credential_rotation_${error.code}` });
169
+ }
170
+ // Drizzle failures can include query parameters. Never attach their cause,
171
+ // ciphertext, fingerprints, header names, or raw input to API telemetry.
172
+ throw new HTTPException(503, { message: "credential rotation unavailable" });
173
+ }
174
+ }
@@ -434,7 +434,7 @@ export function creationInitiatorForGrant(grant: AccessGrant): FrozenCreationIni
434
434
  };
435
435
  }
436
436
 
437
- function normalizedSessionMcpCredentialHeaders(
437
+ export function normalizedSessionMcpCredentialHeaders(
438
438
  headers: Record<string, string> | undefined,
439
439
  ): Record<string, string> {
440
440
  if (!headers) {
package/src/index.ts CHANGED
@@ -64,6 +64,7 @@ export * from "./access";
64
64
  export * from "./application/external-workspace-members";
65
65
  export * from "./application/external-identity-lifecycle";
66
66
  export * from "./application/external-continuation";
67
+ export * from "./application/session-mcp-credential-rotation";
67
68
  export * from "./application/external-link-work-admission";
68
69
  export * from "./application/connect-authority";
69
70
  export * from "./application/host-mcp-owner";