@opengeni/api-router 2.11.3 → 2.12.3-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.
Files changed (66) hide show
  1. package/dist/app.js +1 -1
  2. package/dist/{chunk-5X3XM6CE.js → chunk-4AQMKVQM.js} +17727 -14427
  3. package/dist/chunk-4AQMKVQM.js.map +1 -0
  4. package/dist/connection-ownership.d.ts +5 -1
  5. package/dist/index.js +3 -1
  6. package/dist/index.js.map +1 -1
  7. package/dist/integrations/atlassian.d.ts +4 -0
  8. package/dist/integrations/connect-authority.d.ts +1 -0
  9. package/dist/integrations/connect-callback-return.d.ts +6 -0
  10. package/dist/integrations/fiken.d.ts +7 -0
  11. package/dist/integrations/github-app-connect.d.ts +47 -0
  12. package/dist/integrations/github-installation-proof.d.ts +3 -0
  13. package/dist/integrations/github-lens-connect.d.ts +153 -0
  14. package/dist/integrations/google-drive.d.ts +4 -0
  15. package/dist/integrations/oauth-client.d.ts +4 -0
  16. package/dist/integrations/oauth-return-path.d.ts +1 -0
  17. package/dist/integrations/personal-github.d.ts +2 -0
  18. package/dist/integrations/provider-oauth.d.ts +11 -1
  19. package/dist/integrations/slack-install.d.ts +20 -0
  20. package/dist/integrations/social-oauth.d.ts +2 -0
  21. package/dist/mcp/scheduled-task-view.d.ts +3 -3
  22. package/dist/routes/api-integrations.d.ts +14 -0
  23. package/dist/routes/connect.d.ts +5 -0
  24. package/dist/routes/external-identity-links.d.ts +6 -0
  25. package/dist/routes/host-mcp-bindings.d.ts +5 -0
  26. package/dist/sandbox/rematerialize.d.ts +1 -1
  27. package/dist/workspace-tool-gateway.d.ts +1 -0
  28. package/package.json +19 -19
  29. package/src/app.ts +22 -2
  30. package/src/connection-ownership.ts +14 -1
  31. package/src/index.ts +2 -0
  32. package/src/integrations/atlassian.ts +143 -39
  33. package/src/integrations/connect-authority.ts +2 -0
  34. package/src/integrations/connect-callback-return.ts +86 -0
  35. package/src/integrations/fiken.ts +222 -40
  36. package/src/integrations/github-app-connect.ts +389 -0
  37. package/src/integrations/github-installation-proof.ts +60 -0
  38. package/src/integrations/github-lens-connect.ts +97 -0
  39. package/src/integrations/google-drive.ts +152 -47
  40. package/src/integrations/oauth-client.ts +180 -84
  41. package/src/integrations/oauth-return-path.ts +18 -0
  42. package/src/integrations/personal-github.ts +146 -52
  43. package/src/integrations/provider-oauth.ts +132 -21
  44. package/src/integrations/slack-install.ts +83 -0
  45. package/src/integrations/social-oauth.ts +142 -1
  46. package/src/mcp/documents.ts +1 -1
  47. package/src/mcp/server.ts +64 -17
  48. package/src/routes/api-integrations.ts +74 -64
  49. package/src/routes/connect.ts +1869 -0
  50. package/src/routes/connections.ts +263 -221
  51. package/src/routes/documents.ts +2 -2
  52. package/src/routes/external-identity-links.ts +200 -0
  53. package/src/routes/github.ts +39 -64
  54. package/src/routes/host-mcp-bindings.ts +122 -0
  55. package/src/routes/organization-memberships.ts +23 -0
  56. package/src/routes/organization-sessions.ts +2 -2
  57. package/src/routes/personal-github.ts +8 -1
  58. package/src/routes/pr-review-github.ts +30 -3
  59. package/src/routes/scheduled-tasks.ts +29 -4
  60. package/src/routes/sessions.ts +61 -32
  61. package/src/routes/social.ts +5 -1
  62. package/src/routes/supergrok.ts +90 -3
  63. package/src/routes/workspaces.ts +16 -0
  64. package/src/sandbox/rematerialize.ts +33 -1
  65. package/src/workspace-tool-gateway.ts +136 -24
  66. package/dist/chunk-5X3XM6CE.js.map +0 -1
