@opengeni/api-router 0.30.3 → 2.2.0-canary.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (165) hide show
  1. package/dist/api-websocket.d.ts +18 -0
  2. package/dist/app.js +1 -1
  3. package/dist/auth/managed-auth.d.ts +6 -0
  4. package/dist/{chunk-WCXHI3FF.js → chunk-3TP54PPX.js} +26692 -15389
  5. package/dist/chunk-3TP54PPX.js.map +1 -0
  6. package/dist/codemode.d.ts +8 -1
  7. package/dist/company-brain-okf.d.ts +24 -0
  8. package/dist/connected-machine-computer-access.d.ts +11 -0
  9. package/dist/connection-authority-owner.d.ts +59 -0
  10. package/dist/connection-ownership.d.ts +47 -0
  11. package/dist/controller-data-plane.d.ts +16 -0
  12. package/dist/editable-artifact-websocket.d.ts +5 -15
  13. package/dist/editable-artifact-workspace-files.d.ts +1 -1
  14. package/dist/http/api-error.d.ts +10 -0
  15. package/dist/http/cors.d.ts +5 -0
  16. package/dist/http/interaction-control-error.d.ts +13 -0
  17. package/dist/http/sse.d.ts +2 -3
  18. package/dist/index.d.ts +5 -2
  19. package/dist/index.js +326 -82
  20. package/dist/index.js.map +1 -1
  21. package/dist/integrations/atlassian.d.ts +17 -0
  22. package/dist/integrations/fiken.d.ts +7 -2
  23. package/dist/integrations/google-drive.d.ts +18 -0
  24. package/dist/integrations/oauth-client.d.ts +22 -3
  25. package/dist/integrations/oauth-profiles.d.ts +195 -0
  26. package/dist/integrations/personal-github-repositories.d.ts +45 -0
  27. package/dist/integrations/personal-github.d.ts +40 -0
  28. package/dist/integrations/pr-review-provider.d.ts +22 -0
  29. package/dist/integrations/provider-oauth.d.ts +8 -0
  30. package/dist/integrations/slack-app-home.d.ts +24 -0
  31. package/dist/integrations/slack-bot.d.ts +157 -9
  32. package/dist/integrations/slack-interactions.d.ts +39 -5
  33. package/dist/integrations/slack-routing.d.ts +120 -0
  34. package/dist/integrations/social-oauth.d.ts +5 -0
  35. package/dist/interaction-frame-proxy.d.ts +68 -0
  36. package/dist/mcp/company-brain-governed-writes.d.ts +26 -0
  37. package/dist/mcp/company-profile-agent-admin.d.ts +44 -0
  38. package/dist/mcp/documents.d.ts +1 -0
  39. package/dist/mcp/remember.d.ts +28 -0
  40. package/dist/mcp/request-abort.d.ts +19 -0
  41. package/dist/mcp/scheduled-task-view.d.ts +4 -4
  42. package/dist/mcp/server.d.ts +15 -2
  43. package/dist/mcp/session-view.d.ts +4 -0
  44. package/dist/mcp/session-wait.d.ts +137 -0
  45. package/dist/routes/automations.d.ts +4 -0
  46. package/dist/routes/billing.d.ts +5 -0
  47. package/dist/routes/browser-sessions.d.ts +4 -1
  48. package/dist/routes/company-brain.d.ts +3 -0
  49. package/dist/routes/company-profile.d.ts +2 -1
  50. package/dist/routes/connection-authorities.d.ts +3 -0
  51. package/dist/routes/personal-github-git-broker.d.ts +29 -0
  52. package/dist/routes/personal-github.d.ts +3 -0
  53. package/dist/routes/pr-review.d.ts +3 -0
  54. package/dist/routes/sessions.d.ts +1 -0
  55. package/dist/routes/user-resource-authorities.d.ts +3 -0
  56. package/dist/routes/workspace-capture.d.ts +10 -2
  57. package/dist/routes/workspace-learning.d.ts +3 -0
  58. package/dist/routes/workspaces.d.ts +19 -0
  59. package/dist/sandbox/channel-a.d.ts +11 -1
  60. package/dist/sandbox/connection-authority.d.ts +3 -0
  61. package/dist/sandbox/enrollment.d.ts +2 -0
  62. package/dist/sandbox/machines.d.ts +2 -2
  63. package/dist/sandbox/metrics-ingestion.d.ts +10 -20
  64. package/dist/sandbox/viewer.d.ts +24 -1
  65. package/dist/sandbox-file-artifacts.d.ts +11 -0
  66. package/dist/scheduled-task-deletion.d.ts +14 -0
  67. package/dist/slack-reaction-files.d.ts +1 -1
  68. package/dist/temporal-schedule-cleanup.d.ts +1 -0
  69. package/package.json +20 -18
  70. package/src/api-websocket.ts +23 -0
  71. package/src/app.ts +161 -23
  72. package/src/auth/managed-auth.ts +25 -3
  73. package/src/codemode.ts +53 -23
  74. package/src/codex-realtime.ts +8 -2
  75. package/src/company-brain-okf.ts +340 -0
  76. package/src/connected-machine-computer-access.ts +33 -0
  77. package/src/connection-authority-owner.ts +61 -0
  78. package/src/connection-ownership.ts +180 -0
  79. package/src/controller-data-plane.ts +47 -0
  80. package/src/editable-artifact-native-kernel.ts +26 -15
  81. package/src/editable-artifact-office-import.ts +28 -3
  82. package/src/editable-artifact-production.ts +13 -7
  83. package/src/editable-artifact-websocket.ts +11 -18
  84. package/src/editable-artifact-workspace-files.ts +31 -6
  85. package/src/http/api-error.ts +28 -0
  86. package/src/http/auth.ts +4 -0
  87. package/src/http/cors.ts +35 -0
  88. package/src/http/interaction-control-error.ts +164 -0
  89. package/src/http/sse.ts +112 -66
  90. package/src/index.ts +46 -6
  91. package/src/integrations/atlassian.ts +146 -35
  92. package/src/integrations/fiken.ts +102 -22
  93. package/src/integrations/google-drive.ts +301 -92
  94. package/src/integrations/oauth-client.ts +272 -143
  95. package/src/integrations/oauth-profiles.ts +477 -0
  96. package/src/integrations/personal-github-repositories.ts +445 -0
  97. package/src/integrations/personal-github.ts +705 -0
  98. package/src/integrations/pr-review-provider.ts +246 -0
  99. package/src/integrations/provider-oauth.ts +121 -5
  100. package/src/integrations/slack-app-home.ts +300 -0
  101. package/src/integrations/slack-bot.ts +342 -57
  102. package/src/integrations/slack-interactions.ts +1898 -259
  103. package/src/integrations/slack-routing.ts +337 -0
  104. package/src/integrations/social-oauth.ts +25 -0
  105. package/src/interaction-frame-proxy.ts +409 -0
  106. package/src/mcp/company-brain-governed-writes.ts +262 -0
  107. package/src/mcp/company-profile-agent-admin.ts +205 -0
  108. package/src/mcp/documents.ts +37 -6
  109. package/src/mcp/files.ts +16 -1
  110. package/src/mcp/remember.ts +181 -0
  111. package/src/mcp/request-abort.ts +44 -0
  112. package/src/mcp/scheduled-task-view.ts +1 -0
  113. package/src/mcp/server.ts +719 -369
  114. package/src/mcp/session-view.ts +54 -0
  115. package/src/mcp/session-wait.ts +554 -0
  116. package/src/model-catalog.ts +20 -12
  117. package/src/routes/api-integrations.ts +4 -0
  118. package/src/routes/api-keys.ts +1 -0
  119. package/src/routes/automations.ts +534 -0
  120. package/src/routes/billing.ts +57 -1
  121. package/src/routes/browser-sessions.ts +329 -79
  122. package/src/routes/channels.ts +17 -1
  123. package/src/routes/codex.ts +50 -0
  124. package/src/routes/company-brain.ts +367 -0
  125. package/src/routes/company-profile.ts +1 -1
  126. package/src/routes/computer-sessions.ts +320 -93
  127. package/src/routes/connection-authorities.ts +139 -0
  128. package/src/routes/connections.ts +144 -34
  129. package/src/routes/documents.ts +353 -4
  130. package/src/routes/editable-artifacts.ts +11 -3
  131. package/src/routes/enrollments.ts +116 -26
  132. package/src/routes/environments.ts +127 -55
  133. package/src/routes/files.ts +72 -11
  134. package/src/routes/install.ts +82 -28
  135. package/src/routes/integration-facets.ts +29 -0
  136. package/src/routes/interaction-resources.ts +20 -2
  137. package/src/routes/machines.ts +275 -13
  138. package/src/routes/organization-memberships.ts +717 -28
  139. package/src/routes/packs.ts +4 -3
  140. package/src/routes/personal-github-git-broker.ts +785 -0
  141. package/src/routes/personal-github.ts +319 -0
  142. package/src/routes/pr-review.ts +531 -0
  143. package/src/routes/rigs.ts +104 -39
  144. package/src/routes/scheduled-tasks.ts +19 -21
  145. package/src/routes/sessions.ts +775 -38
  146. package/src/routes/social.ts +14 -2
  147. package/src/routes/supergrok.ts +23 -4
  148. package/src/routes/transcription-recordings.ts +8 -2
  149. package/src/routes/user-resource-authorities.ts +138 -0
  150. package/src/routes/workspace-artifacts.ts +4 -1
  151. package/src/routes/workspace-capture.ts +21 -0
  152. package/src/routes/workspace-learning.ts +228 -0
  153. package/src/routes/workspaces.ts +59 -6
  154. package/src/sandbox/auth-callout.ts +38 -12
  155. package/src/sandbox/channel-a.ts +242 -19
  156. package/src/sandbox/connection-authority.ts +3 -0
  157. package/src/sandbox/enrollment.ts +15 -3
  158. package/src/sandbox/machines.ts +186 -64
  159. package/src/sandbox/metrics-ingestion.ts +220 -58
  160. package/src/sandbox/viewer.ts +243 -19
  161. package/src/sandbox-file-artifacts.ts +329 -0
  162. package/src/scheduled-task-deletion.ts +127 -0
  163. package/src/slack-reaction-files.ts +16 -5
  164. package/src/temporal-schedule-cleanup.ts +13 -0
  165. package/dist/chunk-WCXHI3FF.js.map +0 -1
