@opengeni/api-router 0.26.1 → 0.30.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 (87) hide show
  1. package/dist/app.d.ts +1 -0
  2. package/dist/app.js +5 -3
  3. package/dist/auth/canonical-human-session-admission.d.ts +27 -0
  4. package/dist/browser-auth-broker.d.ts +20 -0
  5. package/dist/browser-controller-authority.d.ts +19 -1
  6. package/dist/browser-network-route.d.ts +8 -0
  7. package/dist/{chunk-JIKNR5YL.js → chunk-2PQMSRCB.js} +15518 -6863
  8. package/dist/chunk-2PQMSRCB.js.map +1 -0
  9. package/dist/controller-data-plane.d.ts +12 -0
  10. package/dist/http/auth.d.ts +1 -1
  11. package/dist/http/sse.d.ts +18 -1
  12. package/dist/index.js +1 -1
  13. package/dist/integrations/api-integrations.d.ts +2 -2
  14. package/dist/integrations/fiken.d.ts +233 -0
  15. package/dist/integrations/google-drive.d.ts +6 -6
  16. package/dist/integrations/provider-oauth.d.ts +0 -2
  17. package/dist/integrations/slack-bot.d.ts +171 -1
  18. package/dist/integrations/slack-interactions.d.ts +17 -5
  19. package/dist/interaction-holder-heartbeat.d.ts +17 -0
  20. package/dist/interaction-metrics.d.ts +11 -0
  21. package/dist/mcp/server.d.ts +3 -2
  22. package/dist/model-catalog.d.ts +1 -0
  23. package/dist/routes/canonical-human-identities.d.ts +3 -0
  24. package/dist/routes/channels.d.ts +3 -0
  25. package/dist/routes/integration-facets.d.ts +3 -0
  26. package/dist/routes/interaction-resources.d.ts +5 -0
  27. package/dist/routes/organization-memberships.d.ts +3 -0
  28. package/dist/routes/sessions.d.ts +2 -1
  29. package/dist/routes/slack-task-policy.d.ts +3 -0
  30. package/dist/routes/supergrok.d.ts +3 -0
  31. package/dist/sandbox/channel-a.d.ts +8 -1
  32. package/dist/sandbox/machines.d.ts +2 -2
  33. package/dist/sandbox/viewer.d.ts +3 -1
  34. package/dist/slack-reaction-files.d.ts +27 -0
  35. package/dist/transcription/providers/xai-subscription.d.ts +8 -0
  36. package/dist/xai-realtime.d.ts +26 -0
  37. package/dist/xai-subscription-auth.d.ts +23 -0
  38. package/package.json +17 -16
  39. package/src/app.ts +352 -49
  40. package/src/auth/canonical-human-session-admission.ts +116 -0
  41. package/src/auth/managed-auth.ts +101 -0
  42. package/src/browser-auth-broker.ts +155 -0
  43. package/src/browser-controller-authority.ts +50 -1
  44. package/src/browser-network-route.ts +34 -0
  45. package/src/controller-data-plane.ts +35 -0
  46. package/src/http/auth.ts +27 -9
  47. package/src/http/sse.ts +229 -5
  48. package/src/integrations/api-integrations.ts +57 -42
  49. package/src/integrations/fiken.ts +1230 -0
  50. package/src/integrations/google-drive.ts +31 -26
  51. package/src/integrations/provider-oauth.ts +70 -91
  52. package/src/integrations/slack-bot.ts +638 -9
  53. package/src/integrations/slack-interactions.ts +1583 -73
  54. package/src/interaction-holder-heartbeat.ts +95 -0
  55. package/src/interaction-metrics.ts +159 -0
  56. package/src/mcp/documents.ts +115 -6
  57. package/src/mcp/server.ts +865 -242
  58. package/src/model-catalog.ts +19 -6
  59. package/src/routes/api-integrations.ts +32 -29
  60. package/src/routes/browser-identities.ts +24 -0
  61. package/src/routes/browser-sessions.ts +2122 -183
  62. package/src/routes/canonical-human-identities.ts +156 -0
  63. package/src/routes/channels.ts +90 -0
  64. package/src/routes/computer-sessions.ts +441 -86
  65. package/src/routes/connections.ts +141 -4
  66. package/src/routes/{integration-features.ts → integration-facets.ts} +63 -63
  67. package/src/routes/interaction-resources.ts +595 -0
  68. package/src/routes/organization-memberships.ts +53 -0
  69. package/src/routes/plugins.ts +2 -1
  70. package/src/routes/sessions.ts +419 -43
  71. package/src/routes/skills.ts +92 -10
  72. package/src/routes/slack-task-policy.ts +115 -0
  73. package/src/routes/supergrok.ts +717 -0
  74. package/src/routes/transcription-recordings.ts +9 -2
  75. package/src/routes/transcriptions.ts +2 -1
  76. package/src/routes/video-generation.ts +34 -4
  77. package/src/routes/workspaces.ts +22 -4
  78. package/src/sandbox/channel-a.ts +29 -2
  79. package/src/sandbox/machines.ts +5 -5
  80. package/src/sandbox/viewer.ts +10 -3
  81. package/src/slack-reaction-files.ts +181 -0
  82. package/src/transcription/providers/xai-subscription.ts +110 -0
  83. package/src/transcription/service.ts +17 -2
  84. package/src/xai-realtime.ts +216 -0
  85. package/src/xai-subscription-auth.ts +132 -0
  86. package/dist/chunk-JIKNR5YL.js.map +0 -1
  87. package/dist/routes/integration-features.d.ts +0 -3