@@ -7,7 +7,11 @@ import {
7
7
  import { listScheduledTaskRuns, listScheduledTasks } from "@opengeni/db";
8
8
  import type { Hono } from "hono";
9
9
  import { HTTPException } from "hono/http-exception";
10
- import { requireAccessGrant, resolveWorkspaceCatalogSettings } from "@opengeni/core";
10
+ import {
11
+ requireAccessGrant,
12
+ requireAccessGrantAuthorization,
13
+ resolveWorkspaceCatalogSettings,
14
+ } from "@opengeni/core";
11
15
  import { recordWorkspaceUsage, requireLimit } from "@opengeni/core";
12
16
  import type { ApiRouteDeps } from "@opengeni/core";
13
17
  import {
@@ -35,7 +39,13 @@ export function registerScheduledTaskRoutes(app: Hono, deps: ApiRouteDeps): void
35
39
 
36
40
  app.post("/v1/workspaces/:workspaceId/scheduled-tasks", async (c) => {
37
41
  const workspaceId = c.req.param("workspaceId");
38
- const grant = await requireAccessGrant(c, deps, workspaceId, "scheduled_tasks:manage");
42
+ const authorization = await requireAccessGrantAuthorization(
43
+ c,
44
+ deps,
45
+ workspaceId,
46
+ "scheduled_tasks:manage",
47
+ );
48
+ const grant = authorization.grant;
39
49
  const rawPayload = await c.req.json();
40
50
  const parsedPayload = CreateScheduledTaskRequest.safeParse(rawPayload);
41
51
  if (!parsedPayload.success) {
@@ -61,6 +71,7 @@ export function registerScheduledTaskRoutes(app: Hono, deps: ApiRouteDeps): void
61
71
  db,
62
72
  objectStorage,
63
73
  grant,
74
+ authorization,
64
75
  payload,
65
76
  toolsProvided: scheduledTaskToolsProvided(rawPayload),
66
77
  sessionAuthorization: deps.sessionAuthorization,
@@ -103,7 +114,13 @@ export function registerScheduledTaskRoutes(app: Hono, deps: ApiRouteDeps): void
103
114
 
104
115
  app.patch("/v1/workspaces/:workspaceId/scheduled-tasks/:taskId", async (c) => {
105
116
  const workspaceId = c.req.param("workspaceId");
106
- const grant = await requireAccessGrant(c, deps, workspaceId, "scheduled_tasks:manage");
117
+ const authorization = await requireAccessGrantAuthorization(
118
+ c,
119
+ deps,
120
+ workspaceId,
121
+ "scheduled_tasks:manage",
122
+ );
123
+ const grant = authorization.grant;
107
124
  const taskId = c.req.param("taskId");
108
125
  const existing = await requireScheduledTaskForApi(db, workspaceId, taskId);
109
126
  const previous = await captureScheduledTaskRestoreState(db, existing);
@@ -127,6 +144,7 @@ export function registerScheduledTaskRoutes(app: Hono, deps: ApiRouteDeps): void
127
144
  objectStorage,
128
145
  grant,
129
146
  existing,
147
+ authorization,
130
148
  payload,
131
149
  toolsProvided: scheduledTaskToolsProvided(rawPayload),
132
150
  sessionAuthorization: deps.sessionAuthorization,
@@ -151,7 +169,13 @@ export function registerScheduledTaskRoutes(app: Hono, deps: ApiRouteDeps): void
151
169
 
152
170
  app.post("/v1/workspaces/:workspaceId/scheduled-tasks/:taskId/resume", async (c) => {
153
171
  const workspaceId = c.req.param("workspaceId");
154
- const grant = await requireAccessGrant(c, deps, workspaceId, "scheduled_tasks:manage");
172
+ const authorization = await requireAccessGrantAuthorization(
173
+ c,
174
+ deps,
175
+ workspaceId,
176
+ "scheduled_tasks:manage",
177
+ );
178
+ const grant = authorization.grant;
155
179
  const existing = await requireScheduledTaskForApi(db, workspaceId, c.req.param("taskId"));
156
180
  const previous = await captureScheduledTaskRestoreState(db, existing);
157
181
  const catalogSettings = (
@@ -167,6 +191,7 @@ export function registerScheduledTaskRoutes(app: Hono, deps: ApiRouteDeps): void
167
191
  grant,
168
192
  existing,
169
193
  payload: { status: "active" },
194
+ authorization,
170
195
  sessionAuthorization: deps.sessionAuthorization,
171
196
  authorizationSurface: "http",
172
197
  });
@@ -49,9 +49,7 @@ import {
49
49
  SessionEventReadMode,
50
50
  SessionEventLatestClass,
51
51
  SessionEventResultMode,
52
- SessionEndUser,
53
- SESSION_END_USER_ID_MAX_CHARS,
54
- SESSION_END_USER_SOURCE_MAX_CHARS,
52
+ SessionScopeSubjectId,
55
53
  SessionEventSemanticClass,
56
54
  SessionEventType,
57
55
  SessionMcpServerId,
@@ -214,6 +212,10 @@ import {
214
212
  GatewayRealtimeBrokerError,
215
213
  } from "../gateway-realtime";
216
214
  import { createXaiRealtimeConnectionSecret, XaiRealtimeBrokerError } from "../xai-realtime";
215
+ import {
216
+ prepareExternalLinkTurnAdmission,
217
+ externalContinuationCommitAuthorizer,
218
+ } from "@opengeni/core";
217
219
  import { z, ZodError } from "zod";
218
220
  import {
219
221
  runConcurrentChannelAReads,
@@ -240,6 +242,7 @@ import {
240
242
  requireAccessGrant,
241
243
  requireAccessGrantAuthorization,
242
244
  requireFreshAccessGrant,
245
+ hasVerifiedOwningUserAuthorization,
243
246
  requirePermission,
244
247
  requireSessionAuthorization,
245
248
  requireSessionAuthorizationListScope,
@@ -639,6 +642,7 @@ export function registerSessionRoutes(app: Hono, deps: SessionRouteDeps): void {
639
642
  workspaceId,
640
643
  payload,
641
644
  authorization.canonicalManagedHumanSession,
645
+ authorization,
642
646
  ),
643
647
  );
644
648
  } catch (error) {
@@ -700,12 +704,12 @@ export function registerSessionRoutes(app: Hono, deps: SessionRouteDeps): void {
700
704
  ...(query.updatedBefore ? { updatedBefore: query.updatedBefore } : {}),
701
705
  ...(query.createdFrom ? { createdFrom: query.createdFrom } : {}),
702
706
  ...(query.createdBefore ? { createdBefore: query.createdBefore } : {}),
703
- ...(query.endUser ? { endUser: query.endUser } : {}),
707
+ ...(query.scopeSubjectId ? { scopeSubjectId: query.scopeSubjectId } : {}),
704
708
  ...(authorizationScope ? { authorizationScope } : {}),
705
709
  // A managed human's own personal workspace has no membership row, so
706
710
  // the list's removal fence must fall back to the organization-membership
707
- // pointer for the canonical managed-cookie session that owns it only.
708
- personalWorkspaceOwnerException: authorization.canonicalManagedHumanSession,
711
+ // pointer, only with verified native or external owning-user provenance.
712
+ personalWorkspaceOwnerException: hasVerifiedOwningUserAuthorization(authorization),
709
713
  });
710
714
  } catch (error) {
711
715
  if (error instanceof SessionListAccessError) {
@@ -1672,7 +1676,15 @@ export function registerSessionRoutes(app: Hono, deps: SessionRouteDeps): void {
1672
1676
  const workspaceId = c.req.param("workspaceId");
1673
1677
  const sessionId = c.req.param("sessionId");
1674
1678
  const realtimeId = c.req.param("realtimeId");
1675
- const grant = await requireAccessGrant(c, deps, workspaceId, "sessions:control");
1679
+ const authorization = await requireAccessGrantAuthorization(
1680
+ c,
1681
+ deps,
1682
+ workspaceId,
1683
+ "sessions:control",
1684
+ );
1685
+ const grant = authorization.grant;
1686
+ const beforeCommit = externalContinuationCommitAuthorizer(authorization);
1687
+ const captureLinked = prepareExternalLinkTurnAdmission(authorization);
1676
1688
  if (
1677
1689
  !z.string().uuid().safeParse(sessionId).success ||
1678
1690
  !z.string().uuid().safeParse(realtimeId).success
@@ -1688,16 +1700,29 @@ export function registerSessionRoutes(app: Hono, deps: SessionRouteDeps): void {
1688
1700
  });
1689
1701
  }
1690
1702
  try {
1691
- const result = await withWorkspaceSessionActivityRls(db, workspaceId, async (scopedDb) =>
1692
- syncSessionRealtimeLedgerInTransaction(scopedDb, {
1693
- workspaceId,
1694
- sessionId,
1695
- realtimeId,
1696
- ownerSubjectId: grant.subjectId,
1697
- ...parsed.data,
1698
- controlLockTimeoutMs: workspaceControlRequestLockTimeoutMs(),
1699
- }),
1700
- );
1703
+ const result = await withWorkspaceSessionActivityRls(db, workspaceId, async (scopedDb) => {
1704
+ // Acquire origin authority before inference/session locks. A deferred
1705
+ // realtime session has no initial worker turn; capture its exact
1706
+ // linked actor when the ledger actually admits ordinary agent work.
1707
+ await beforeCommit?.(scopedDb as unknown as Database);
1708
+ return syncSessionRealtimeLedgerInTransaction(
1709
+ scopedDb,
1710
+ {
1711
+ workspaceId,
1712
+ sessionId,
1713
+ realtimeId,
1714
+ ownerSubjectId: grant.subjectId,
1715
+ ...parsed.data,
1716
+ controlLockTimeoutMs: workspaceControlRequestLockTimeoutMs(),
1717
+ },
1718
+ captureLinked
1719
+ ? {
1720
+ afterDelegationAdmission: async ({ turnId }) =>
1721
+ captureLinked(scopedDb as unknown as Database, sessionId, turnId),
1722
+ }
1723
+ : {},
1724
+ );
1725
+ });
1701
1726
  await publishRealtimeMutation(grant.accountId, workspaceId, sessionId, result);
1702
1727
  c.header("cache-control", "private, no-store");
1703
1728
  return c.json({ accepted: result.accepted, outbound: result.outbound });
@@ -1733,7 +1758,7 @@ export function registerSessionRoutes(app: Hono, deps: SessionRouteDeps): void {
1733
1758
  subjectId: grant.subjectId,
1734
1759
  sessionId,
1735
1760
  // Same owner-only personal-workspace fallback as the list above.
1736
- personalWorkspaceOwnerException: authorization.canonicalManagedHumanSession,
1761
+ personalWorkspaceOwnerException: hasVerifiedOwningUserAuthorization(authorization),
1737
1762
  ...parsed.data,
1738
1763
  });
1739
1764
  if (!session) {
@@ -1790,7 +1815,7 @@ export function registerSessionRoutes(app: Hono, deps: SessionRouteDeps): void {
1790
1815
  workspaceId,
1791
1816
  subjectId: grant.subjectId,
1792
1817
  sessionId,
1793
- personalWorkspaceOwnerException: authorization.canonicalManagedHumanSession,
1818
+ personalWorkspaceOwnerException: hasVerifiedOwningUserAuthorization(authorization),
1794
1819
  ...parsed.data,
1795
1820
  });
1796
1821
  if (!session) throw new HTTPException(404, { message: "session not found" });
@@ -1840,7 +1865,7 @@ export function registerSessionRoutes(app: Hono, deps: SessionRouteDeps): void {
1840
1865
  workspaceId,
1841
1866
  subjectId: grant.subjectId,
1842
1867
  sessionId,
1843
- personalWorkspaceOwnerException: authorization.canonicalManagedHumanSession,
1868
+ personalWorkspaceOwnerException: hasVerifiedOwningUserAuthorization(authorization),
1844
1869
  ...parsed.data,
1845
1870
  });
1846
1871
  if (!session) throw new HTTPException(404, { message: "session not found" });
@@ -3064,6 +3089,9 @@ export function registerSessionRoutes(app: Hono, deps: SessionRouteDeps): void {
3064
3089
  latencyMode: payload.latencyMode ?? null,
3065
3090
  mcpCredentialUpdates: payload.mcpCredentialUpdates ?? [],
3066
3091
  connectionAuthorities: payload.connectionAuthorities,
3092
+ ...(payload.selectedHostMcpDelegations
3093
+ ? { selectedHostMcpDelegations: payload.selectedHostMcpDelegations }
3094
+ : {}),
3067
3095
  ...(payload.personalResourceAttachment
3068
3096
  ? { personalResourceAttachment: payload.personalResourceAttachment }
3069
3097
  : {}),
@@ -3146,6 +3174,9 @@ export function registerSessionRoutes(app: Hono, deps: SessionRouteDeps): void {
3146
3174
  latencyMode: event.payload.latencyMode ?? null,
3147
3175
  mcpCredentialUpdates: event.payload.mcpCredentialUpdates ?? [],
3148
3176
  connectionAuthorities: event.payload.connectionAuthorities,
3177
+ ...(event.payload.selectedHostMcpDelegations
3178
+ ? { selectedHostMcpDelegations: event.payload.selectedHostMcpDelegations }
3179
+ : {}),
3149
3180
  ...(event.payload.personalResourceAttachment
3150
3181
  ? { personalResourceAttachment: event.payload.personalResourceAttachment }
3151
3182
  : {}),
@@ -4740,7 +4771,7 @@ function sessionListQuery(
4740
4771
  updatedBefore: Date | undefined;
4741
4772
  createdFrom: Date | undefined;
4742
4773
  createdBefore: Date | undefined;
4743
- endUser: SessionEndUser | undefined;
4774
+ scopeSubjectId: SessionScopeSubjectId | undefined;
4744
4775
  hasPageFilters: boolean;
4745
4776
  } {
4746
4777
  const parentSessionId = query.parentSessionId;
@@ -4836,22 +4867,20 @@ function sessionListQuery(
4836
4867
  }
4837
4868
  // The opaque end-user label filter is an exact pair: one half alone is a
4838
4869
  // client error rather than a silently unfiltered list.
4839
- const endUserSource = query.endUserSource;
4840
- const endUserId = query.endUserId;
4841
- if ((endUserSource === undefined) !== (endUserId === undefined)) {
4870
+ if (query.endUserSource !== undefined || query.endUserId !== undefined) {
4842
4871
  throw new HTTPException(400, {
4843
- message: "endUserSource and endUserId must be supplied together",
4872
+ message: "Use canonical scopeSubjectId, not an external-user label",
4844
4873
  });
4845
4874
  }
4846
- let endUser: SessionEndUser | undefined;
4847
- if (endUserSource !== undefined && endUserId !== undefined) {
4848
- const parsedEndUser = SessionEndUser.safeParse({ source: endUserSource, id: endUserId });
4875
+ let scopeSubjectId: SessionScopeSubjectId | undefined;
4876
+ if (query.scopeSubjectId !== undefined) {
4877
+ const parsedEndUser = SessionScopeSubjectId.safeParse(query.scopeSubjectId);
4849
4878
  if (!parsedEndUser.success) {
4850
4879
  throw new HTTPException(400, {
4851
- message: `endUserSource must be 1-${SESSION_END_USER_SOURCE_MAX_CHARS} and endUserId 1-${SESSION_END_USER_ID_MAX_CHARS} well-formed characters`,
4880
+ message: "scopeSubjectId must be a canonical OpenGeni user subject",
4852
4881
  });
4853
4882
  }
4854
- endUser = parsedEndUser.data;
4883
+ scopeSubjectId = parsedEndUser.data;
4855
4884
  }
4856
4885
  const hasPageFilters =
4857
4886
  originSiteId !== undefined ||
@@ -4861,7 +4890,7 @@ function sessionListQuery(
4861
4890
  updatedBefore !== undefined ||
4862
4891
  createdFrom !== undefined ||
4863
4892
  createdBefore !== undefined ||
4864
- endUser !== undefined;
4893
+ scopeSubjectId !== undefined;
4865
4894
  if (pinsOnly && !allowCursor) {
4866
4895
  throw new HTTPException(400, { message: 'pinsOnly requires view="page"' });
4867
4896
  }
@@ -4895,7 +4924,7 @@ function sessionListQuery(
4895
4924
  updatedBefore,
4896
4925
  createdFrom,
4897
4926
  createdBefore,
4898
- endUser,
4927
+ scopeSubjectId,
4899
4928
  hasPageFilters,
4900
4929
  };
4901
4930
  }
@@ -19,6 +19,7 @@ import { requireAccessGrant, requireAccessGrantAuthorization } from "@opengeni/c
19
19
  import {
20
20
  assertPersonalConnectionOwnerPrincipal,
21
21
  isPersonalConnectionOwnerPrincipal,
22
+ requireLegacyOAuthActor,
22
23
  } from "../connection-ownership";
23
24
  import type { ApiRouteDeps } from "@opengeni/core";
24
25
  import { boundedLimit } from "../http/common";
@@ -111,6 +112,7 @@ export function registerSocialRoutes(app: Hono, deps: ApiRouteDeps): void {
111
112
  );
112
113
  // The contract already defaults this request to workspace ownership; an
113
114
  // explicit personal choice still needs a human who can own it.
115
+ requireLegacyOAuthActor(access);
114
116
  const personalOwnershipAllowed = isPersonalConnectionOwnerPrincipal(access);
115
117
  if (payload.ownership === "personal") {
116
118
  assertPersonalConnectionOwnerPrincipal(access);
@@ -140,7 +142,9 @@ export function registerSocialRoutes(app: Hono, deps: ApiRouteDeps): void {
140
142
  requestUrl: c.req.url,
141
143
  },
142
144
  );
143
- return c.redirect(result.redirectTo, 302);
145
+ return result.exactReturn
146
+ ? new Response(null, { status: 302, headers: { location: result.redirectTo } })
147
+ : c.redirect(result.redirectTo, 302);
144
148
  });
145
149
 
146
150
  app.get("/v1/workspaces/:workspaceId/social/posts", async (c) => {
@@ -30,10 +30,19 @@ import {
30
30
  updateXaiRotationSettings,
31
31
  upsertXaiSubscriptionCredential,
32
32
  wakeXaiCapacityWaiters,
33
+ encryptEnvironmentValue,
34
+ decryptEnvironmentValue,
35
+ getWorkspaceGrant,
33
36
  type XaiSubscriptionAccountMetadata,
34
37
  } from "@opengeni/db";
35
38
  import { createSignedState, readSignedState } from "@opengeni/github";
36
- import { getManagedSession, requireAccessGrant, type ApiRouteDeps } from "@opengeni/core";
39
+ import {
40
+ getManagedSession,
41
+ requireAccessGrant,
42
+ requireAccessGrantAuthorization,
43
+ externalActorContinuationForAuthorization,
44
+ type ApiRouteDeps,
45
+ } from "@opengeni/core";
37
46
  import {
38
47
  XAI_CLIENT_VERSION,
39
48
  XaiSubscriptionError,
@@ -50,11 +59,14 @@ import type { Context, Hono } from "hono";
50
59
  import { HTTPException } from "hono/http-exception";
51
60
  import * as z from "zod/v4";
52
61
  import { projectClientModel } from "../model-catalog";
62
+ import { ExternalActorContinuation } from "@opengeni/contracts/external-identities";
63
+ import { requireConnectOwnerAuthority } from "../integrations/connect-authority";
53
64
 
54
65
  type XaiAuthoritySnapshot = XaiProviderAccountAuthoritySnapshotV1;
55
66
  type ManagedCookieHuman = { subjectId: string };
56
67
 
57
68
  type SuperGrokConnectState = {
69
+ externalContinuationEncrypted?: string;
58
70
  workspaceId: string;
59
71
  scope: "workspace" | "user";
60
72
  subjectId: string;
@@ -132,8 +144,28 @@ async function requirePrivateHuman(
132
144
  workspaceId: string,
133
145
  ): Promise<{ accountId: string; subjectId: string }> {
134
146
  if (c.req.header("authorization")) {
147
+ const authorization = await requireAccessGrantAuthorization(
148
+ c,
149
+ deps,
150
+ workspaceId,
151
+ "connections:write",
152
+ );
153
+ const external = externalActorContinuationForAuthorization(authorization);
154
+ if (
155
+ external &&
156
+ authorization.contextIntegrity &&
157
+ external.actor.effectiveSubjectId === authorization.grant.subjectId
158
+ ) {
159
+ // The existing xAI user-pool domain requires an ordinary workspace
160
+ // membership, not just the synthetic Personal-workspace owner grant.
161
+ if (!(await getWorkspaceGrant(deps.db, authorization.grant.subjectId, workspaceId)))
162
+ throw new HTTPException(409, {
163
+ message: "Private SuperGrok accounts require membership in an ordinary workspace",
164
+ });
165
+ return { accountId: authorization.grant.accountId, subjectId: authorization.grant.subjectId };
166
+ }
135
167
  throw new HTTPException(403, {
136
- message: "authorization bearer is not allowed for private SuperGrok accounts",
168
+ message: "Private SuperGrok accounts require a verified owning user",
137
169
  });
138
170
  }
139
171
  const human = await managedCookieHuman(c, deps);
@@ -148,6 +180,10 @@ async function requirePrivateHuman(
148
180
  message: "managed browser identity mismatch",
149
181
  });
150
182
  }
183
+ if (!(await getWorkspaceGrant(deps.db, grant.subjectId, workspaceId)))
184
+ throw new HTTPException(409, {
185
+ message: "Private SuperGrok accounts require membership in an ordinary workspace",
186
+ });
151
187
  return { accountId: grant.accountId, subjectId: grant.subjectId };
152
188
  }
153
189
 
@@ -160,7 +196,9 @@ export async function requireScopeMutation(
160
196
  if (scope === "organization")
161
197
  throw new HTTPException(409, { message: "Manage this subscription in organization settings" });
162
198
  if (scope === "user") {
163
- requireSameOriginBrowserMutation(c, deps);
199
+ // Server-side external-user assertions do not use browser cookies. Ordinary
200
+ // bearers remain rejected by requirePrivateHuman; native CSRF is unchanged.
201
+ if (!c.req.header("authorization")) requireSameOriginBrowserMutation(c, deps);
164
202
  return await requirePrivateHuman(c, deps, workspaceId);
165
203
  }
166
204
  const grant = await requireAccessGrant(c, deps, workspaceId, "workspace:admin");
@@ -509,6 +547,16 @@ export function registerSuperGrokRoutes(app: Hono, deps: ApiRouteDeps): void {
509
547
  const parsed = connectStartBody.safeParse(await c.req.json().catch(() => ({})));
510
548
  if (!parsed.success) throw new HTTPException(400, { message: "invalid SuperGrok scope" });
511
549
  const authority = await requireScopeMutation(c, deps, workspaceId, parsed.data.scope);
550
+ const authorization = await requireAccessGrantAuthorization(
551
+ c,
552
+ deps,
553
+ workspaceId,
554
+ parsed.data.scope === "workspace" ? "workspace:admin" : "connections:write",
555
+ );
556
+ const continuation = externalActorContinuationForAuthorization(authorization);
557
+ const encryptionKey = environmentsEncryptionKeyBytes(deps.settings);
558
+ if (continuation && !encryptionKey)
559
+ throw new HTTPException(503, { message: "Credential encryption is unavailable" });
512
560
  try {
513
561
  const start = await requestXaiDeviceCode({
514
562
  fetch: (deps.xaiFetch ?? fetch) as XaiFetch,
@@ -528,6 +576,14 @@ export function registerSuperGrokRoutes(app: Hono, deps: ApiRouteDeps): void {
528
576
  deviceCode: start.deviceCode,
529
577
  intervalSeconds: start.intervalSeconds,
530
578
  expiresAt,
579
+ ...(continuation
580
+ ? {
581
+ externalContinuationEncrypted: encryptEnvironmentValue(
582
+ encryptionKey!,
583
+ JSON.stringify(continuation),
584
+ ),
585
+ }
586
+ : {}),
531
587
  }),
532
588
  });
533
589
  } catch (error) {
@@ -563,6 +619,30 @@ export function registerSuperGrokRoutes(app: Hono, deps: ApiRouteDeps): void {
563
619
  message: "SuperGrok connect identity changed",
564
620
  });
565
621
  }
622
+ const originKey = environmentsEncryptionKeyBytes(deps.settings);
623
+ const origin =
624
+ state.externalContinuationEncrypted && originKey
625
+ ? ExternalActorContinuation.parse(
626
+ JSON.parse(decryptEnvironmentValue(originKey, state.externalContinuationEncrypted)),
627
+ )
628
+ : null;
629
+ if (state.externalContinuationEncrypted && !origin)
630
+ throw new HTTPException(503, { message: "Connection origin unavailable" });
631
+ const requireOrigin = async () => {
632
+ if (origin)
633
+ await requireConnectOwnerAuthority(
634
+ deps.db,
635
+ {
636
+ accountId: authority.accountId,
637
+ workspaceId,
638
+ subjectId: authority.subjectId,
639
+ externalContinuation: origin,
640
+ },
641
+ state.scope === "workspace" ? "workspace:admin" : "connections:write",
642
+ origin,
643
+ );
644
+ };
645
+ await requireOrigin();
566
646
  if (Math.floor(Date.now() / 1_000) >= state.expiresAt) {
567
647
  return c.json({ status: "expired" as const });
568
648
  }
@@ -579,6 +659,13 @@ export function registerSuperGrokRoutes(app: Hono, deps: ApiRouteDeps): void {
579
659
  phase = "token_identity";
580
660
  const identity = xaiIdentityFromDeviceTokens(poll.tokens);
581
661
  phase = "credential_persist";
662
+ await requireOrigin();
663
+ const liveAuthority = await requireScopeMutation(c, deps, workspaceId, state.scope);
664
+ if (
665
+ liveAuthority.accountId !== authority.accountId ||
666
+ liveAuthority.subjectId !== authority.subjectId
667
+ )
668
+ throw new HTTPException(403, { message: "SuperGrok connection identity changed" });
582
669
  const encryptionKey = environmentsEncryptionKeyBytes(deps.settings);
583
670
  if (!encryptionKey) {
584
671
  throw new HTTPException(500, {
@@ -89,6 +89,8 @@ import {
89
89
  accountScopedApiKeyWorkspaceAuthority,
90
90
  hasPermission,
91
91
  requireAccessContext,
92
+ listExternalActorWorkspaces,
93
+ addExternalWorkspaceMemberForRequest,
92
94
  requireAccessGrant,
93
95
  requireWorkspaceSettingsGrant,
94
96
  requireFreshAccessGrant,
@@ -200,12 +202,25 @@ export function workspaceUpdateRequestsAccountTransfer(value: unknown): boolean
200
202
  }
201
203
 
202
204
  export function registerWorkspaceRoutes(app: Hono, deps: ApiRouteDeps): void {
205
+ app.post("/v1/workspaces/:workspaceId/external-members", async (c) => {
206
+ return c.json(
207
+ await addExternalWorkspaceMemberForRequest(
208
+ c,
209
+ deps,
210
+ c.req.param("workspaceId"),
211
+ await c.req.json(),
212
+ ),
213
+ );
214
+ });
203
215
  app.get("/v1/access/me", async (c) => {
204
216
  return c.json(await requireAccessContext(c, deps));
205
217
  });
206
218
 
207
219
  app.get("/v1/workspaces", async (c) => {
208
220
  const context = await requireAccessContext(c, deps);
221
+ const externalWorkspaces = await listExternalActorWorkspaces(context, deps);
222
+ if (externalWorkspaces !== null)
223
+ return c.json(externalWorkspaces.map((workspace) => Workspace.parse(workspace)));
209
224
  const accountScopedAuthority = accountScopedApiKeyWorkspaceAuthority(context);
210
225
  if (
211
226
  accountScopedAuthority &&
@@ -243,6 +258,7 @@ export function registerWorkspaceRoutes(app: Hono, deps: ApiRouteDeps): void {
243
258
  requireAccountPermission(context, payload.accountId, "workspace:create");
244
259
  try {
245
260
  const existing = await findWorkspaceByExternalIdentity(deps.db, {
261
+ accountId: payload.accountId,
246
262
  externalSource: payload.externalSource,
247
263
  externalId: payload.externalId,
248
264
  });
@@ -32,7 +32,11 @@ import {
32
32
  type EstablishedSandboxSession,
33
33
  type WorkspaceArchiveDescriptor,
34
34
  } from "@opengeni/runtime/sandbox";
35
- import type { ObjectStorage } from "@opengeni/storage";
35
+ import {
36
+ downloadWorkspaceArchiveSpool,
37
+ WorkspaceArchiveStorageError,
38
+ type ObjectStorage,
39
+ } from "@opengeni/storage";
36
40
 
37
41
  function hasWorkspaceArchive(envelope: Record<string, unknown> | null): boolean {
38
42
  const sessionState =
@@ -73,6 +77,14 @@ async function materializeArchiveObjectRef(
73
77
  "workspace archive object storage is not configured",
74
78
  );
75
79
  }
80
+ const descriptor = parseWorkspaceArchiveDescriptor(sessionState.workspaceArchiveMeta);
81
+ if (
82
+ process.platform === "linux" &&
83
+ descriptor?.version === 1 &&
84
+ descriptor.workspace.projection === "sdk_local_archive_v1"
85
+ ) {
86
+ return envelope;
87
+ }
76
88
  return {
77
89
  ...envelope,
78
90
  sessionState: await inlineWorkspaceArchiveForRestore(sessionState, (key) =>
@@ -209,6 +221,26 @@ export async function establishApiSandboxSpawner(input: {
209
221
  established = await establishSandboxSessionFromEnvelope(input.settings, hydrateEnvelope, {
210
222
  sessionId: input.sessionId,
211
223
  recovery: "create-or-restore",
224
+ ...(input.objectStorage
225
+ ? {
226
+ loadHostWorkspaceArchive: async (ref) => {
227
+ try {
228
+ return await downloadWorkspaceArchiveSpool(input.objectStorage!, ref.key, {
229
+ bytes: ref.bytes,
230
+ sha256: ref.sha256,
231
+ });
232
+ } catch (error) {
233
+ if (error instanceof WorkspaceArchiveStorageError) {
234
+ throw new WorkspaceArchiveIntegrityError(error.code, error.message, {
235
+ retryable: error.retryable,
236
+ cause: error,
237
+ });
238
+ }
239
+ throw error;
240
+ }
241
+ },
242
+ }
243
+ : {}),
212
244
  backendOverride: input.backend as never,
213
245
  environment: input.environment,
214
246
  onSandboxCreated: async (created) => {