@@ -0,0 +1,180 @@
1
+ import type { ConnectionOwnership } from "@opengeni/contracts";
2
+ import type { AccessGrantAuthorization } from "@opengeni/core";
3
+ import { HTTPException } from "hono/http-exception";
4
+
5
+ /**
6
+ * Who may own a *personal* Connection.
7
+ *
8
+ * Connection setup defaults to a workspace-owned Connection; personal ownership
9
+ * is the explicit "Connect only for me" choice (AGENTS.md, "Connection
10
+ * ownership and executable personal authority are separate from turn
11
+ * initiation"). Only a managed human can own one, for two independent reasons:
12
+ * personal authority executes only through the immutable delegation snapshot
13
+ * frozen on a *human's* causal turn or scheduled task, and
14
+ * `bind_connection_authority` (migration 0256) can mint the `user` authority
15
+ * scope only for a subject holding an active organization membership. A
16
+ * machine-owned personal row therefore lands on the `legacy_user` compatibility
17
+ * lane and can never become a real organization-scoped authority.
18
+ *
19
+ * `principalKind` is the trusted, allow-listed signal: exactly `human_session`
20
+ * passes and every other value - including absent/unknown provenance - fails
21
+ * closed. The delegated-token contract forbids a `human_session` claim from
22
+ * carrying `serviceInitiator` or exact agent-attempt authority, so the claim
23
+ * cannot be combined with machine authority.
24
+ */
25
+
26
+ /**
27
+ * Subject namespaces OpenGeni itself mints for machines. This is deliberately
28
+ * NOT an allow-list of human subjects: `docs/embedding.md` states that
29
+ * `subjectId` "remains opaque to OpenGeni" and that the kind must not be
30
+ * inferred from a subject-id prefix "because the host owns that namespace", so
31
+ * a trusted embedding host legitimately signs `human_session` over an opaque
32
+ * subject that is not `user:`-prefixed. Restricting personal ownership to
33
+ * `user:`/`dev` would break those hosts and would itself be the prefix-based
34
+ * kind inference the embedding contract forbids.
35
+ *
36
+ * The allow-list that actually decides is `principalKind === "human_session"`.
37
+ * This list is defence-in-depth against exactly one residual threat: a holder
38
+ * of the first-party delegation secret signing a human claim over an
39
+ * OpenGeni-minted machine subject.
40
+ *
41
+ * `connection-ownership.test.ts` asserts that every machine subject named in
42
+ * its list is rejected. Note what that does and does not buy: it catches a
43
+ * namespace being *removed* from this constant, and it documents the known
44
+ * machine subjects, but it is a hand-maintained list on both sides, so it
45
+ * cannot fail when a genuinely new machine namespace is introduced elsewhere in
46
+ * the repo. Adding one here is a manual step. That residual gap is acceptable
47
+ * only because this check is not the deciding signal anywhere: a new machine
48
+ * namespace still arrives with a non-`human_session` `principalKind` and is
49
+ * refused on that basis.
50
+ *
51
+ * It is also no longer the sole signal on any OAuth callback: a callback that
52
+ * persists a personal owner requires the HMAC-signed `personalOwnerVerified`
53
+ * state claim minted by a start path that saw the live principal.
54
+ */
55
+ const RESERVED_MACHINE_SUBJECT_NAMESPACES = [
56
+ // packages/core/src/access/index.ts
57
+ "api_key",
58
+ "configured",
59
+ // packages/runtime/src/index.ts (signFirstPartyDelegatedBearer default)
60
+ "worker",
61
+ // packages/runtime/src/sandbox/codemode-authority.ts
62
+ "sandbox",
63
+ // apps/worker/src/activities/scheduled-tasks.ts
64
+ "scheduled_task",
65
+ // packages/db/src/session-queue-commands.ts
66
+ "attempt",
67
+ // Service principals (e.g. governed-learning activation).
68
+ "service",
69
+ ] as const;
70
+
71
+ /** True only for an exact authenticated managed human. Unknown provenance fails closed. */
72
+ export function isPersonalConnectionOwnerPrincipal(access: AccessGrantAuthorization): boolean {
73
+ const { grant } = access;
74
+ return (
75
+ // The anti-substitution invariant `requireConnectionAuthorityOwner` uses for
76
+ // the same question: every context grant agrees on principal kind, delegated
77
+ // flag and service flags, and the selected grant's account resolves to
78
+ // exactly one matching account grant. Without it a grant whose account has
79
+ // no matching account grant (a surviving membership row in an organization
80
+ // where the membership is no longer active) could mint a personal
81
+ // Connection that the sibling helper would refuse.
82
+ access.contextIntegrity &&
83
+ access.authenticatedSubjectId === grant.subjectId &&
84
+ grant.principalKind === "human_session" &&
85
+ !grant.serviceInitiator &&
86
+ !grant.serviceInitiatorContext &&
87
+ isPersonalConnectionOwnerSubject(grant.subjectId)
88
+ );
89
+ }
90
+
91
+ /** Rejects an OpenGeni-minted machine subject; see the namespace list's rationale. */
92
+ export function isPersonalConnectionOwnerSubject(subjectId: string): boolean {
93
+ const separator = subjectId.indexOf(":");
94
+ if (separator < 0) {
95
+ return true;
96
+ }
97
+ const namespace = subjectId.slice(0, separator);
98
+ return !RESERVED_MACHINE_SUBJECT_NAMESPACES.some((reserved) => reserved === namespace);
99
+ }
100
+
101
+ export const PERSONAL_CONNECTION_PRINCIPAL_MESSAGE =
102
+ "personal Connection ownership requires an authenticated human; API keys, configured keys, " +
103
+ "services, and agent attempts can create only workspace-owned Connections";
104
+
105
+ /** Personal-only connectors cannot degrade to workspace ownership, so they say so exactly. */
106
+ export function personalOnlyConnectionPrincipalMessage(label: string): string {
107
+ return (
108
+ `${label} connects only as a personal Connection, which requires an authenticated human; ` +
109
+ "API keys, configured keys, services, and agent attempts cannot own one"
110
+ );
111
+ }
112
+
113
+ /**
114
+ * Rejects a non-human principal before a personal Connection is created.
115
+ * `label` names a personal-only connector, whose message must not suggest a
116
+ * workspace-owned alternative that its provider profile forbids.
117
+ *
118
+ * This shares that helper's core caller-integrity checks, then additionally
119
+ * rejects every reserved machine-subject namespace as defence-in-depth. The
120
+ * sibling guards a self-service authority surface where the caller claims to
121
+ * *be* the owner (403 "not you"), while this one rejects an ownership *value*
122
+ * that is unavailable to the caller, alongside `assertOwnershipAllowed`'s
123
+ * existing 422 convention.
124
+ */
125
+ export function assertPersonalConnectionOwnerPrincipal(
126
+ access: AccessGrantAuthorization,
127
+ label?: string,
128
+ ): void {
129
+ if (isPersonalConnectionOwnerPrincipal(access)) {
130
+ return;
131
+ }
132
+ throw new HTTPException(422, {
133
+ message: label
134
+ ? personalOnlyConnectionPrincipalMessage(label)
135
+ : PERSONAL_CONNECTION_PRINCIPAL_MESSAGE,
136
+ });
137
+ }
138
+
139
+ /** The same fence expressed for a flow that resolves ownership after admission. */
140
+ export function assertConnectionOwnershipAllowedForPrincipal(
141
+ ownership: ConnectionOwnership,
142
+ personalOwnershipAllowed: boolean,
143
+ ): void {
144
+ if (ownership === "personal" && !personalOwnershipAllowed) {
145
+ throw new HTTPException(422, { message: PERSONAL_CONNECTION_PRINCIPAL_MESSAGE });
146
+ }
147
+ }
148
+
149
+ /**
150
+ * The claim a start path mints into its HMAC-signed OAuth state when it has
151
+ * verified the live principal may own a personal Connection.
152
+ *
153
+ * An OAuth callback carries signed state, not a live principal, so it cannot
154
+ * re-evaluate `principalKind`. Requiring this claim makes the callback enforce
155
+ * exactly what the start path decided with the full signal, instead of guessing
156
+ * from the subject's shape. A state minted before this claim existed simply
157
+ * lacks it and fails closed for personal ownership - which is precisely the
158
+ * in-flight rolling-deploy window these fences exist to close.
159
+ */
160
+ export const PERSONAL_OWNER_VERIFIED_STATE_CLAIM = "personalOwnerVerified" as const;
161
+
162
+ /** True only for an explicit boolean `true` claim; absent or malformed fails closed. */
163
+ export function personalOwnerVerifiedInState(payload: Record<string, unknown>): boolean {
164
+ return payload[PERSONAL_OWNER_VERIFIED_STATE_CLAIM] === true;
165
+ }
166
+
167
+ /**
168
+ * Callback-side fence for every path that persists a personal owner. Requires
169
+ * both the signed start-time verification and a non-machine subject.
170
+ */
171
+ export function personalOwnerStateAccepted(state: {
172
+ ownership: ConnectionOwnership;
173
+ subjectId: string;
174
+ personalOwnerVerified: boolean;
175
+ }): boolean {
176
+ if (state.ownership !== "personal") {
177
+ return true;
178
+ }
179
+ return state.personalOwnerVerified && isPersonalConnectionOwnerSubject(state.subjectId);
180
+ }
@@ -1,8 +1,55 @@
1
1
  import {
2
2
  BrowserControlRequestError,
3
3
  BrowserControlTransportError,
4
+ exposedPortAllowsHostFetch,
5
+ exposedPortEndpointFromUrl,
6
+ parseOpenSandboxSignedUriPath,
7
+ signedEndpointNeedsRefresh,
4
8
  } from "@opengeni/runtime/sandbox";