@@ -0,0 +1,116 @@
1
+ import type {
2
+ CanonicalHumanIdentityStatus,
3
+ CanonicalHumanLoginBindingStatus,
4
+ CanonicalHumanRecoveryState,
5
+ } from "@opengeni/contracts/canonical-human-identities";
6
+
7
+ export type CanonicalHumanSessionAdmissionIntent =
8
+ | "ordinary_session"
9
+ | "binding_synchronization"
10
+ | "recovery_completion";
11
+
12
+ export type CanonicalHumanIdentityAdmissionSnapshot = {
13
+ readonly id: string;
14
+ readonly status: CanonicalHumanIdentityStatus;
15
+ readonly recoveryState: CanonicalHumanRecoveryState;
16
+ readonly activeLoginBindingId: string | null;
17
+ };
18
+
19
+ export type CanonicalHumanLoginBindingAdmissionSnapshot = {
20
+ readonly id: string;
21
+ readonly identityId: string;
22
+ readonly status: CanonicalHumanLoginBindingStatus;
23
+ };
24
+
25
+ export type CanonicalHumanSessionAdmissionDenialCode =
26
+ | "identity_binding_mismatch"
27
+ | "ordinary_identity_not_active"
28
+ | "ordinary_identity_not_ready"
29
+ | "ordinary_binding_missing"
30
+ | "ordinary_binding_not_active"
31
+ | "ordinary_active_binding_missing"
32
+ | "recovery_identity_not_required"
33
+ | "recovery_state_not_required"
34
+ | "recovery_active_binding_present"
35
+ | "recovery_binding_missing"
36
+ | "recovery_binding_not_pending";
37
+
38
+ export type CanonicalHumanSessionAdmissionDecision =
39
+ | {
40
+ allowed: true;
41
+ intent: CanonicalHumanSessionAdmissionIntent;
42
+ }
43
+ | {
44
+ allowed: false;
45
+ intent: CanonicalHumanSessionAdmissionIntent;
46
+ code: CanonicalHumanSessionAdmissionDenialCode;
47
+ };
48
+
49
+ export function decideCanonicalHumanSessionAdmission(input: {
50
+ intent: CanonicalHumanSessionAdmissionIntent;
51
+ identity: CanonicalHumanIdentityAdmissionSnapshot;
52
+ binding: CanonicalHumanLoginBindingAdmissionSnapshot | null;
53
+ }): CanonicalHumanSessionAdmissionDecision {
54
+ if (input.binding && input.binding.identityId !== input.identity.id) {
55
+ return denied(input.intent, "identity_binding_mismatch");
56
+ }
57
+
58
+ if (input.intent === "recovery_completion") {
59
+ return decideRecoveryCompletion(input.identity, input.binding);
60
+ }
61
+
62
+ if (input.identity.status !== "active") {
63
+ return denied(input.intent, "ordinary_identity_not_active");
64
+ }
65
+ if (input.identity.recoveryState !== "ready") {
66
+ return denied(input.intent, "ordinary_identity_not_ready");
67
+ }
68
+ if (input.intent === "binding_synchronization" && input.binding === null) {
69
+ return allowed(input.intent);
70
+ }
71
+ if (input.binding === null) {
72
+ return denied(input.intent, "ordinary_binding_missing");
73
+ }
74
+ if (input.binding.status !== "active") {
75
+ return denied(input.intent, "ordinary_binding_not_active");
76
+ }
77
+ if (input.identity.activeLoginBindingId === null) {
78
+ return denied(input.intent, "ordinary_active_binding_missing");
79
+ }
80
+ return allowed(input.intent);
81
+ }
82
+
83
+ function decideRecoveryCompletion(
84
+ identity: CanonicalHumanIdentityAdmissionSnapshot,
85
+ binding: CanonicalHumanLoginBindingAdmissionSnapshot | null,
86
+ ): CanonicalHumanSessionAdmissionDecision {
87
+ if (identity.status !== "recovery_required") {
88
+ return denied("recovery_completion", "recovery_identity_not_required");
89
+ }
90
+ if (identity.recoveryState !== "recovery_required" && identity.recoveryState !== "lost_factor") {
91
+ return denied("recovery_completion", "recovery_state_not_required");
92
+ }
93
+ if (identity.activeLoginBindingId !== null) {
94
+ return denied("recovery_completion", "recovery_active_binding_present");
95
+ }
96
+ if (binding === null) {
97
+ return denied("recovery_completion", "recovery_binding_missing");
98
+ }
99
+ if (binding.status !== "recovery_pending") {
100
+ return denied("recovery_completion", "recovery_binding_not_pending");
101
+ }
102
+ return allowed("recovery_completion");
103
+ }
104
+
105
+ function allowed(
106
+ intent: CanonicalHumanSessionAdmissionIntent,
107
+ ): CanonicalHumanSessionAdmissionDecision {
108
+ return { allowed: true, intent };
109
+ }
110
+
111
+ function denied(
112
+ intent: CanonicalHumanSessionAdmissionIntent,
113
+ code: CanonicalHumanSessionAdmissionDenialCode,
114
+ ): CanonicalHumanSessionAdmissionDecision {
115
+ return { allowed: false, intent, code };
116
+ }
@@ -2,11 +2,18 @@ import type { Settings } from "@opengeni/config";
2
2
  import { type ManagedAuth } from "@opengeni/core";
