@opengeni/api-router 0.23.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 (152) hide show
  1. package/dist/app.d.ts +3 -2
  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 +61 -0
  6. package/dist/browser-network-route.d.ts +8 -0
  7. package/dist/browser-state-authority.d.ts +27 -0
  8. package/dist/{chunk-T4T2PGU4.js → chunk-2PQMSRCB.js} +37891 -17463
  9. package/dist/chunk-2PQMSRCB.js.map +1 -0
  10. package/dist/codemode.d.ts +23 -0
  11. package/dist/controller-data-plane.d.ts +12 -0
  12. package/dist/editable-artifact-live-hints.d.ts +11 -0
  13. package/dist/editable-artifact-native-kernel.d.ts +37 -0
  14. package/dist/editable-artifact-office-import.d.ts +22 -0
  15. package/dist/editable-artifact-production.d.ts +29 -0
  16. package/dist/editable-artifact-websocket.d.ts +49 -0
  17. package/dist/editable-artifact-workspace-files.d.ts +23 -0
  18. package/dist/github-browser-flow.d.ts +6 -0
  19. package/dist/http/auth.d.ts +1 -1
  20. package/dist/http/cors.d.ts +1 -0
  21. package/dist/http/sse.d.ts +18 -1
  22. package/dist/index.d.ts +3 -2
  23. package/dist/index.js +1796 -22
  24. package/dist/index.js.map +1 -1
  25. package/dist/integrations/api-integrations.d.ts +24 -0
  26. package/dist/integrations/atlassian.d.ts +176 -0
  27. package/dist/integrations/fiken.d.ts +233 -0
  28. package/dist/integrations/github-skill-source.d.ts +5 -0
  29. package/dist/integrations/google-drive.d.ts +85 -0
  30. package/dist/integrations/oauth-client.d.ts +22 -1
  31. package/dist/integrations/provider-oauth.d.ts +17 -0
  32. package/dist/integrations/slack-bot.d.ts +175 -1
  33. package/dist/integrations/slack-interactions.d.ts +24 -6
  34. package/dist/integrations/social-api.d.ts +2 -1
  35. package/dist/interaction-holder-heartbeat.d.ts +17 -0
  36. package/dist/interaction-metrics.d.ts +11 -0
  37. package/dist/mcp/editable-artifact-query-schema.d.ts +4 -0
  38. package/dist/mcp/editable-artifacts.d.ts +13 -0
  39. package/dist/mcp/scheduled-task-view.d.ts +168 -0
  40. package/dist/mcp/server.d.ts +16 -4
  41. package/dist/memory-slack-delivery.d.ts +9 -0
  42. package/dist/model-catalog.d.ts +1 -0
  43. package/dist/routes/api-integrations.d.ts +8 -0
  44. package/dist/routes/browser-identities.d.ts +5 -0
  45. package/dist/routes/browser-sessions.d.ts +6 -0
  46. package/dist/routes/canonical-human-identities.d.ts +3 -0
  47. package/dist/routes/channels.d.ts +3 -0
  48. package/dist/routes/company-profile.d.ts +3 -0
  49. package/dist/routes/computer-sessions.d.ts +6 -0
  50. package/dist/routes/editable-artifacts.d.ts +44 -0
  51. package/dist/routes/integration-facets.d.ts +3 -0
  52. package/dist/routes/interaction-resources.d.ts +5 -0
  53. package/dist/routes/memory-slack-publications.d.ts +6 -0
  54. package/dist/routes/organization-memberships.d.ts +3 -0
  55. package/dist/routes/plugins.d.ts +8 -0
  56. package/dist/routes/sessions.d.ts +18 -2
  57. package/dist/routes/skills.d.ts +6 -0
  58. package/dist/routes/slack-task-policy.d.ts +3 -0
  59. package/dist/routes/supergrok.d.ts +3 -0
  60. package/dist/routes/video-generation.d.ts +3 -0
  61. package/dist/sandbox/channel-a.d.ts +62 -2
  62. package/dist/sandbox/machines.d.ts +2 -2
  63. package/dist/sandbox/metrics-ingestion.d.ts +6 -1
  64. package/dist/sandbox/viewer.d.ts +7 -3
  65. package/dist/slack-reaction-files.d.ts +27 -0
  66. package/dist/temporal-schedule-cleanup.d.ts +26 -0
  67. package/dist/transcription/providers/xai-subscription.d.ts +8 -0
  68. package/dist/xai-realtime.d.ts +26 -0
  69. package/dist/xai-subscription-auth.d.ts +23 -0
  70. package/package.json +20 -14
  71. package/src/app.ts +604 -64
  72. package/src/auth/canonical-human-session-admission.ts +116 -0
  73. package/src/auth/managed-auth.ts +101 -0
  74. package/src/browser-auth-broker.ts +155 -0
  75. package/src/browser-controller-authority.ts +186 -0
  76. package/src/browser-network-route.ts +34 -0
  77. package/src/browser-state-authority.ts +236 -0
  78. package/src/codemode.ts +186 -0
  79. package/src/controller-data-plane.ts +35 -0
  80. package/src/editable-artifact-live-hints.ts +64 -0
  81. package/src/editable-artifact-native-kernel.ts +659 -0
  82. package/src/editable-artifact-office-import.ts +230 -0
  83. package/src/editable-artifact-production.ts +419 -0
  84. package/src/editable-artifact-websocket.ts +311 -0
  85. package/src/editable-artifact-workspace-files.ts +186 -0
  86. package/src/github-browser-flow.ts +35 -6
  87. package/src/http/auth.ts +29 -9
  88. package/src/http/cors.ts +3 -0
  89. package/src/http/sse.ts +229 -5
  90. package/src/index.ts +119 -20
  91. package/src/integrations/api-integrations.ts +365 -0
  92. package/src/integrations/atlassian.ts +1621 -0
  93. package/src/integrations/fiken.ts +1230 -0
  94. package/src/integrations/github-skill-source.ts +142 -0
  95. package/src/integrations/google-drive.ts +1005 -64
  96. package/src/integrations/oauth-client.ts +123 -33
  97. package/src/integrations/provider-oauth.ts +756 -0
  98. package/src/integrations/slack-bot.ts +669 -11
  99. package/src/integrations/slack-interactions.ts +2063 -93
  100. package/src/integrations/social-api.ts +11 -0
  101. package/src/interaction-holder-heartbeat.ts +95 -0
  102. package/src/interaction-metrics.ts +159 -0
  103. package/src/mcp/documents.ts +146 -6
  104. package/src/mcp/editable-artifact-query-schema.ts +236 -0
  105. package/src/mcp/editable-artifacts.ts +448 -0
  106. package/src/mcp/scheduled-task-view.ts +34 -0
  107. package/src/mcp/server.ts +1756 -355
  108. package/src/memory-slack-delivery.ts +209 -0
  109. package/src/model-catalog.ts +19 -6
  110. package/src/routes/api-integrations.ts +410 -0
  111. package/src/routes/browser-identities.ts +160 -0
  112. package/src/routes/browser-sessions.ts +4482 -0
  113. package/src/routes/canonical-human-identities.ts +156 -0
  114. package/src/routes/channels.ts +90 -0
  115. package/src/routes/company-profile.ts +255 -0
  116. package/src/routes/computer-sessions.ts +1602 -0
  117. package/src/routes/connections.ts +421 -99
  118. package/src/routes/documents.ts +22 -3
  119. package/src/routes/editable-artifacts.ts +1159 -0
  120. package/src/routes/files.ts +106 -4
  121. package/src/routes/github.ts +18 -2
  122. package/src/routes/install.ts +7 -1
  123. package/src/routes/integration-facets.ts +258 -0
  124. package/src/routes/interaction-resources.ts +595 -0
  125. package/src/routes/memory-slack-publications.ts +216 -0
  126. package/src/routes/organization-memberships.ts +53 -0
  127. package/src/routes/packs.ts +437 -7
  128. package/src/routes/plugins.ts +752 -0
  129. package/src/routes/rigs.ts +77 -20
  130. package/src/routes/scheduled-tasks.ts +67 -40
  131. package/src/routes/sessions.ts +830 -278
  132. package/src/routes/skills.ts +256 -0
  133. package/src/routes/slack-task-policy.ts +115 -0
  134. package/src/routes/supergrok.ts +717 -0
  135. package/src/routes/transcription-recordings.ts +9 -2
  136. package/src/routes/transcriptions.ts +2 -1
  137. package/src/routes/video-generation.ts +162 -0
  138. package/src/routes/workspaces.ts +68 -28
  139. package/src/sandbox/channel-a.ts +750 -116
  140. package/src/sandbox/machines.ts +5 -5
  141. package/src/sandbox/metrics-ingestion.ts +121 -3
  142. package/src/sandbox/rematerialize.ts +35 -47
  143. package/src/sandbox/viewer.ts +39 -12
  144. package/src/slack-reaction-files.ts +181 -0
  145. package/src/temporal-schedule-cleanup.ts +135 -0
  146. package/src/transcription/providers/xai-subscription.ts +110 -0
  147. package/src/transcription/service.ts +17 -2
  148. package/src/xai-realtime.ts +216 -0
  149. package/src/xai-subscription-auth.ts +132 -0
  150. package/dist/chunk-T4T2PGU4.js.map +0 -1
  151. package/dist/mcp/toolspace.d.ts +0 -71
  152. package/src/mcp/toolspace.ts +0 -1190