5
9
 
10
+ /** Modal/Daytona/Blaxel tunnels serve browserd at `/`, so a cached
11
+ * controller-only session can host-fetch JSON. OpenSandbox's lifecycle proxy
12
+ * prefixes `/v1/sandboxes/<id>/proxy/<port>` and rewrites Authorization; JSON
13
+ * must stay on an exec-capable in-box curl session. OSEP-0011 signed URIs are
14
+ * Authorization-preserving and host-fetch like a native tunnel, but they are
15
+ * not durable cache keys. */
16
+ export function controllerCacheAllowsHostFetch(url: string): boolean {
17
+ try {
18
+ return exposedPortAllowsHostFetch(exposedPortEndpointFromUrl(url));
19
+ } catch {
20
+ return false;
21
+ }
22
+ }
23
+
24
+ export function isOpenSandboxSignedControllerUrl(url: string): boolean {
25
+ try {
26
+ return parseOpenSandboxSignedUriPath(new URL(url).pathname) !== null;
27
+ } catch {
28
+ return false;
29
+ }
30
+ }
31
+
32
+ /** Signed Channel B URLs expire. Do not treat a lease-cached signed URI as an
33
+ * infinite controller endpoint. Unsigned native `/` tunnels stay cacheable. */
34
+ export function controllerCachedUrlIsUsable(url: string, nowMs = Date.now()): boolean {
35
+ if (!controllerCacheAllowsHostFetch(url)) return false;
36
+ if (!isOpenSandboxSignedControllerUrl(url)) return true;
37
+ try {
38
+ return !signedEndpointNeedsRefresh(new URL(url).pathname, nowMs);
39
+ } catch {
40
+ return false;
41
+ }
42
+ }
43
+
44
+ export function shouldPersistControllerDataPlaneUrl(input: {
45
+ backend: string | null | undefined;
46
+ signedEndpoints: boolean;
47
+ url: string;
48
+ }): boolean {
49
+ if (input.backend === "opensandbox" && input.signedEndpoints) return false;
50
+ return controllerCachedUrlIsUsable(input.url);
51
+ }
52
+
6
53
  /** Prefer a lease-fenced controller endpoint for the idempotent create path.
7
54
  * Only a transport-class failure invalidates the cache and provisions once;
8
55
  * semantic failures belong to the caller and must never be replayed. */