3
3
  import type { Database } from "@opengeni/db";
4
4
  import { ensureManagedAccessForUser } from "@opengeni/db";
5
+ import {
6
+ ensureCanonicalHumanIdentityForAuthUser,
7
+ getCanonicalHumanIdentityProjection,
8
+ synchronizeCanonicalHumanLoginBindings,
9
+ } from "@opengeni/db/canonical-human-identities";
5
10
  import { betterAuth } from "better-auth";
6
11
  import { createEmailVerificationToken } from "better-auth/api";
7
12
  import { Pool } from "pg";
8
13
  import { Resend } from "resend";
9
14
 
15
+ import { decideCanonicalHumanSessionAdmission } from "./canonical-human-session-admission";
16
+
10
17
  // `ManagedAuth` (the Better Auth `Auth<any>` alias) is owned by @opengeni/core
11
18
  // (`managed-auth-type.ts`) — `dependencies.ts`/`access` reference it as a
12
19
  // type-only slot. We re-export it from this construction site so existing
@@ -65,6 +72,28 @@ export function createManagedAuth(settings: Settings, db: Database): ManagedAuth
65
72
  createdAt: "created_at",
66
73
  updatedAt: "updated_at",
67
74
  },
75
+ additionalFields: {
76
+ identityId: {
77
+ type: "string",
78
+ fieldName: "identity_id",
79
+ input: false,
80
+ returned: false,
81
+ },
82
+ identityRevision: {
83
+ type: "number",
84
+ fieldName: "identity_revision",
85
+ input: false,
86
+ returned: false,
87
+ bigint: true,
88
+ },
89
+ authRevision: {
90
+ type: "number",
91
+ fieldName: "auth_revision",
92
+ input: false,
93
+ returned: false,
94
+ bigint: true,
95
+ },
96
+ },
68
97
  },
