@opengeni/api-router 2.5.0 → 2.6.4

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 (59) hide show
  1. package/dist/app.js +1 -1
  2. package/dist/auth/managed-auth-attempt-context.d.ts +5 -1
  3. package/dist/auth/managed-auth.d.ts +24 -1
  4. package/dist/{chunk-QESX7HDK.js → chunk-XTLI3CBH.js} +2236 -387
  5. package/dist/chunk-XTLI3CBH.js.map +1 -0
  6. package/dist/http/sse.d.ts +9 -0
  7. package/dist/index.js +104 -4
  8. package/dist/index.js.map +1 -1
  9. package/dist/interaction-metrics.d.ts +2 -0
  10. package/dist/mcp/company-brain-governed-writes.d.ts +1 -1
  11. package/dist/mcp/company-profile-agent-admin.d.ts +6 -6
  12. package/dist/mcp/remember.d.ts +2 -2
  13. package/dist/mcp/server.d.ts +1 -0
  14. package/dist/mcp/session-view.d.ts +1 -0
  15. package/dist/mcp/session-wait.d.ts +19 -0
  16. package/dist/routes/api-keys.d.ts +2 -0
  17. package/dist/routes/browser-sessions.d.ts +1 -0
  18. package/dist/routes/computer-sessions.d.ts +12 -0
  19. package/dist/routes/managed-auth-session-sets.d.ts +7 -0
  20. package/dist/routes/workspaces.d.ts +1 -1
  21. package/dist/sandbox/metrics-ingestion.d.ts +16 -0
  22. package/dist/workspace-delete-observability.d.ts +10 -0
  23. package/package.json +15 -15
  24. package/src/app.ts +172 -20
  25. package/src/auth/managed-auth-attempt-context.ts +40 -3
  26. package/src/auth/managed-auth-session-adapter.ts +1 -0
  27. package/src/auth/managed-auth.ts +164 -4
  28. package/src/http/sse.ts +279 -45
  29. package/src/integrations/oauth-client.ts +8 -1
  30. package/src/integrations/provider-oauth.ts +12 -2
  31. package/src/interaction-metrics.ts +30 -0
  32. package/src/mcp/company-brain-governed-writes.ts +38 -23
  33. package/src/mcp/company-profile-agent-admin.ts +7 -7
  34. package/src/mcp/remember.ts +19 -8
  35. package/src/mcp/server.ts +318 -26
  36. package/src/mcp/session-wait.ts +56 -8
  37. package/src/routes/api-integrations.ts +2 -2
  38. package/src/routes/api-keys.ts +149 -7
  39. package/src/routes/browser-sessions.ts +10 -2
  40. package/src/routes/capabilities.ts +3 -3
  41. package/src/routes/codex.ts +483 -74
  42. package/src/routes/company-profile.ts +64 -0
  43. package/src/routes/computer-sessions.ts +103 -1
  44. package/src/routes/integration-facets.ts +8 -5
  45. package/src/routes/interaction-resources.ts +7 -1
  46. package/src/routes/managed-auth-session-sets.ts +199 -2
  47. package/src/routes/organization-memberships.ts +28 -8
  48. package/src/routes/packs.ts +5 -5
  49. package/src/routes/plugins.ts +2 -2
  50. package/src/routes/scheduled-tasks.ts +9 -0
  51. package/src/routes/sessions.ts +3 -6
  52. package/src/routes/skills.ts +3 -3
  53. package/src/routes/workspaces.ts +293 -54
  54. package/src/sandbox/channel-a.ts +10 -4
  55. package/src/sandbox/machines.ts +13 -6
  56. package/src/sandbox/metrics-ingestion.ts +157 -3
  57. package/src/sandbox/viewer.ts +20 -1
  58. package/src/workspace-delete-observability.ts +75 -0
  59. package/dist/chunk-QESX7HDK.js.map +0 -1
@@ -1,6 +1,7 @@
1
1
  import { randomUUID } from "node:crypto";