@@ -17,6 +17,13 @@ import {
17
17
  } from "@opengeni/contracts/editable-artifact-causal-frontier";
18
18
  import { decodeEditableArtifactSerializedCommit } from "@opengeni/contracts/editable-artifact-serialized-commit";
19
19
  import { decodeEditableArtifactMutationIntent } from "@opengeni/contracts/editable-artifacts";
20
+ import {
21
+ COMMITTED_TRANSACTION_PROTOCOL_VERSION,
22
+ DOCUMENT_ARTIFACT_MODEL_SCHEMA_VERSION,
23
+ PRESENTATION_ARTIFACT_MODEL_SCHEMA_VERSION,
24
+ SPREADSHEET_ARTIFACT_MODEL_SCHEMA_VERSION,
25
+ SPREADSHEET_COLLABORATION_SNAPSHOT_VERSION,
26
+ } from "@opengeni/contracts/editable-artifact-versions";
20
27
  import {
21
28
  MAX_EDITABLE_ARTIFACT_SNAPSHOT_BYTES,
22
29
  causalFrontiersEqual,
@@ -39,9 +46,6 @@ import {
39
46
  } from "@opengeni/core";
40
47
  import type { BoundedObjectReadPort } from "@opengeni/storage";
41
48
 
42
- const MODEL_SCHEMA_VERSION = 1;
43
- const OPERATION_PROTOCOL_VERSION = 1;
44
- const CRDT_STATE_VERSION = 1;
45
49
  const SNAPSHOT_CHUNK_BYTES = 1024 * 1024;
46
50
 
47
51
  type RuntimeFacadeModule = Readonly<{
@@ -104,7 +108,7 @@ export class NativeEditableArtifactKernelAdapter
104
108
  modality: "spreadsheet" as const,
105
109
  committedTransactionBytes,
106
110
  kernelVersion: this.runtime.buildIdentity,
107
- modelSchemaVersion: MODEL_SCHEMA_VERSION,
111
+ modelSchemaVersion: SPREADSHEET_ARTIFACT_MODEL_SCHEMA_VERSION,
108
112
  });