@@ -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
+ }
@@ -0,0 +1,186 @@
1
+ import { createHmac } from "node:crypto";
2
+ import type {
3
+ InteractionPlacement,
4
+ NetworkRouteConfiguration,
5
+ NetworkRouteConsistency,
6
+ } from "@opengeni/contracts";
7
+
8
+ type BrowserControllerAuthorityScope = {
9
+ rootSecret: string;
10
+ accountId: string;
11
+ workspaceId: string;
12
+ placement: InteractionPlacement;
13
+ placementInstanceId: string;
14
+ };
15
+
16
+ type BrowserSessionAuthorityScope = BrowserControllerAuthorityScope & {
17
+ browserSessionId: string;
18
+ controllerGeneration: string;
19
+ tokenGeneration: number;
20
+ };
21
+
22
+ type ComputerSessionAuthorityScope = BrowserControllerAuthorityScope & {
23
+ computerSessionId: string;
24
+ controllerGeneration: string;
25
+ tokenGeneration: number;
26
+ };
27
+
28
+ /** Stable placement-admin authority. This token is installed owner-only inside
29
+ * the exact placement and is never persisted or returned by the public API. */
30
+ export function deriveBrowserControllerAdminToken(scope: BrowserControllerAuthorityScope): string {
31
+ return derive(scope.rootSecret, "placement-admin.v1", [
32
+ scope.accountId,
33
+ scope.workspaceId,
34
+ placementKey(scope.placement),
35
+ scope.placementInstanceId,
36
+ ]);
37
+ }
38
+
39
+ /** Deterministic controller credentials. The durable generations are the only
40
+ * stored authority; plaintext tokens can be reconstructed only by API replicas
41
+ * holding the deployment HMAC root. */
42
+ export function deriveBrowserSessionControllerTokens(scope: BrowserSessionAuthorityScope): {
43
+ controlToken: string;
44
+ viewToken: string;
45
+ } {
46
+ const fields = [
47
+ scope.accountId,
48
+ scope.workspaceId,
49
+ placementKey(scope.placement),
50
+ scope.placementInstanceId,
51
+ scope.browserSessionId,
52
+ scope.controllerGeneration,
53
+ scope.tokenGeneration,
54
+ ] as const;
55
+ return {
56
+ controlToken: derive(scope.rootSecret, "session-control.v1", fields),
57
+ viewToken: derive(scope.rootSecret, "session-view.v1", fields),
58
+ };
59
+ }
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
+
90
+ export function deriveBrowserViewGrantToken(
91
+ scope: BrowserSessionAuthorityScope & { grantId: string; expiresAt: string },
92
+ ): string {
93
+ return derive(scope.rootSecret, "frame-view-grant.v1", [
94
+ scope.accountId,
95
+ scope.workspaceId,
96
+ placementKey(scope.placement),
97
+ scope.placementInstanceId,
98
+ scope.browserSessionId,
99
+ scope.controllerGeneration,
100
+ scope.tokenGeneration,
101
+ scope.grantId,
102
+ scope.expiresAt,
103
+ ]);
104
+ }
105
+
106
+ /** ComputerSession credentials share the placement controller root while using
107
+ * resource-specific domains. Browser and computer authority cannot collide. */
108
+ export function deriveComputerSessionControllerTokens(scope: ComputerSessionAuthorityScope): {
109
+ controlToken: string;
110
+ viewToken: string;
111
+ } {
112
+ const fields = [
113
+ scope.accountId,
114
+ scope.workspaceId,
115
+ placementKey(scope.placement),
116
+ scope.placementInstanceId,
117
+ scope.computerSessionId,
118
+ scope.controllerGeneration,
119
+ scope.tokenGeneration,
120
+ ] as const;
121
+ return {
122
+ controlToken: derive(scope.rootSecret, "computer-session-control.v1", fields),
123
+ viewToken: derive(scope.rootSecret, "computer-session-view.v1", fields),
124
+ };
125
+ }
126
+
127
+ export function deriveComputerViewGrantToken(
128
+ scope: ComputerSessionAuthorityScope & { grantId: string; expiresAt: string },
129
+ ): string {
130
+ return derive(scope.rootSecret, "computer-frame-view-grant.v1", [
131
+ scope.accountId,
132
+ scope.workspaceId,
133
+ placementKey(scope.placement),
134
+ scope.placementInstanceId,
135
+ scope.computerSessionId,
136
+ scope.controllerGeneration,
137
+ scope.tokenGeneration,
138
+ scope.grantId,
139
+ scope.expiresAt,
140
+ ]);
141
+ }
142
+
143
+ function derive(rootSecret: string, domain: string, fields: readonly (string | number)[]): string {
144
+ if (!rootSecret) throw new Error("browser controller authority root is unavailable");
145
+ const hmac = createHmac("sha256", rootSecret);
146
+ hmac.update("opengeni.browser-controller-authority\0", "utf8");
147
+ writeField(hmac, domain);
148
+ for (const field of fields) writeField(hmac, String(field));
149
+ return `ogb.${hmac.digest("base64url")}`;
150
+ }
151
+
152
+ function writeField(hmac: ReturnType<typeof createHmac>, value: string): void {
153
+ const bytes = Buffer.from(value, "utf8");
154
+ hmac.update(String(bytes.byteLength), "utf8");
155
+ hmac.update(":", "utf8");
156
+ hmac.update(bytes);
157
+ }
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
+
175
+ function placementKey(placement: InteractionPlacement): string {
176
+ switch (placement.kind) {
177
+ case "sandbox_group":
178
+ return `sandbox_group:${placement.sandboxGroupId}`;
179
+ case "connected_machine":
180
+ return `connected_machine:${placement.sandboxId}`;
181
+ case "attached_device":
182
+ return `attached_device:${placement.deviceId}`;
183
+ case "external_provider":
184
+ return `external_provider:${placement.providerId}:${placement.placementId}`;
185
+ }
186
+ }
@@ -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
+ }