2
2
  import {
3
3
  ActivateCompanyProfileRevisionRequest,
4
+ CompanyProfileAgentPolicy,
4
5
  CompanyProfileConflictResponse,
5
6
  CompanyProfileDiffRequest,
6
7
  CompanyProfileDiffResponse,
@@ -11,6 +12,7 @@ import {
11
12
  CompanyProfileRevision,
12
13
  RollbackCompanyProfileRequest,
13
14
  UpdateCompanyProfileRequest,
15
+ UpdateCompanyProfileAgentPolicyRequest,
14
16
  } from "@opengeni/contracts";
15
17
  import {
16
18
  requireAccessGrant,
@@ -20,15 +22,18 @@ import {
20
22
  } from "@opengeni/core";
21
23
  import {
22
24
  activateCompanyProfileRevision,
25
+ CompanyProfileAgentPolicyError,
23
26
  CompanyProfileConflictError,
24
27
  CompanyProfileInvalidOperationError,
25
28
  CompanyProfileNotFoundError,
26
29
  CompanyProfileOperationReuseError,
27
30
  diffCompanyProfileRevisions,
28
31
  getCompanyProfileRevision,
32
+ getCompanyProfileAgentPolicy,
29
33
  listCompanyProfile,
30
34
  rollbackCompanyProfileRevision,
31
35
  updateCompanyProfile,
36
+ updateCompanyProfileAgentPolicy,
32
37
  } from "@opengeni/db";
33
38
  import type { Context, Hono } from "hono";
34
39
  import { HTTPException } from "hono/http-exception";
@@ -84,6 +89,15 @@ function profileError(context: Context, error: unknown): Response {
84
89
  if (error instanceof CompanyProfileInvalidOperationError) {
85
90
  return context.json({ code: "INVALID_COMPANY_PROFILE_OPERATION", message: error.message }, 422);
86
91
  }
92
+ if (error instanceof CompanyProfileAgentPolicyError) {
93
+ if (error.code === "authority_unavailable") {
94
+ throw new HTTPException(403, { message: error.message });
95
+ }
96
+ if (error.code === "policy_conflict" || error.code === "operation_reused") {
97
+ throw new HTTPException(409, { message: error.message });
98
+ }
99
+ throw new HTTPException(422, { message: error.message });
100
+ }
87
101
  throw error;
88
102
  }
89
103
 
@@ -97,6 +111,56 @@ function revisionId(context: Context): string {
97
111
  export function registerCompanyProfileRoutes(app: Hono, deps: ApiRouteDeps): void {
98
112
  const base = "/v1/workspaces/:workspaceId/company-profile";
99
113
 
114
+ app.get(`${base}/agent-policy`, async (context) => {
115
+ const workspaceId = context.req.param("workspaceId");
116
+ const access = await requireAccessGrantAuthorization(
117
+ context,
118
+ deps,
119
+ workspaceId,
120
+ "workspace:read",
121
+ );
122
+ requireDirectAccountAdmin(access);
123
+ try {
124
+ return context.json(
125
+ CompanyProfileAgentPolicy.parse(
126
+ await getCompanyProfileAgentPolicy(deps.db, {
127
+ accountId: access.grant.accountId,
128
+ workspaceId,
129
+ actorSubjectId: access.grant.subjectId,
130
+ }),
131
+ ),
132
+ );
133
+ } catch (error) {
134
+ return profileError(context, error);
135
+ }
136
+ });
137
+
138
+ app.patch(`${base}/agent-policy`, async (context) => {
139
+ const workspaceId = context.req.param("workspaceId");
140
+ const access = await requireAccessGrantAuthorization(
141
+ context,
142
+ deps,
143
+ workspaceId,
144
+ "workspace:read",
145
+ );
146
+ requireDirectAccountAdmin(access);
147
+ const request = await parseBody(context, UpdateCompanyProfileAgentPolicyRequest);
148
+ try {
149
+ return context.json(
150
+ CompanyProfileAgentPolicy.parse(
151
+ await updateCompanyProfileAgentPolicy(deps.db, {
152
+ accountId: access.grant.accountId,
153
+ workspaceId,
154
+ actorSubjectId: access.grant.subjectId,
155
+ ...request,
156
+ }),
157
+ ),
158
+ );
159
+ } catch (error) {
160
+ return profileError(context, error);
161
+ }
162
+ });
163
+
100
164
  app.get(base, async (context) => {
101
165
  const workspaceId = context.req.param("workspaceId");
102
166
  const grant = await requireAccessGrant(context, deps, workspaceId, "workspace:read");
@@ -68,6 +68,7 @@ import {
68
68
  BrowserControlServerUnsupportedError,
69
69
  BrowserControlTransportError,
70
70
  BrowserControlUnsupportedError,
71
+ ComputerFrameEvidenceMismatchError,
71
72
  buildSelfhostedBackendSession,
72
73
  buildStreamUrl,
73
74
  exposedPortEndpointFromUrl,
@@ -75,8 +76,11 @@ import {
75
76
  NatsControlRpc,
76
77
  NatsOpStreamTransport,
77
78
  provisionBrowserControlClient,
79
+ validateComputerControlFrameEvidence,
78
80
  renewSandboxProviderExpiration,
79
81
  type BrowserControlPlacementSession,
82
+ type ComputerControlFrame,
83
+ type ExpectedComputerFrameEvidence,
80
84
  } from "@opengeni/runtime/sandbox";
81
85
  import type { Context, Hono } from "hono";
82
86
  import { HTTPException } from "hono/http-exception";
@@ -101,7 +105,11 @@ import {
101
105
  createInteractionFrameProxyAttachment,
102
106
  placementUsesInteractionFrameProxy,
103
107
  } from "../interaction-frame-proxy";
104
- import { observeComputerActionResult, observeLifecycleResult } from "../interaction-metrics";
108
+ import {
109
+ observeComputerActionResult,
110
+ observeComputerFrameEvidenceMismatch,
111
+ observeLifecycleResult,
112
+ } from "../interaction-metrics";
105
113
  import { withChannelA, withChannelARead, type ChannelAOperation } from "../sandbox/channel-a";
106
114
 
107
115
  type ComputerPlacement = {
@@ -111,6 +119,59 @@ type ComputerPlacement = {
111
119
  lease: LeaseSnapshot | null;
112
120
  };
113
121
 
122
+ const MODEL_COMPUTER_FRAME_MAX_BYTES = 256 * 1024;
123
+
124
+ type ComputerFrameCaptureClient = {
125
+ capture(
126
+ targetId: string,
127
+ options: {
128
+ format?: "jpeg" | "png";
129
+ quality?: number;
130
+ maxWidth?: number;
131
+ maxHeight?: number;
132
+ },
133
+ ): Promise<ComputerControlFrame>;
134
+ };
135
+
136
+ export function validateComputerFrameForApi(
137
+ frame: Pick<ComputerControlFrame, "data" | "mediaType" | "metadataHeader">,
138
+ expected: ExpectedComputerFrameEvidence,
139
+ ): ComputerControlFrame {
140
+ return validateComputerControlFrameEvidence(frame, expected);
141
+ }
142
+
143
+ export async function captureModelComputerFrame(
144
+ sessionClient: ComputerFrameCaptureClient,
145
+ expected: ExpectedComputerFrameEvidence,
146
+ ): Promise<ComputerControlFrame> {
147
+ let captured = validateComputerFrameForApi(
148
+ await sessionClient.capture(expected.targetId, {
149
+ format: "jpeg",
150
+ quality: 55,
151
+ maxWidth: 1_024,
152
+ maxHeight: 768,
153
+ }),
154
+ expected,
155
+ );
156
+ if (captured.data.byteLength > MODEL_COMPUTER_FRAME_MAX_BYTES) {
157
+ captured = validateComputerFrameForApi(
158
+ await sessionClient.capture(expected.targetId, {
159
+ format: "jpeg",
160
+ quality: 30,
161
+ maxWidth: 640,
162
+ maxHeight: 480,
163
+ }),
164
+ expected,
165
+ );
166
+ }
167
+ if (captured.data.byteLength > MODEL_COMPUTER_FRAME_MAX_BYTES) {
168
+ throw new BrowserControlProtocolError(
169
+ "computer screenshot could not honor the model image byte bound",
170
+ );
171
+ }
172
+ return captured;
173
+ }
174
+
114
175
  /** Public ComputerSession resource surface. Physical app/window authority stays
115
176
  * in the same placement controller used by BrowserSession; this route owns only
116
177
  * durable authorization, placement fencing, lifecycle receipts, and routing. */
@@ -389,6 +450,47 @@ export function registerComputerSessionRoutes(app: Hono, deps: ApiRouteDeps): vo
389
450
  },
390
451
  );
391
452
 
453
+ app.get(
454
+ "/v1/workspaces/:workspaceId/computer-sessions/:computerSessionId/targets/:targetId/screenshot",
455
+ async (context) => {
456
+ const { workspaceId, grant, computerSessionId } = await routePreamble(
457
+ context,
458
+ "sessions:read",
459
+ );
460
+ const targetId = requireOpaqueParam(context, "targetId");
461
+ const frame = await withActiveComputerController(
462
+ context,
463
+ grant,
464
+ workspaceId,
465
+ computerSessionId,
466
+ "session.read",
467
+ "computer.read",
468
+ async ({ sessionClient, binding }) => {
469
+ try {
470
+ return await captureModelComputerFrame(sessionClient, {
471
+ computerSessionId,
472
+ controllerGeneration: binding.controllerGeneration,
473
+ targetId,
474
+ });
475
+ } catch (error) {
476
+ if (error instanceof ComputerFrameEvidenceMismatchError) {
477
+ observeComputerFrameEvidenceMismatch(deps.observability, error.reason);
478
+ }
479
+ throw error;
480
+ }
481
+ },
482
+ );
483
+ return new Response(frame.data.slice().buffer, {
484
+ status: 200,
485
+ headers: {
486
+ "cache-control": "no-store",
487
+ "content-type": frame.mediaType,
488
+ "x-opengeni-computer-frame": frame.metadataHeader,
489
+ },
490
+ });
491
+ },
492
+ );
493
+
392
494
  app.get(
393
495
  "/v1/workspaces/:workspaceId/computer-sessions/:computerSessionId/clipboard",
394
496
  async (context) => {
@@ -66,7 +66,7 @@ export function registerIntegrationFacetRoutes(app: Hono, deps: ApiRouteDeps): v
66
66
  c,
67
67
  deps,
68
68
  workspaceId,
69
- "workspace:admin",
69
+ "capabilities:manage",
70
70
  );
71
71
  const { grant } = authorization;
72
72
  try {
@@ -82,7 +82,10 @@ export function registerIntegrationFacetRoutes(app: Hono, deps: ApiRouteDeps): v
82
82
  payload: await c.req.json(),
83
83
  canManageOrganizationDestination:
84
84
  authorization.accountGrant?.permissions.includes("account:admin") === true,
85
- canManageWorkspaceDestination: hasPermission(grant.permissions, "workspace:admin"),
85
+ canManageWorkspaceDestination: hasPermission(
86
+ grant.permissions,
87
+ "capabilities:manage",
88
+ ),
86
89
  canManagePersonalDestination:
87
90
  authorization.contextIntegrity &&
88
91
  authorization.authenticatedSubjectId === grant.subjectId,
@@ -122,7 +125,7 @@ export function registerIntegrationFacetRoutes(app: Hono, deps: ApiRouteDeps): v
122
125
  "/v1/workspaces/:workspaceId/integrations/:capabilityId/instances/:instanceKey/facets/:facetKey",
123
126
  async (c) => {
124
127
  const workspaceId = c.req.param("workspaceId");
125
- const grant = await requireAccessGrant(c, deps, workspaceId, "workspace:admin");
128
+ const grant = await requireAccessGrant(c, deps, workspaceId, "capabilities:manage");
126
129
  const payload = UpsertIntegrationFacetRequest.parse(await c.req.json());
127
130
  const capabilityId = decoded(c.req.param("capabilityId"));
128
131
  const instanceKey = decoded(c.req.param("instanceKey"));
@@ -204,7 +207,7 @@ export function registerIntegrationFacetRoutes(app: Hono, deps: ApiRouteDeps): v
204
207
  `/v1/workspaces/:workspaceId/integrations/:capabilityId/instances/:instanceKey/facets/:facetKey/${action}`,
205
208
  async (c) => {
206
209
  const workspaceId = c.req.param("workspaceId");
207
- const grant = await requireAccessGrant(c, deps, workspaceId, "workspace:admin");
210
+ const grant = await requireAccessGrant(c, deps, workspaceId, "capabilities:manage");
208
211
  const payload = MutateIntegrationFacetRequest.parse(await c.req.json());
209
212
  try {
210
213
  return c.json(
@@ -233,7 +236,7 @@ export function registerIntegrationFacetRoutes(app: Hono, deps: ApiRouteDeps): v
233
236
  "/v1/workspaces/:workspaceId/integrations/:capabilityId/instances/:instanceKey/facets/:facetKey",
234
237
  async (c) => {
235
238
  const workspaceId = c.req.param("workspaceId");
236
- const grant = await requireAccessGrant(c, deps, workspaceId, "workspace:admin");
239
+ const grant = await requireAccessGrant(c, deps, workspaceId, "capabilities:manage");
237
240
  const payload = MutateIntegrationFacetRequest.parse(await c.req.json());
238
241
  try {
239
242
  return c.json(
@@ -58,7 +58,11 @@ import {
58
58
  import { publishDurableSessionEvents } from "@opengeni/events";
59
59
  import type { Context, Hono } from "hono";
60
60
  import { HTTPException } from "hono/http-exception";
61
- import { sseWorkspaceInteractionRevisionStream, sseWorkspaceLiveStream } from "../http/sse";
61
+ import {
62
+ browserSseDeliveryOptions,
63
+ sseWorkspaceInteractionRevisionStream,
64
+ sseWorkspaceLiveStream,
65
+ } from "../http/sse";
62
66
  import { observeInterventionMutation } from "../interaction-metrics";
63
67
 
64
68
  export function registerInteractionResourceRoutes(app: Hono, deps: ApiRouteDeps): void {
@@ -74,6 +78,7 @@ export function registerInteractionResourceRoutes(app: Hono, deps: ApiRouteDeps)
74
78
  nonnegativeSafeIntegerQuery(context, "interactionAfter", 0),
75
79
  context.req.raw.signal,
76
80
  {
81
+ ...browserSseDeliveryOptions(context.req.query("transport")),
77
82
  observability: deps.observability,
78
83
  actorEpoch: getManagedAuthRequestActorEpoch(context.req.raw) ?? undefined,
79
84
  reauthorize: async () => {
@@ -99,6 +104,7 @@ export function registerInteractionResourceRoutes(app: Hono, deps: ApiRouteDeps)
99
104
  after,
100
105
  context.req.raw.signal,
101
106
  {
107
+ ...browserSseDeliveryOptions(context.req.query("transport")),
102
108
  observability: deps.observability,
103
109
  actorEpoch: getManagedAuthRequestActorEpoch(context.req.raw) ?? undefined,
104
110
  reauthorize: async () => {
@@ -7,6 +7,7 @@ import {
7
7
  CompleteManagedAuthLoginTransactionResponse,
8
8
  LogoutManagedAuthLoginSlotRequest,
9
9
  LogoutManagedAuthSessionSetRequest,
10
+ MANAGED_AUTH_TRANSACTION_TTL_SECONDS,
10
11
  MANAGED_AUTH_SESSION_SET_API_CONTRACT_HEADER,
11
12
  MANAGED_AUTH_SESSION_SET_API_CONTRACT_REVISION,
12
13
  ManagedAuthDeepLinkResolution,
@@ -15,6 +16,8 @@ import {
15
16
  ManagedAuthSessionSetErrorCode,
16
17
  ResolveManagedAuthDeepLinkRequest,
17
18
  SelectManagedAuthLoginSlotRequest,
19
+ StartManagedAuthSocialTransactionRequest,
20
+ StartManagedAuthSocialTransactionResponse,
18
21
  type ManagedAuthSessionSetProjection as ManagedAuthSessionSetProjectionType,
19
22
  type ManagedAuthSessionSetErrorCode as ManagedAuthSessionSetErrorCodeType,
20
23
  } from "@opengeni/contracts/managed-auth-session-sets";
@@ -57,11 +60,21 @@ import {
57
60
  mutateManagedAuthSessionSet,
58
61
  } from "@opengeni/db/managed-auth-session-sets";
59
62
  import { ensureManagedAccessForUser, getSession } from "@opengeni/db";
63
+ import { sql } from "drizzle-orm";
60
64
  import type { Context, Hono } from "hono";
61
65
  import { deleteCookie, getCookie, setCookie } from "hono/cookie";
62
66
  import { HTTPException } from "hono/http-exception";
63
67
  import { ApiHttpError } from "../http/api-error";
64
- import type { z } from "zod";
68
+ import { z } from "zod";
69
+
70
+ const ManagedAuthSocialStartReceipt = z
71
+ .object({
72
+ version: z.literal(1),
73
+ requestDigest: z.string().regex(/^[0-9a-f]{64}$/u),
74
+ url: z.string().url().max(4_096),
75
+ stateCookie: z.string().min(1).max(4_096),
76
+ })
77
+ .strict();
65
78
 
66
79
  export function registerManagedAuthSessionSetRoutes(app: Hono, deps: ApiRouteDeps): void {
67
80
  const noStore = async (context: Context, next: () => Promise<void>) => {
@@ -304,6 +317,129 @@ export function registerManagedAuthSessionSetRoutes(app: Hono, deps: ApiRouteDep
304
317
  }
305
318
  });
306
319
 
320
+ app.post("/v1/auth/session-set/transactions/social", async (context) => {
321
+ const body = await bodyAs(context, StartManagedAuthSocialTransactionRequest);
322
+ const { authority, actorEpoch } = await requireMutation(context, deps, body.expectedGeneration);
323
+ const available = requireAvailable(deps);
324
+ if (!managedAuthSocialProviderConfigured(deps, body.provider)) {
325
+ throw managedAuthApiError(404, "managed_authentication_unavailable");
326
+ }
327
+ const transactionSecret = requireTransactionSecret(context, body.transactionId);
328
+ const publicBaseUrl = deps.settings.publicBaseUrl;
329
+ if (!publicBaseUrl) {
330
+ throw managedAuthApiError(503, "managed_authentication_unavailable", {
331
+ retryable: true,
332
+ });
333
+ }
334
+ const callbackURL = new URL("/account-auth", publicBaseUrl);
335
+ callbackURL.searchParams.set("transaction", body.transactionId);
336
+ callbackURL.searchParams.set("social", "complete");
337
+ const errorCallbackURL = new URL(callbackURL);
338
+ errorCallbackURL.searchParams.set("social", "error");
339
+ try {
340
+ const authorityHash = managedAuthSha256(authority);
341
+ const transactionSecretHash = managedAuthSha256(transactionSecret);
342
+ const requestDigest = digest(deps, {
343
+ operation: "social_start",
344
+ authorityHash,
345
+ actorEpoch,
346
+ transactionSecretHash,
347
+ request: body,
348
+ });
349
+ const receiptIdentifier = `opengeni-managed-social-start:${authorityHash}:${body.operationId}`;
350
+ const receipt = await deps.db.transaction(async (transaction) => {
351
+ await transaction.execute(
352
+ sql`select pg_advisory_xact_lock(hashtextextended(${receiptIdentifier}, 0))`,
353
+ );
354
+ const authContext = await available.managedAuth.$context;
355
+ const stored = await authContext.internalAdapter.findVerificationValue(receiptIdentifier);
356
+ if (stored && new Date(stored.expiresAt).getTime() > Date.now()) {
357
+ let parsed: ReturnType<typeof ManagedAuthSocialStartReceipt.safeParse>;
358
+ try {
359
+ parsed = ManagedAuthSocialStartReceipt.safeParse(JSON.parse(stored.value));
360
+ } catch (error) {
361
+ throw new ManagedAuthCompletionOutcomeUnknownError({
362
+ cause: error,
363
+ });
364
+ }
365
+ if (!parsed.success) {
366
+ throw new ManagedAuthCompletionOutcomeUnknownError({
367
+ cause: parsed.error,
368
+ });
369
+ }
370
+ if (parsed.data.requestDigest !== requestDigest) {
371
+ throw new ManagedAuthSessionSetOperationReuseError();
372
+ }
373
+ return parsed.data;
374
+ }
375
+ if (stored) {
376
+ await authContext.internalAdapter.deleteVerificationByIdentifier(receiptIdentifier);
377
+ }
378
+ const result = await available.managedAuth.api.signInSocial({
379
+ body: {
380
+ provider: body.provider,
381
+ disableRedirect: true,
382
+ callbackURL: callbackURL.toString(),
383
+ errorCallbackURL: errorCallbackURL.toString(),
384
+ additionalData: {
385
+ opengeniManagedAuth: {
386
+ version: 1,
387
+ operationId: body.operationId,
388
+ provider: body.provider,
389
+ transactionId: body.transactionId,
390
+ authorityHash,
391
+ transactionSecretHash,
392
+ expectedGeneration: body.expectedGeneration,
393
+ expectedActorEpoch: actorEpoch,
394
+ },
395
+ },
396
+ },
397
+ headers: isolatedManagedAuthHeaders(context.req.raw),
398
+ returnHeaders: true,
399
+ });
400
+ const url = validatedManagedAuthSocialAuthorizationUrl({
401
+ provider: body.provider,
402
+ rawUrl: result.response?.url,
403
+ expectedClientId:
404
+ body.provider === "google"
405
+ ? deps.settings.managedAuthGoogleClientId!
406
+ : deps.settings.managedAuthGithubClientId!,
407
+ expectedCallbackUrl: new URL(
408
+ `/v1/auth/callback/${body.provider}`,
409
+ publicBaseUrl,
410
+ ).toString(),
411
+ });
412
+ const stateCookieName = authContext.createAuthCookie("state").name;
413
+ const stateCookie = setCookieHeaders(result.headers).find((cookie) =>
414
+ cookie.startsWith(`${stateCookieName}=`),
415
+ );
416
+ const parsed = ManagedAuthSocialStartReceipt.parse({
417
+ version: 1,
418
+ requestDigest,
419
+ url,
420
+ stateCookie,
421
+ });
422
+ const created = await authContext.internalAdapter.createVerificationValue({
423
+ identifier: receiptIdentifier,
424
+ value: JSON.stringify(parsed),
425
+ expiresAt: new Date(Date.now() + MANAGED_AUTH_TRANSACTION_TTL_SECONDS * 1_000),
426
+ });
427
+ if (!created) {
428
+ throw new ManagedAuthCompletionOutcomeUnknownError();
429
+ }
430
+ return parsed;
431
+ });
432
+ context.header("set-cookie", receipt.stateCookie, { append: true });
433
+ return jsonWithActorEpoch(
434
+ context,
435
+ actorEpoch,
436
+ StartManagedAuthSocialTransactionResponse.parse({ url: receipt.url }),
437
+ );
438
+ } catch (error) {
439
+ throwHttp(error);
440
+ }
441
+ });
442
+
307
443
  app.delete("/v1/auth/session-set/transactions/:transactionId", async (context) => {
308
444
  const body = await bodyAs(context, CancelManagedAuthLoginTransactionRequest);
309
445
  if (body.transactionId !== context.req.param("transactionId")) invalid();
@@ -449,6 +585,46 @@ export function registerManagedAuthSessionSetRoutes(app: Hono, deps: ApiRouteDep
449
585
  });
450
586
  }
451
587
 
588
+ export function validatedManagedAuthSocialAuthorizationUrl(input: {
589
+ provider: "google" | "github";
590
+ rawUrl: unknown;
591
+ expectedClientId: string;
592
+ expectedCallbackUrl: string;
593
+ }): string {
594
+ if (typeof input.rawUrl !== "string") {
595
+ throw new ManagedAuthCompletionOutcomeUnknownError();
596
+ }
597
+ let url: URL;
598
+ try {
599
+ url = new URL(input.rawUrl);
600
+ } catch (error) {
601
+ throw new ManagedAuthCompletionOutcomeUnknownError({ cause: error });
602
+ }
603
+ const expectedEndpoint =
604
+ input.provider === "google"
605
+ ? { origin: "https://accounts.google.com", pathname: "/o/oauth2/v2/auth" }
606
+ : { origin: "https://github.com", pathname: "/login/oauth/authorize" };
607
+ const exactlyOne = (name: string, expected?: string): boolean => {
608
+ const values = url.searchParams.getAll(name);
609
+ return (
610
+ values.length === 1 && values[0] !== "" && (expected === undefined || values[0] === expected)
611
+ );
612
+ };
613
+ if (
614
+ url.origin !== expectedEndpoint.origin ||
615
+ url.pathname !== expectedEndpoint.pathname ||
616
+ url.username ||
617
+ url.password ||
618
+ url.hash ||
619
+ !exactlyOne("client_id", input.expectedClientId) ||
620
+ !exactlyOne("redirect_uri", input.expectedCallbackUrl) ||
621
+ !exactlyOne("state")
622
+ ) {
623
+ throw new ManagedAuthCompletionOutcomeUnknownError();
624
+ }
625
+ return input.rawUrl;
626
+ }
627
+
452
628
  /**
453
629
  * Better Auth 1.6.26 provider lifecycle routes retained while session-set mode
454
630
  * owns all selected-session reads, enumeration, revocation, and user mutation.
@@ -470,21 +646,42 @@ export function requireManagedAuthProviderRouteAllowed(method: string, pathname:
470
646
  (pathname === "/v1/auth/verify-email" ||
471
647
  pathname === "/v1/auth/error" ||
472
648
  pathname === "/v1/auth/ok" ||
649
+ /^\/v1\/auth\/callback\/(?:google|github)$/.test(pathname) ||
473
650
  /^\/v1\/auth\/reset-password\/[^/]+$/.test(pathname)));
474
651
  if (!allowed) throw managedAuthApiError(409, "provider_route_blocked");
475
652
  }
476
653
 
654
+ function managedAuthSocialProviderConfigured(
655
+ deps: ApiRouteDeps,
656
+ provider: "google" | "github",
657
+ ): boolean {
658
+ return provider === "google"
659
+ ? Boolean(
660
+ deps.settings.managedAuthGoogleClientId && deps.settings.managedAuthGoogleClientSecret,
661
+ )
662
+ : Boolean(
663
+ deps.settings.managedAuthGithubClientId && deps.settings.managedAuthGithubClientSecret,
664
+ );
665
+ }
666
+
477
667
  /** Remove provider bearer/session material and enforce browser-auth cache/cookie policy. */
478
668
  export async function scrubManagedAuthProviderResponse(
479
669
  response: Response,
480
- options: { replacementCookies?: readonly string[] | undefined } = {},
670
+ options: {
671
+ replacementCookies?: readonly string[] | undefined;
672
+ preserveCookieNames?: readonly string[] | undefined;
673
+ } = {},
481
674
  ): Promise<Response> {
482
675
  const contentType = response.headers.get("content-type")?.toLowerCase() ?? "";
483
676
  const headers = new Headers(response.headers);
484
677
  headers.set("cache-control", "no-store");
485
678
  headers.set("pragma", "no-cache");
486
679
  if (options.replacementCookies !== undefined) {
680
+ const preserved = setCookieHeaders(response.headers).filter((cookie) =>
681
+ options.preserveCookieNames?.some((name) => cookie.startsWith(`${name}=`)),
682
+ );
487
683
  headers.delete("set-cookie");
684
+ for (const cookie of preserved) headers.append("set-cookie", cookie);
488
685
  for (const cookie of options.replacementCookies) headers.append("set-cookie", cookie);
489
686
  }
490
687
  let body: BodyInit | null = response.body;