109
113
  }
110
114
  if (session.modality !== request.modality) {
@@ -116,7 +120,7 @@ export class NativeEditableArtifactKernelAdapter
116
120
  nativeReceiptBytes,
117
121
  resultingStateHash: editableArtifactStateHash(session.stateHash()),
118
122
  kernelVersion: this.runtime.buildIdentity,
119
- modelSchemaVersion: MODEL_SCHEMA_VERSION,
123
+ modelSchemaVersion: modelSchemaVersionFor(request.modality),
120
124
  });
121
125
  } finally {
122
126
  session.dispose();
@@ -188,7 +192,7 @@ export class NativeEditableArtifactKernelAdapter
188
192
  ),
189
193
  stateHash: editableArtifactStateHash(session.stateHash()),
190
194
  coveredHeadSequence: 0 as const,
191
- modelSchemaVersion: MODEL_SCHEMA_VERSION,
195
+ modelSchemaVersion: modelSchemaVersionFor(input.modality),
192
196
  kernelVersion: this.runtime.buildIdentity,
193
197
  };
194
198
  if (session.modality === "spreadsheet") {
@@ -196,8 +200,8 @@ export class NativeEditableArtifactKernelAdapter
196
200
  ...common,
197
201
  modality: "spreadsheet" as const,
198
202
  coveredCausalFrontier: domainFrontier(session.frontier()),
199
- operationProtocolVersion: OPERATION_PROTOCOL_VERSION,
200
- crdtStateVersion: CRDT_STATE_VERSION,
203
+ operationProtocolVersion: COMMITTED_TRANSACTION_PROTOCOL_VERSION,
204
+ crdtStateVersion: SPREADSHEET_COLLABORATION_SNAPSHOT_VERSION,
201
205
  });
202
206
  }
203
207
  return Object.freeze({
@@ -239,7 +243,7 @@ export class NativeEditableArtifactKernelAdapter
239
243
  ),
240
244
  stateHash: editableArtifactStateHash(session.stateHash()),
241
245
  coveredHeadSequence: input.state.artifact.headSequence,
242
- modelSchemaVersion: MODEL_SCHEMA_VERSION,
246
+ modelSchemaVersion: modelSchemaVersionFor(input.state.modality),
243
247
  kernelVersion: this.runtime.buildIdentity,
244
248
  } as const;
245
249
  if (session.modality === "spreadsheet") {
@@ -248,8 +252,8 @@ export class NativeEditableArtifactKernelAdapter
248
252
  ...common,
249
253
  modality: session.modality,
250
254
  coveredCausalFrontier: domainFrontier(session.frontier()),
251
- operationProtocolVersion: OPERATION_PROTOCOL_VERSION,
252
- crdtStateVersion: CRDT_STATE_VERSION,
255
+ operationProtocolVersion: COMMITTED_TRANSACTION_PROTOCOL_VERSION,
256
+ crdtStateVersion: SPREADSHEET_COLLABORATION_SNAPSHOT_VERSION,
253
257
  });
254
258
  }
255
259
  if (session.modality !== input.state.modality) throw new Error("Native modality mismatch");
@@ -310,12 +314,12 @@ export class NativeEditableArtifactKernelAdapter
310
314
  canonicalContentHash,
311
315
  coveredHeadSequence,
312
316
  stateHash,
313
- modelSchemaVersion: MODEL_SCHEMA_VERSION,
317
+ modelSchemaVersion: SPREADSHEET_ARTIFACT_MODEL_SCHEMA_VERSION,
314
318
  kernelVersion: this.runtime.buildIdentity,
315
319
  fullCrdtStateVerified: true as const,
316
320
  coveredCausalFrontier,
317
- operationProtocolVersion: OPERATION_PROTOCOL_VERSION,
318
- crdtStateVersion: CRDT_STATE_VERSION,
321
+ operationProtocolVersion: COMMITTED_TRANSACTION_PROTOCOL_VERSION,
322
+ crdtStateVersion: SPREADSHEET_COLLABORATION_SNAPSHOT_VERSION,
319
323
  ...(replayedTarget ? { replayedTarget } : {}),
320
324
  });
321
325
  }