69
98
  account: {
70
99
  modelName: "auth_identities",
@@ -136,6 +165,78 @@ export function createManagedAuth(settings: Settings, db: Database): ManagedAuth
136
165
  },
137
166
  },
138
167
  databaseHooks: {
168
+ session: {
169
+ create: {
170
+ before: async (session) => {
171
+ await ensureCanonicalHumanIdentityForAuthUser(db, session.userId);
172
+ const preflightProjection = await getCanonicalHumanIdentityProjection(
173
+ db,
174
+ session.userId,
175
+ );
176
+ const preflight = decideCanonicalHumanSessionAdmission({
177
+ intent: "binding_synchronization",
178
+ identity: preflightProjection.activeIdentity,
179
+ binding: null,
180
+ });
181
+ if (!preflight.allowed) {
182
+ const recoveryBinding = preflightProjection.loginBindings.find(
183
+ (binding) => binding.status === "recovery_pending",
184
+ );
185
+ const recoveryAdmission = decideCanonicalHumanSessionAdmission({
186
+ intent: "recovery_completion",
187
+ identity: preflightProjection.activeIdentity,
188
+ binding: recoveryBinding
189
+ ? {
190
+ id: recoveryBinding.id,
191
+ identityId: preflightProjection.activeIdentity.id,
192
+ status: recoveryBinding.status,
193
+ }
194
+ : null,
195
+ });
196
+ if (!recoveryAdmission.allowed) {
197
+ return false;
198
+ }
199
+ return {
200
+ data: {
201
+ ...session,
202
+ identityId: preflightProjection.activeIdentity.id,
203
+ identityRevision: preflightProjection.activeIdentity.identityRevision,
204
+ authRevision: preflightProjection.activeIdentity.authRevision,
205
+ },
206
+ };
207
+ }
208
+
209
+ await synchronizeCanonicalHumanLoginBindings(db, session.userId);
210
+ const projection = await getCanonicalHumanIdentityProjection(db, session.userId);
211
+ const activeBinding = projection.loginBindings.find(
212
+ (binding) => binding.id === projection.activeIdentity.activeLoginBindingId,
213
+ );
214
+ const admission = decideCanonicalHumanSessionAdmission({
215
+ intent: "ordinary_session",
216
+ identity: projection.activeIdentity,
217
+ binding: activeBinding
218
+ ? {
219
+ id: activeBinding.id,
220
+ identityId: projection.activeIdentity.id,
221
+ status: activeBinding.status,
222
+ }
223
+ : null,
224
+ });
225
+ if (!admission.allowed) {
226
+ return false;
227
+ }
228
+
229
+ return {
230
+ data: {
231
+ ...session,
232
+ identityId: projection.activeIdentity.id,
233
+ identityRevision: projection.activeIdentity.identityRevision,
234
+ authRevision: projection.activeIdentity.authRevision,
235
+ },
236
+ };
237
+ },
238
+ },
239
+ },
139
240
  user: {
140
241
  create: {
141
242
  after: async (user) => {
@@ -0,0 +1,155 @@
1
+ import { createHmac } from "node:crypto";
2
+ import type {
3
+ BrowserProtectedAuthFieldValue,
4
+ ProtectedAuthField,
5
+ SiteAuthAuthority,
6
+ } from "@opengeni/contracts";
7
+
8
+ type ConnectionFieldsAuthority = Extract<SiteAuthAuthority, { kind: "connection_fields" }>;
9
+
10
+ export class BrowserAuthCredentialError extends Error {
11
+ readonly name = "BrowserAuthCredentialError";
12
+ }
13
+
14
+ export function resolveProtectedAuthFieldValues(input: {
15
+ authority: ConnectionFieldsAuthority;
16
+ requestedFields: readonly ProtectedAuthField[];
17
+ credential: Readonly<Record<string, unknown>>;
18
+ nowMs?: number;
19
+ }): BrowserProtectedAuthFieldValue[] {
20
+ const configured = new Map(input.authority.fields.map((field) => [field.id, field]));
21
+ return input.requestedFields.map((requested) => {
22
+ const field = configured.get(requested.fieldId);
23
+ if (!field) {
24
+ throw new BrowserAuthCredentialError(
25
+ `Protected auth field ${requested.fieldId} is not configured`,
26
+ );
27
+ }
28
+ const stored = Object.prototype.hasOwnProperty.call(input.credential, field.credentialKey)
29
+ ? input.credential[field.credentialKey]
30
+ : undefined;
31
+ if (typeof stored !== "string" || stored.length === 0) {
32
+ throw new BrowserAuthCredentialError(
33
+ `Credential field ${field.credentialKey} is unavailable`,
34
+ );
35
+ }
36
+ const value =
37
+ field.purpose === "totp"
38
+ ? totpCode(stored, {
39
+ ...(field.digits === undefined ? {} : { digits: field.digits }),
40
+ ...(field.periodSeconds === undefined ? {} : { periodSeconds: field.periodSeconds }),
41
+ ...(field.algorithm === undefined ? {} : { algorithm: field.algorithm }),
42
+ ...(input.nowMs === undefined ? {} : { nowMs: input.nowMs }),
43
+ })
44
+ : stored;
45
+ return {
46
+ fieldId: requested.fieldId,
47
+ locator: requested.locator,
48
+ purpose: field.purpose,
49
+ value,
50
+ };
51
+ });
52
+ }
53
+
54
+ export function totpCode(
55
+ secretValue: string,
56
+ options: {
57
+ digits?: number;
58
+ periodSeconds?: number;
59
+ algorithm?: "sha1" | "sha256" | "sha512";
60
+ nowMs?: number;
61
+ } = {},
62
+ ): string {
63
+ const uri = secretValue.startsWith("otpauth://") ? parseOtpAuthUri(secretValue) : null;
64
+ const digits = options.digits ?? uri?.digits ?? 6;
65
+ const periodSeconds = options.periodSeconds ?? uri?.periodSeconds ?? 30;
66
+ const algorithm = options.algorithm ?? uri?.algorithm ?? "sha1";
67
+ if (!Number.isInteger(digits) || digits < 6 || digits > 10) {
68
+ throw new BrowserAuthCredentialError("TOTP digits must be between 6 and 10");
69
+ }
70
+ if (!Number.isInteger(periodSeconds) || periodSeconds < 15 || periodSeconds > 300) {
71
+ throw new BrowserAuthCredentialError("TOTP period is outside the supported range");
72
+ }
73
+ const nowMs = options.nowMs ?? Date.now();
74
+ if (!Number.isFinite(nowMs) || nowMs < 0) {
75
+ throw new BrowserAuthCredentialError("TOTP time is invalid");
76
+ }
77
+ const secret = decodeBase32(uri?.secret ?? secretValue);
78
+ const counter = Buffer.alloc(8);
79
+ counter.writeBigUInt64BE(BigInt(Math.floor(nowMs / (periodSeconds * 1_000))));
80
+ const digest = createHmac(algorithm, secret).update(counter).digest();
81
+ const offset = digest[digest.length - 1]! & 0x0f;
82
+ const binary =
83
+ ((digest[offset]! & 0x7f) << 24) |
84
+ (digest[offset + 1]! << 16) |
85
+ (digest[offset + 2]! << 8) |
86
+ digest[offset + 3]!;
87
+ const code = String(binary % 10 ** digits).padStart(digits, "0");
88
+ secret.fill(0);
89
+ counter.fill(0);
90
+ digest.fill(0);
91
+ return code;
92
+ }
93
+
94
+ function parseOtpAuthUri(value: string): {
95
+ secret: string;
96
+ digits?: number;
97
+ periodSeconds?: number;
98
+ algorithm?: "sha1" | "sha256" | "sha512";
99
+ } {
100
+ let url: URL;
101
+ try {
102
+ url = new URL(value);
103
+ } catch {
104
+ throw new BrowserAuthCredentialError("TOTP credential contains an invalid otpauth URI");
105
+ }
106
+ if (url.protocol !== "otpauth:" || url.hostname !== "totp") {
107
+ throw new BrowserAuthCredentialError("Only otpauth TOTP credentials are supported");
108
+ }
109
+ const secret = url.searchParams.get("secret")?.trim();
110
+ if (!secret) throw new BrowserAuthCredentialError("TOTP credential has no secret");
111
+ const digits = optionalIntegerParameter(url, "digits");
112
+ const periodSeconds = optionalIntegerParameter(url, "period");
113
+ const rawAlgorithm = url.searchParams.get("algorithm")?.toLowerCase();
114
+ if (rawAlgorithm && !["sha1", "sha256", "sha512"].includes(rawAlgorithm)) {
115
+ throw new BrowserAuthCredentialError("TOTP credential uses an unsupported algorithm");
116
+ }
117
+ return {
118
+ secret,
119
+ ...(digits === undefined ? {} : { digits }),
120
+ ...(periodSeconds === undefined ? {} : { periodSeconds }),
121
+ ...(rawAlgorithm ? { algorithm: rawAlgorithm as "sha1" | "sha256" | "sha512" } : {}),
122
+ };
123
+ }
124
+
125
+ function optionalIntegerParameter(url: URL, name: string): number | undefined {
126
+ const value = url.searchParams.get(name);
127
+ if (value === null) return undefined;
128
+ if (!/^\d+$/u.test(value)) {
129
+ throw new BrowserAuthCredentialError(`TOTP ${name} is invalid`);
130
+ }
131
+ return Number(value);
132
+ }
133
+
134
+ function decodeBase32(value: string): Buffer {
135
+ const normalized = value.toUpperCase().replace(/[\s=-]/gu, "");
136
+ if (!normalized || /[^A-Z2-7]/u.test(normalized)) {
137
+ throw new BrowserAuthCredentialError("TOTP secret is not valid Base32");
138
+ }
139
+ let accumulator = 0;
140
+ let bits = 0;
141
+ const bytes: number[] = [];
142
+ for (const character of normalized) {
143
+ const code = character.charCodeAt(0);
144
+ const digit = code >= 65 && code <= 90 ? code - 65 : code - 50 + 26;
145
+ accumulator = (accumulator << 5) | digit;
146
+ bits += 5;
147
+ if (bits >= 8) {
148
+ bits -= 8;
149
+ bytes.push((accumulator >>> bits) & 0xff);
150
+ accumulator &= (1 << bits) - 1;
151
+ }
152
+ }
153
+ if (bytes.length === 0) throw new BrowserAuthCredentialError("TOTP secret is empty");
154
+ return Buffer.from(bytes);
155
+ }
@@ -1,5 +1,9 @@
1
1
  import { createHmac } from "node:crypto";
2
- import type { InteractionPlacement } from "@opengeni/contracts";
2
+ import type {
3
+ InteractionPlacement,
4
+ NetworkRouteConfiguration,
5
+ NetworkRouteConsistency,
6
+ } from "@opengeni/contracts";
3
7
 
4
8
  type BrowserControllerAuthorityScope = {
5
9
  rootSecret: string;
@@ -54,6 +58,35 @@ export function deriveBrowserSessionControllerTokens(scope: BrowserSessionAuthor
54
58
  };
55
59
  }
56
60
 
61
+ /** Secret-safe durable identity for one exact route launch. The digest binds
62
+ * the route snapshot and proxy credential without making a password-verifier
63
+ * hash available to database readers. */
64
+ export function deriveBrowserNetworkRouteAuthorityDigest(scope: {
65
+ rootSecret: string;
66
+ accountId: string;
67
+ workspaceId: string;
68
+ browserSessionId: string;
69
+ routeId: string;
70
+ routeVersion: number;
71
+ credentialVersion: number | null;
72
+ configuration: NetworkRouteConfiguration;
73
+ consistency: NetworkRouteConsistency;
74
+ proxyCredential: { username: string; password: string } | null;
75
+ }): string {
76
+ return derive(scope.rootSecret, "network-route-launch.v1", [
77
+ scope.accountId,
78
+ scope.workspaceId,
79
+ scope.browserSessionId,
80
+ scope.routeId,
81
+ scope.routeVersion,
82
+ scope.credentialVersion ?? "none",
83
+ canonicalJson(scope.configuration),
84
+ canonicalJson(scope.consistency),
85
+ scope.proxyCredential?.username ?? "none",
86
+ scope.proxyCredential?.password ?? "none",
87
+ ]).replace(/^ogb\./u, "ogr.");
88
+ }
89
+
57
90
  export function deriveBrowserViewGrantToken(
58
91
  scope: BrowserSessionAuthorityScope & { grantId: string; expiresAt: string },
59
92
  ): string {
@@ -123,6 +156,22 @@ function writeField(hmac: ReturnType<typeof createHmac>, value: string): void {
123
156
  hmac.update(bytes);
124
157
  }
125
158
 
159
+ function canonicalJson(value: unknown): string {
160
+ return JSON.stringify(canonicalJsonValue(value));
161
+ }
162
+
163
+ function canonicalJsonValue(value: unknown): unknown {
164
+ if (Array.isArray(value)) return value.map(canonicalJsonValue);
165
+ if (value && typeof value === "object") {
166
+ return Object.fromEntries(
167
+ Object.entries(value as Record<string, unknown>)
168
+ .sort(([left], [right]) => left.localeCompare(right))
169
+ .map(([key, entry]) => [key, canonicalJsonValue(entry)]),
170
+ );
171
+ }
172
+ return value;
173
+ }
174
+
126
175
  function placementKey(placement: InteractionPlacement): string {
127
176
  switch (placement.kind) {
128
177
  case "sandbox_group":
@@ -0,0 +1,34 @@
1
+ import type {
2
+ InteractionPlacement,
3
+ NetworkRouteConfiguration,
4
+ NetworkRouteConsistency,
5
+ } from "@opengeni/contracts";
6
+ import { networkRoutePlacementCompatibilityIssue } from "@opengeni/contracts";
7
+ import { BrowserSessionStateError } from "@opengeni/db";
8
+ import type { PlacementBrowserNetworkRoute } from "@opengeni/runtime/sandbox";
9
+
10
+ /** Resolve one provider-neutral managed route into the exact private selector
11
+ * accepted by the chosen external browser placement. Unsupported guarantees
12
+ * fail before lifecycle dispatch; no provider API key enters this value. */
13
+ export function managedNetworkRouteForPlacement(
14
+ configuration: Extract<NetworkRouteConfiguration, { kind: "managed" }>,
15
+ consistency: NetworkRouteConsistency,
16
+ placement: InteractionPlacement,
17
+ ): NonNullable<PlacementBrowserNetworkRoute["providerRoute"]> {
18
+ const compatibilityIssue = networkRoutePlacementCompatibilityIssue(
19
+ configuration,
20
+ consistency,
21
+ placement,
22
+ );
23
+ if (compatibilityIssue) throw new BrowserSessionStateError(compatibilityIssue);
24
+ if (placement.kind !== "external_provider") throw new Error("unreachable managed placement");
25
+ if (configuration.providerId !== "browserbase" && configuration.providerId !== "kernel") {
26
+ throw new Error("unreachable managed provider");
27
+ }
28
+ return {
29
+ providerId: configuration.providerId,
30
+ routeId: configuration.routeId,
31
+ egressClass: configuration.egressClass,
32
+ region: configuration.region,
33
+ };
34
+ }
@@ -0,0 +1,35 @@
1
+ import {
2
+ BrowserControlRequestError,
3
+ BrowserControlTransportError,
4
+ } from "@opengeni/runtime/sandbox";
5
+
6
+ /** Prefer a lease-fenced controller endpoint for the idempotent create path.
7
+ * Only a transport-class failure invalidates the cache and provisions once;
8
+ * semantic failures belong to the caller and must never be replayed. */
9
+ export async function withCachedController<C, T>(options: {
10
+ cachedUrl: string | null;
11
+ createCachedClient: (url: string) => C;
12
+ prepareCachedClient?: (client: C) => Promise<void>;
13
+ invalidateCachedUrl: () => Promise<unknown>;
14
+ provisionClient: () => Promise<C>;
15
+ use: (client: C) => Promise<T>;
16
+ }): Promise<T> {
17
+ if (options.cachedUrl) {
18
+ try {
19
+ const client = options.createCachedClient(options.cachedUrl);
20
+ await options.prepareCachedClient?.(client);
21
+ return await options.use(client);
22
+ } catch (error) {
23
+ if (!isRetryableControllerTransport(error)) throw error;
24
+ await options.invalidateCachedUrl().catch(() => undefined);
25
+ }
26
+ }
27
+ return await options.use(await options.provisionClient());
28
+ }
29
+
30
+ export function isRetryableControllerTransport(error: unknown): boolean {
31
+ return (
32
+ error instanceof BrowserControlTransportError ||
33
+ (error instanceof BrowserControlRequestError && error.retryable)
34
+ );
35
+ }
package/src/http/auth.ts CHANGED
@@ -1,4 +1,5 @@
1
- import type { Settings } from "@opengeni/config";
1
+ import { resolveFirstPartyDelegationSecret, type Settings } from "@opengeni/config";
2
+ import { verifyDelegatedAccessToken } from "@opengeni/contracts";
2
3
  import type { Context, MiddlewareHandler } from "hono";
3
4
  import { installExactPaths, isInstallRedirectPath } from "../routes/install";
4
5
 
@@ -17,7 +18,7 @@ export function requireAccessKey(settings: Settings): MiddlewareHandler {
17
18
  await next();
18
19
  return;
19
20
  }
20
- if (isAuthorized(c, settings.accessKey)) {
21
+ if (await isAuthorized(c, settings)) {
21
22
  await next();
22
23
  return;
23
24
  }
@@ -55,7 +56,10 @@ function isAuthExempt(c: Context, settings: Settings): boolean {
55
56
  path === "/v1/integrations/slack/callback" ||
56
57
  path === "/v1/integrations/slack/events" ||
57
58
  path === "/v1/integrations/slack/commands" ||
58
- path === "/v1/integrations/slack/interactions"
59
+ path === "/v1/integrations/slack/interactions" ||
60
+ // Fiken OAuth browser redirect: exact path only, protected by signed
61
+ // single-use state plus a callback-time grant recheck.
62
+ path === "/v1/integrations/fiken/callback"
59
63
  ) {
60
64
  return true;
61
65
  }
@@ -100,19 +104,33 @@ function isAuthExempt(c: Context, settings: Settings): boolean {
100
104
  return false;
101
105
  }
102
106
 
103
- function isAuthorized(c: Context, expected: string | undefined): boolean {
104
- if (!expected) {
105
- return false;
106
- }
107
+ async function isAuthorized(c: Context, settings: Settings): Promise<boolean> {
108
+ const expected = settings.accessKey;
107
109
  const explicit = c.req.header("x-opengeni-access-key");
108
- if (constantTimeEqual(explicit, expected)) {
110
+ if (expected && constantTimeEqual(explicit, expected)) {
109
111
  return true;
110
112
  }
111
113
  const authorization = c.req.header("authorization");
112
114
  const bearer = authorization?.startsWith("Bearer ")
113
115
  ? authorization.slice("Bearer ".length)
114
116
  : undefined;
115
- return constantTimeEqual(bearer, expected);
117
+ if (expected && constantTimeEqual(bearer, expected)) {
118
+ return true;
119
+ }
120
+
121
+ // This middleware is only the deployment perimeter. A valid first-party
122
+ // delegated bearer is already authenticated with the deployment's separate
123
+ // delegation authority, so admit it only to the product API's route-level
124
+ // access resolver. Deployment-only surfaces such as /metrics still require
125
+ // the static deployment key. The resolver enforces exact account, workspace,
126
+ // principal, and permissions; attempt-bound surfaces add live-attempt fences.
127
+ const delegationSecret = resolveFirstPartyDelegationSecret(settings);
128
+ return Boolean(
129
+ c.req.path.startsWith("/v1/") &&
130
+ bearer &&
131
+ delegationSecret &&
132
+ (await verifyDelegatedAccessToken(delegationSecret, bearer)),
133
+ );
116
134
  }
117
135
 
118
136
  function constantTimeEqual(actual: string | undefined, expected: string): boolean {