@@ -339,7 +343,7 @@ export class NativeEditableArtifactKernelAdapter
339
343
  canonicalContentHash,
340
344
  coveredHeadSequence,
341
345
  stateHash,
342
- modelSchemaVersion: MODEL_SCHEMA_VERSION,
346
+ modelSchemaVersion: modelSchemaVersionFor(session.modality),
343
347
  kernelVersion: this.runtime.buildIdentity,
344
348
  nativeRevision,
345
349
  ...(replayedTarget ? { replayedTarget } : {}),
@@ -350,6 +354,13 @@ export class NativeEditableArtifactKernelAdapter
350
354
  }
351
355
  }
352
356
 
357
+ function modelSchemaVersionFor(modality: "spreadsheet" | "document" | "presentation"): 1 | 2 {
358
+ if (modality === "spreadsheet") return SPREADSHEET_ARTIFACT_MODEL_SCHEMA_VERSION;
359
+ return modality === "document"
360
+ ? DOCUMENT_ARTIFACT_MODEL_SCHEMA_VERSION
361
+ : PRESENTATION_ARTIFACT_MODEL_SCHEMA_VERSION;
362
+ }
363
+
353
364
  function assertRuntime(
354
365
  candidate: unknown,
355
366
  location: ConfiguredArtifactRuntimeLocation,
@@ -22,6 +22,7 @@ import { getFilesForSubject } from "@opengeni/db";
22
22
  import {
23
23
  MAX_BOUNDED_OBJECT_CHUNK_BYTES,
24
24
  type BoundedImmutableObjectWritePort,
25
+ retryWhileMissing,
25
26
  type ObjectStorage,
26
27
  } from "@opengeni/storage";
27
28
  import type { Database } from "@opengeni/db";
@@ -200,7 +201,19 @@ async function readVerifiedWorkspaceFile(
200
201
  signal?: AbortSignal,
201
202
  ): Promise<Uint8Array> {
202
203
  throwIfAborted(signal);
203
- const before = await storage.headFile(file);
204
+ const before = await retryWhileMissing(async () => {
205
+ try {
206
+ return await storage.headFile(file);
207
+ } catch (error) {
208
+ if (typeof storage.fileExists === "function" && !(await storage.fileExists(file))) {
209
+ return null;
210
+ }
211
+ throw error;
212
+ }
213
+ });
214
+ if (!before) {
215
+ throw new EditableArtifactOfficeImportError("source_changed");
216
+ }
204
217
  if (
205
218
  before.ContentLength !== file.sizeBytes ||
206
219
  before.ContentType !== file.contentType ||
@@ -215,14 +228,26 @@ async function readVerifiedWorkspaceFile(
215
228
  for (let start = 0; start < file.sizeBytes; start += MAX_BOUNDED_OBJECT_CHUNK_BYTES) {
216
229
  throwIfAborted(signal);
217
230
  const end = Math.min(file.sizeBytes, start + MAX_BOUNDED_OBJECT_CHUNK_BYTES) - 1;
218
- const chunk = await storage.getFileRange(file, { start, end });
231
+ const chunk = await retryWhileMissing(async () => storage.getFileRange(file, { start, end }));
219
232
  if (!chunk || chunk.byteLength !== end - start + 1) {
220
233
  throw new EditableArtifactOfficeImportError("source_changed");
221
234
  }
222
235
  bytes.set(chunk, start);
223
236
  digest.update(chunk);
224
237
  }
225
- const after = await storage.headFile(file);
238
+ const after = await retryWhileMissing(async () => {
239
+ try {
240
+ return await storage.headFile(file);
241
+ } catch (error) {
242
+ if (typeof storage.fileExists === "function" && !(await storage.fileExists(file))) {
243
+ return null;
244
+ }
245
+ throw error;
246
+ }
247
+ });
248
+ if (!after) {
249
+ throw new EditableArtifactOfficeImportError("source_changed");
250
+ }
226
251
  const sha256 = digest.digest("hex");
227
252
  if (
228
253
  after.ContentLength !== before.ContentLength ||
@@ -30,7 +30,6 @@ import {
30
30
  type EditableArtifactMaterializationProfilePort,
31
31
  } from "@opengeni/core/editable-artifacts";
32
32
  import {
33
- EDITABLE_ARTIFACT_LIVE_PROTOCOL_VERSION,
34
33
  EditableArtifactApplication,
35
34
  EditableArtifactCompatibilityError,
36
35
  EditableArtifactLiveServer,
@@ -41,6 +40,7 @@ import {
41
40
  type EditableArtifactLiveAuthorizationInvalidationPort,
42
41
  type EditableArtifactLiveCompatibilityPort,
43
42
  } from "@opengeni/core/editable-artifact-live";
43
+ import { currentEditableArtifactCompatibility } from "@opengeni/contracts/editable-artifacts";
44
44
  import {
45
45
  PostgresEditableArtifactLiveReadStore,
46
46
  PostgresEditableArtifactLiveTicketStore,
@@ -194,10 +194,14 @@ export async function createStandaloneEditableArtifactApplication(input: {
194
194
  assertCompatible(
195
195
  request: Parameters<EditableArtifactLiveCompatibilityPort["assertCompatible"]>[0],
196
196
  ) {
197
+ const current = currentEditableArtifactCompatibility(request.artifact.modality);
197
198
  if (
198
- request.protocolVersion !== EDITABLE_ARTIFACT_LIVE_PROTOCOL_VERSION ||
199
- request.modelSchemaVersion !== 1 ||
200
- request.kernelVersion !== runtime.buildIdentity
199
+ request.modality !== request.artifact.modality ||
200
+ request.liveProtocolVersion !== current.liveProtocolVersion ||
201
+ request.modelSchemaVersion !== current.modelSchemaVersion ||
202
+ request.snapshotVersion !== current.snapshotVersion ||
203
+ request.commandProtocolVersion !== current.commandProtocolVersion ||
204
+ request.committedTransactionProtocolVersion !== current.committedTransactionProtocolVersion
201
205
  ) {
202
206
  throw new EditableArtifactCompatibilityError();
203
207
  }
@@ -334,14 +338,16 @@ class RandomEditableArtifactDurableExportIds implements EditableArtifactDurableE
334
338
  function materializationProfiles(
335
339
  capabilities: EditableArtifactNativeMaterializerCapabilities,
336
340
  ): EditableArtifactMaterializationProfilePort {
341
+ const spreadsheetCompatibility = currentEditableArtifactCompatibility("spreadsheet");
337
342
  if (
338
343
  capabilities.protocol !== "OGAMC001" ||
339
344
  capabilities.runtimeKind !== "native" ||
340
345
  capabilities.maxOutputBytes !== EDITABLE_ARTIFACT_EXPORT_MAX_DOWNLOAD_BYTES ||
341
346
  capabilities.codecVersions["opengeni.xlsx"] === undefined ||
342
- !capabilities.supportedModelSchemaVersions.includes(1) ||
343
- !capabilities.supportedOperationProtocolVersions.includes(1) ||
344
- !capabilities.supportedSnapshotProtocolVersions.includes(1)
347
+ capabilities.supportedModelSchemaVersions[0] !== spreadsheetCompatibility.modelSchemaVersion ||
348
+ capabilities.supportedOperationProtocolVersions[0] !==
349
+ spreadsheetCompatibility.committedTransactionProtocolVersion ||
350
+ capabilities.supportedSnapshotProtocolVersions[0] !== spreadsheetCompatibility.snapshotVersion
345
351
  ) {
346
352
  throw new Error("Verified artifact materializer profile is incompatible");
347
353
  }
@@ -9,31 +9,22 @@ import {
9
9
  type EditableArtifactLiveSession,
10
10
  type EditableArtifactLiveSinkPort,
11
11
  } from "@opengeni/core";
12
+ import type {
13
+ ApiWebSocketConnection,
14
+ ApiWebSocketLike,
15
+ ApiWebSocketUpgradeServer,
16
+ } from "./api-websocket";
12
17
 
13
18
  export const EDITABLE_ARTIFACT_LIVE_WEBSOCKET_PATH = "/v1/editable-artifacts/live";
14
- export const EDITABLE_ARTIFACT_LIVE_WEBSOCKET_PROTOCOL = "opengeni-artifact-v1";
19
+ export const EDITABLE_ARTIFACT_LIVE_WEBSOCKET_PROTOCOL = "opengeni-artifact-v2";
15
20
  export const EDITABLE_ARTIFACT_LIVE_WEBSOCKET_MAX_MESSAGE_BYTES = 8 * 1024 * 1024 + 64 * 1024;
16
21
 
17
22
  const MAX_QUEUED_MESSAGES = 64;
18
23
  const MAX_QUEUED_BYTES = 12 * 1024 * 1024;
19
24
 
20
- export type EditableArtifactWebSocketLike = Readonly<{
21
- data: EditableArtifactWebSocketConnection;
22
- send(data: Uint8Array, compress?: boolean): number;
23
- close(code?: number, reason?: string): void;
24
- readonly bufferedAmount?: number;
25
- getBufferedAmount?(): number;
26
- }>;
25
+ export type EditableArtifactWebSocketLike = ApiWebSocketLike;
27
26
 
28
- export type EditableArtifactWebSocketUpgradeServer = Readonly<{
29
- upgrade(
30
- request: Request,
31
- options: Readonly<{
32
- data: EditableArtifactWebSocketConnection;
33
- headers: HeadersInit;
34
- }>,
35
- ): boolean;
36
- }>;
27
+ export type EditableArtifactWebSocketUpgradeServer = ApiWebSocketUpgradeServer;
37
28
 
38
29
  export type EditableArtifactWebSocketHandler = Readonly<{
39
30
  open(socket: EditableArtifactWebSocketLike): void;
@@ -81,7 +72,9 @@ export class EditableArtifactWebSocketTransport {
81
72
  }
82
73
 
83
74
  /** One socket, one consumed ticket, one stream epoch. */
84
- export class EditableArtifactWebSocketConnection implements EditableArtifactLiveSinkPort {
75
+ export class EditableArtifactWebSocketConnection
76
+ implements EditableArtifactLiveSinkPort, ApiWebSocketConnection
77
+ {
85
78
  private socket: EditableArtifactWebSocketLike | null = null;
86
79
  private session: EditableArtifactLiveSession | null = null;
87
80
  private work: Promise<void> = Promise.resolve();
@@ -5,7 +5,7 @@ import {
5
5
  type EditableArtifactDurableExportService,
6
6
  } from "@opengeni/core/editable-artifacts";
7
7
  import { completeFileUpload, prepareGeneratedWorkspaceFile, type Database } from "@opengeni/db";
8
- import type { ObjectHead, ObjectStorage } from "@opengeni/storage";
8
+ import { retryWhileMissing, type ObjectHead, type ObjectStorage } from "@opengeni/storage";
9
9
 
10
10
  const UPLOAD_INTENT_TTL_MS = 60 * 60_000;
11
11
  const UUID_PATTERN = /^[0-9a-f]{8}-[0-9a-f]{4}-[1-8][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/u;
@@ -81,7 +81,7 @@ export class EditableArtifactWorkspaceFileAdapter implements EditableArtifactAge
81
81
  if (existing) {
82
82
  assertStoredExport(existing, download.byteSize, download.mimeType, sha256);
83
83
  } else {
84
- await storage.putObjectStreamIfAbsent({
84
+ const created = await storage.putObjectStreamIfAbsent({
85
85
  key: objectKey,
86
86
  contentType: download.mimeType,
87
87
  chunks: download.chunks(input.signal ? { signal: input.signal } : {}),
@@ -89,18 +89,28 @@ export class EditableArtifactWorkspaceFileAdapter implements EditableArtifactAge
89
89
  sha256,
90
90
  ...(input.signal ? { signal: input.signal } : {}),
91
91
  });
92
- const stored = await storage.headObject(objectKey);
93
- assertStoredExport(stored, download.byteSize, download.mimeType, sha256);
92
+ if (!created) {
93
+ await assertVisibleExport(
94
+ storage,
95
+ objectKey,
96
+ download.byteSize,
97
+ download.mimeType,
98
+ sha256,
99
+ input.signal,
100
+ );
101
+ }
94
102
  }
95
103
  await download.assertUnchanged(input.signal);
96
104
  throwIfAborted(input.signal);
97
105
  file = await this.completeFile(this.dependencies.db, input.scope.workspaceId, uploadId);
98
106
  } else {
99
- assertStoredExport(
100
- await storage.headObject(objectKey),
107
+ await assertVisibleExport(
108
+ storage,
109
+ objectKey,
101
110
  download.byteSize,
102
111
  download.mimeType,
103
112
  sha256,
113
+ input.signal,
104
114
  );
105
115
  await download.assertUnchanged(input.signal);
106
116
  }
@@ -133,6 +143,21 @@ export class EditableArtifactWorkspaceFileAdapter implements EditableArtifactAge
133
143
  }
134
144
  }
135
145
 
146
+ async function assertVisibleExport(
147
+ storage: ObjectStorage,
148
+ objectKey: string,
149
+ byteSize: number,
150
+ contentType: string,
151
+ sha256: string,
152
+ signal?: AbortSignal,
153
+ ): Promise<void> {
154
+ const head = await retryWhileMissing(
155
+ async () => await storage.headObject!(objectKey),
156
+ signal ? { signal } : {},
157
+ );
158
+ assertStoredExport(head, byteSize, contentType, sha256);
159
+ }
160
+
136
161
  function assertStoredExport(
137
162
  head: ObjectHead | null,
138
163
  byteSize: number,
@@ -1,4 +1,5 @@
1
1
  import type { ErrorCode } from "@opengeni/contracts";
2
+ import { WorkspaceControlBusyError } from "@opengeni/db";
2
3
  import type { ContentfulStatusCode } from "hono/utils/http-status";
3
4
  import { HTTPException } from "hono/http-exception";
4
5
 
@@ -6,6 +7,7 @@ type ApiHttpErrorOptions = {
6
7
  code: ErrorCode;
7
8
  message: string;
8
9
  retryable?: boolean;
10
+ outcomeUnknown?: boolean;
9
11
  details?: Record<string, unknown>;
10
12
  };
11
13
 
@@ -13,6 +15,7 @@ type ApiHttpErrorOptions = {
13
15
  export class ApiHttpError extends HTTPException {
14
16
  readonly code: ErrorCode;
15
17
  readonly retryable: boolean | undefined;
18
+ readonly outcomeUnknown: boolean | undefined;
16
19
  readonly details: Record<string, unknown> | undefined;
17
20
 
18
21
  constructor(status: number, options: ApiHttpErrorOptions) {
@@ -20,6 +23,31 @@ export class ApiHttpError extends HTTPException {
20
23
  this.name = "ApiHttpError";
21
24
  this.code = options.code;
22
25
  this.retryable = options.retryable;
26
+ this.outcomeUnknown = options.outcomeUnknown;
23
27
  this.details = options.details;
24
28
  }
25
29
  }
30
+
31
+ /**
32
+ * A request-scoped session/workspace mutation could not enter the workspace
33
+ * control prefix within its bounded wait. The transaction rolled back before
34
+ * any write, so the outcome is known and the client may retry. `app.onError`
35
+ * applies this once for every route; Slack interactions keep the raw typed
36
+ * error so their retry classifier treats it as transient.
37
+ */
38
+ export function workspaceControlBusyHttpError(error: unknown): ApiHttpError | null {
39
+ const busy =
40
+ error instanceof WorkspaceControlBusyError
41
+ ? error
42
+ : error instanceof HTTPException && error.cause instanceof WorkspaceControlBusyError
43
+ ? error.cause
44
+ : null;
45
+ if (!busy) return null;
46
+ return new ApiHttpError(503, {
47
+ code: "upstream_unavailable",
48
+ message: "The workspace is busy applying other session commands; retry shortly.",
49
+ retryable: true,
50
+ outcomeUnknown: false,
51
+ details: { code: busy.code, lockTimeoutMs: busy.lockTimeoutMs },
52
+ });
53
+ }
package/src/http/auth.ts CHANGED
@@ -40,6 +40,9 @@ function isAuthExempt(c: Context, settings: Settings): boolean {
40
40
  if (path === "/v1/webhooks/stripe") {
41
41
  return true;
42
42
  }
43
+ if (c.req.method === "POST" && path.startsWith("/v1/webhooks/automations/")) {
44
+ return true;
45
+ }
43
46
  if (
44
47
  path === "/v1/github/setup" ||
45
48
  path === "/v1/github/install/callback" ||
@@ -51,6 +54,7 @@ function isAuthExempt(c: Context, settings: Settings): boolean {
51
54
  if (
52
55
  path === "/v1/integrations/oauth/callback" ||
53
56
  path === "/v1/integrations/provider-oauth/callback" ||
57
+ path === "/v1/integrations/github-personal/oauth/callback" ||
54
58
  path === "/v1/integrations/google-drive/callback" ||
55
59
  path === "/v1/integrations/oauth/client-metadata.json" ||
56
60
  path === "/v1/integrations/slack/callback" ||