@opengeni/api-router 3.2.2 → 3.4.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 (45) hide show
  1. package/dist/access-grant-rls.d.ts +15 -0
  2. package/dist/app.js +1 -1
  3. package/dist/billing-stripe-customer.d.ts +18 -0
  4. package/dist/billing-subscription-store.d.ts +34 -0
  5. package/dist/billing-subscriptions.d.ts +64 -0
  6. package/dist/{chunk-PQBHZ2YH.js → chunk-6DZ4H5JQ.js} +3133 -1711
  7. package/dist/{chunk-PQBHZ2YH.js.map → chunk-6DZ4H5JQ.js.map} +1 -1
  8. package/dist/index.js +4 -2
  9. package/dist/index.js.map +1 -1
  10. package/dist/integrations/marketplace-plugin.d.ts +43 -43
  11. package/dist/mcp/server.d.ts +11 -1
  12. package/dist/routes/browser-sessions.d.ts +23 -2
  13. package/dist/routes/sessions.d.ts +1 -0
  14. package/dist/sandbox/channel-a.d.ts +10 -1
  15. package/dist/site-publishing/agent-data-tools.d.ts +5 -5
  16. package/dist/site-publishing/agent-session.d.ts +3 -2
  17. package/dist/site-publishing/runtime.d.ts +7 -6
  18. package/dist/site-publishing/service.d.ts +8 -11
  19. package/dist/site-publishing/session-request-authority.d.ts +3 -0
  20. package/package.json +24 -24
  21. package/src/access-grant-rls.ts +99 -10
  22. package/src/app.ts +105 -43
  23. package/src/billing-stripe-customer.ts +40 -0
  24. package/src/billing-subscription-store.ts +121 -0
  25. package/src/billing-subscriptions.ts +759 -0
  26. package/src/http/sse.ts +5 -1
  27. package/src/index.ts +3 -0
  28. package/src/mcp/action-catalog.gen.ts +113 -0
  29. package/src/mcp/action-schema-imports.gen.ts +5 -1
  30. package/src/mcp/server.ts +28 -0
  31. package/src/routes/billing.ts +161 -21
  32. package/src/routes/browser-sessions.ts +420 -26
  33. package/src/routes/computer-sessions.ts +102 -39
  34. package/src/routes/insights-usage.ts +22 -0
  35. package/src/routes/organization-memberships.ts +12 -0
  36. package/src/routes/sessions.ts +30 -5
  37. package/src/routes/site-runtime.ts +62 -20
  38. package/src/sandbox/channel-a.ts +18 -1
  39. package/src/sandbox/metrics-ingestion.ts +1 -0
  40. package/src/site-frame-guard.ts +11 -2
  41. package/src/site-publishing/agent-data-tools.ts +16 -8
  42. package/src/site-publishing/agent-session.ts +24 -6
  43. package/src/site-publishing/runtime.ts +29 -7
  44. package/src/site-publishing/service.ts +65 -77
  45. package/src/site-publishing/session-request-authority.ts +14 -0
@@ -1,6 +1,6 @@
1
1
  import { type ErrorCode, type Permission, type SiteHostMapRecord, type SiteHostnameAvailability, type SitePublication, type UpsertSitePublicationRequest } from "@opengeni/contracts";
2
2
  import type { AccessGrant } from "@opengeni/contracts";
3
- import { type ApiRouteDeps } from "@opengeni/core";
3
+ import { type ApiRouteDeps, type cancelPublishedSiteSession } from "@opengeni/core";
4
4
  import { SitePublishingError, type PublishedSiteRecord, type SiteDataSite, type SiteHostnameEdgeState, type SitePublicationRecord, type SitePublishingScope, type StoredSiteLimitProfile } from "@opengeni/db";
5
5
  import { ApiHttpError } from "../http/api-error.js";
6
6
  import { type SiteContentScanner } from "./content-scanner.js";
@@ -40,13 +40,12 @@ export type SitePublishingEffects = {
40
40
  };
41
41
  /** Background effects running at once per process. */
42
42
  export declare const SITE_EFFECT_CONCURRENCY = 4;
43
+ type SiteCancellation = (input: Parameters<typeof cancelPublishedSiteSession>[1]) => Promise<unknown>;
43
44
  /**
44
- * The app's in-process dispatch, registered with the site-runtime routes, so
45
- * publication changes can cancel a Site's conversations through the ordinary
46
- * session routes. Without it (the operator script) cancellation is skipped.
45
+ * Internal host lifecycle adapter. This never grants the Site service bearer
46
+ * ordinary session access and never impersonates a conversation's viewer.
47
47
  */
48
- export declare function setSitePublishingDispatch(settings: ApiRouteDeps["settings"], dispatch: (request: Request) => Promise<Response>): void;
49
- export declare function sitePublishingDispatch(settings: ApiRouteDeps["settings"]): ((request: Request) => Promise<Response>) | null;
48
+ export declare function setSitePublishingCancellation(settings: ApiRouteDeps["settings"], cancel: SiteCancellation): void;
50
49
  type SitePublishingEffectsOptions = {
51
50
  fetch?: typeof fetch;
52
51
  backoffMs?: number;
@@ -164,11 +163,9 @@ export declare function siteServiceSessionRequest(deps: Pick<SitePublishingDeps,
164
163
  * Stop a Site's viewers after a takedown, unpublish or narrower access:
165
164
  * revoke every site-session now, and after commit cancel the Site's agent
166
165
  * conversations that may still be working (in-flight turns and queued
167
- * messages), as the Site itself through the ordinary control route. The
168
- * gateway refuses new work at once regardless: every site-runtime call
169
- * re-checks the publication. Without an in-process dispatch (the operator
170
- * script) the cancellation is skipped and logged; per-conversation caps
171
- * still bound what running turns can spend.
166
+ * messages), through the native cancel-only lifecycle adapter. The original
167
+ * actor survives queued background execution. No transcript access is granted.
168
+ * Every page is drained, including old conversations; there is no age cutoff.
172
169
  */
173
170
  export declare function stopSiteViewers(deps: SitePublishingDeps, scope: SitePublishingScope, siteId: string, options: {
174
171
  revokeSessions?: boolean;
@@ -0,0 +1,3 @@
1
+ export declare function bindSiteSessionRequestAuthority(request: Request, reauthorize: () => Promise<void>): void;
2
+ /** Periodic SSE reauthorization must check the Site cookie, not only its delegate. */
3
+ export declare function reauthorizeSiteSessionRequest(request: Request): Promise<void>;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@opengeni/api-router",
3
- "version": "3.2.2",
3
+ "version": "3.4.0",
4
4
  "description": "Opengeni HTTP surface: the Hono adapter/router (createApp), routes, MCP HTTP transport, and HTTP access adapters over @opengeni/core. An engine-distribution surface — its runtime closure includes engine-internal packages.",
5
5
  "license": "Apache-2.0",
6
6
  "repository": {
@@ -45,29 +45,29 @@
45
45
  "@keeper-security/secrets-manager-core": "17.6.0",
46
46
  "@llamaindex/liteparse": "2.14.2",
47
47
  "@modelcontextprotocol/sdk": "^1.29.0",
48
- "@opengeni/agent-proto": "3.2.2",
49
- "@opengeni/artifact-tool": "3.2.2",
50
- "@opengeni/capabilities": "3.2.2",
51
- "@opengeni/codemode": "3.2.2",
52
- "@opengeni/codex": "3.2.2",
53
- "@opengeni/config": "3.2.2",
54
- "@opengeni/contracts": "3.2.2",
55
- "@opengeni/core": "3.2.2",
56
- "@opengeni/db": "3.2.2",
57
- "@opengeni/documents": "3.2.2",
58
- "@opengeni/events": "3.2.2",
59
- "@opengeni/github": "3.2.2",
60
- "@opengeni/interaction": "3.2.2",
61
- "@opengeni/network": "3.2.2",
62
- "@opengeni/observability": "3.2.2",
63
- "@opengeni/runtime": "3.2.2",
64
- "@opengeni/storage": "3.2.2",
65
- "@opengeni/tool-gateway": "3.2.2",
66
- "@opengeni/vaults": "3.2.2",
67
- "@opengeni/vaults-keeper": "3.2.2",
68
- "@opengeni/vaults-onepassword": "3.2.2",
69
- "@opengeni/vaults-protonpass": "3.2.2",
70
- "@opengeni/xai-subscription": "3.2.2",
48
+ "@opengeni/agent-proto": "3.4.0",
49
+ "@opengeni/artifact-tool": "3.4.0",
50
+ "@opengeni/capabilities": "3.4.0",
51
+ "@opengeni/codemode": "3.4.0",
52
+ "@opengeni/codex": "3.4.0",
53
+ "@opengeni/config": "3.4.0",
54
+ "@opengeni/contracts": "3.4.0",
55
+ "@opengeni/core": "3.4.0",
56
+ "@opengeni/db": "3.4.0",
57
+ "@opengeni/documents": "3.4.0",
58
+ "@opengeni/events": "3.4.0",
59
+ "@opengeni/github": "3.4.0",
60
+ "@opengeni/interaction": "3.4.0",
61
+ "@opengeni/network": "3.4.0",
62
+ "@opengeni/observability": "3.4.0",
63
+ "@opengeni/runtime": "3.4.0",
64
+ "@opengeni/storage": "3.4.0",
65
+ "@opengeni/tool-gateway": "3.4.0",
66
+ "@opengeni/vaults": "3.4.0",
67
+ "@opengeni/vaults-keeper": "3.4.0",
68
+ "@opengeni/vaults-onepassword": "3.4.0",
69
+ "@opengeni/vaults-protonpass": "3.4.0",
70
+ "@opengeni/xai-subscription": "3.4.0",
71
71
  "@temporalio/client": "^1.17.0",
72
72
  "ajv": "^8.20.0",
73
73
  "better-auth": "^1.6.14",
@@ -49,6 +49,55 @@ export function creditDebitAttributionForGrant(grant: AccessGrant): CreditDebitA
49
49
  : { kind: "unknown" };
50
50
  }
51
51
 
52
+ /**
53
+ * Start `work` alongside an authorization `gate` instead of after it, so a
54
+ * request pays one database wait for both. The gate still decides: when it
55
+ * refuses, its error is the answer and `work`'s result or failure is dropped
56
+ * unread. Only use this for reads whose result is disclosed after the gate.
57
+ */
58
+ export async function alongsideAuthorization<G, W>(
59
+ gate: Promise<G>,
60
+ work: () => Promise<W>,
61
+ ): Promise<[G, W]> {
62
+ const pending = work();
63
+ // Observed here so a refused gate never leaves an unhandled rejection.
64
+ pending.catch(() => undefined);
65
+ const decided = await gate;
66
+ return [decided, await pending];
67
+ }
68
+
69
+ type AgentLearningActor = {
70
+ kind: "agent";
71
+ sessionId: string;
72
+ turnId: string;
73
+ attemptId: string;
74
+ executionGeneration: number;
75
+ };
76
+
77
+ /** The attempt a well-formed agent-attempt grant claims to be; null otherwise. */
78
+ function claimedLearningActor(grant: AccessGrant, sessionId: string): AgentLearningActor | null {
79
+ const turnId = grant.metadata?.turnId;
80
+ const attemptId = grant.metadata?.attemptId;
81
+ const executionGeneration = grant.metadata?.executionGeneration;
82
+ if (
83
+ typeof turnId !== "string" ||
84
+ typeof attemptId !== "string" ||
85
+ typeof executionGeneration !== "number"
86
+ ) {
87
+ return null;
88
+ }
89
+ return { kind: "agent", sessionId, turnId, attemptId, executionGeneration };
90
+ }
91
+
92
+ function sameLearningActor(left: AgentLearningActor, right: AgentLearningActor): boolean {
93
+ return (
94
+ left.sessionId === right.sessionId &&
95
+ left.turnId === right.turnId &&
96
+ left.attemptId === right.attemptId &&
97
+ left.executionGeneration === right.executionGeneration
98
+ );
99
+ }
100
+
52
101
  export async function withAccessGrantSessionRlsContext<T>(
53
102
  deps: Pick<ApiRouteDeps, "db">,
54
103
  grant: AccessGrant,
@@ -63,19 +112,33 @@ export async function withAccessGrantSessionRlsContext<T>(
63
112
  if (typeof callerSessionId !== "string") {
64
113
  throw agentAttemptAuthorityError("malformed");
65
114
  }
66
- try {
67
- const actor = await requireLiveAgentAttemptAuthorization(deps.db, grant, callerSessionId);
68
- const learning = await freezeAgentLearningPolicy(deps.db, {
115
+ const freezeLearning = (actor: AgentLearningActor) =>
116
+ freezeAgentLearningPolicy(deps.db, {
69
117
  accountId: grant.accountId,
70
118
  workspaceId: grant.workspaceId,
71
- actor: {
72
- kind: "agent",
73
- sessionId: actor.callerSessionId,
74
- turnId: actor.turnId,
75
- attemptId: actor.attemptId,
76
- executionGeneration: actor.executionGeneration,
77
- },
119
+ actor,
78
120
  });
121
+ try {
122
+ // The learning policy is keyed by the exact attempt, which a live actor
123
+ // takes verbatim from the grant's claims. Read it for the claimed attempt
124
+ // while the liveness check runs; it is used only once that check passes
125
+ // and names the same attempt, and is otherwise read again for the actor.
126
+ const claimed = claimedLearningActor(grant, callerSessionId);
127
+ const [actor, speculative] = await alongsideAuthorization(
128
+ requireLiveAgentAttemptAuthorization(deps.db, grant, callerSessionId),
129
+ async () => (claimed ? await freezeLearning(claimed) : null),
130
+ );
131
+ const verified: AgentLearningActor = {
132
+ kind: "agent",
133
+ sessionId: actor.callerSessionId,
134
+ turnId: actor.turnId,
135
+ attemptId: actor.attemptId,
136
+ executionGeneration: actor.executionGeneration,
137
+ };
138
+ const learning =
139
+ claimed && speculative && sameLearningActor(claimed, verified)
140
+ ? speculative
141
+ : await freezeLearning(verified);
79
142
  return await withCreditDebitAttribution(
80
143
  {
81
144
  kind: "turn",
@@ -87,6 +150,7 @@ export async function withAccessGrantSessionRlsContext<T>(
87
150
  {
88
151
  subjectId: actor.subjectId,
89
152
  initiatingHumanSubjectId: actor.initiatingHumanSubjectId,
153
+ agentRootSessionId: actor.callerRootSessionId,
90
154
  privateFileOwnerSubjectId:
91
155
  learning.defaultScope === "personal" ? learning.subjectId : null,
92
156
  },
@@ -100,3 +164,28 @@ export async function withAccessGrantSessionRlsContext<T>(
100
164
  throw error;
101
165
  }
102
166
  }
167
+
168
+ /**
169
+ * Prove that an agent-attempt grant is the exact live attempt of its bound
170
+ * session, with the same refusal as {@link withAccessGrantSessionRlsContext},
171
+ * without installing that request's RLS actor context. For requests that read
172
+ * and disclose nothing scoped (MCP protocol handshakes), so they need the
173
+ * liveness fence but none of the actor's data scope.
174
+ */
175
+ export async function requireLiveAgentAttemptGrant(
176
+ deps: Pick<ApiRouteDeps, "db">,
177
+ grant: AccessGrant,
178
+ ): Promise<void> {
179
+ const callerSessionId = grant.metadata?.sessionId;
180
+ if (grant.principalKind !== "agent_attempt" || typeof callerSessionId !== "string") {
181
+ throw agentAttemptAuthorityError("malformed");
182
+ }
183
+ try {
184
+ await requireLiveAgentAttemptAuthorization(deps.db, grant, callerSessionId);
185
+ } catch (error) {
186
+ if (error instanceof SessionAuthorizationDeniedError) {
187
+ throw agentAttemptAuthorityError(error.reason, error);
188
+ }
189
+ throw error;
190
+ }
191
+ }
package/src/app.ts CHANGED
@@ -110,6 +110,7 @@ import {
110
110
  dbSql,
111
111
  getManagedAuthSessionSetSnapshot,
112
112
  getWorkspace,
113
+ getWorkspaceSettings,
113
114
  reapManagedAuthIsolatedSessions,
114
115
  reapExpiredManagedAuthSessionSets,
115
116
  resolveSessionMemoryAgentScope,
@@ -224,7 +225,11 @@ import {
224
225
  assertOrganizationUserSetupQueryTransportConfigured,
225
226
  } from "./auth/organization-user-setup";
226
227
  import { createApiSandboxClient, makeResumeBoxById } from "./sandbox/access";
227
- import { buildOpenGeniMcpServer } from "./mcp/server";
228
+ import {
229
+ buildOpenGeniMcpProtocolServer,
230
+ buildOpenGeniMcpServer,
231
+ isOpenGeniMcpProtocolOnlyMessage,
232
+ } from "./mcp/server";
228
233
  import {
229
234
  buildWorkspaceToolGatewayMcpServer,
230
235
  approveWorkspaceToolGatewayCall,
@@ -273,7 +278,11 @@ import {
273
278
  localBrowserRequestHost,
274
279
  markLocalInternalDispatch,
275
280
  } from "./http/local-browser-boundary";
276
- import { withAccessGrantSessionRlsContext } from "./access-grant-rls";
281
+ import {
282
+ alongsideAuthorization,
283
+ requireLiveAgentAttemptGrant,
284
+ withAccessGrantSessionRlsContext,
285
+ } from "./access-grant-rls";
277
286
  import { registerCapabilityRoutes } from "./routes/capabilities";
278
287
  import { registerCatalogAssetRoutes } from "./routes/catalog-assets";
279
288
  import { registerCodexRoutes } from "./routes/codex";
@@ -1795,6 +1804,28 @@ export function createAppComposition(deps: AppDependencies): {
1795
1804
  throw error;
1796
1805
  }
1797
1806
  const grant = authorization.grant;
1807
+ // MCP handshakes (initialize, ping, notifications) read and disclose
1808
+ // nothing scoped. A session-bound agent attempt still proves it is the
1809
+ // live attempt, but skips the workspace, catalog, Memory and admin reads
1810
+ // that building the tool surface costs on every tools/* POST.
1811
+ if (
1812
+ c.req.method === "POST" &&
1813
+ grant.principalKind === "agent_attempt" &&
1814
+ typeof grant.metadata?.sessionId === "string" &&
1815
+ (await isProtocolOnlyMcpRequest(boundedRequest))
1816
+ ) {
1817
+ await requireLiveAgentAttemptGrant(routeDeps, grant);
1818
+ const transport = new WebStandardStreamableHTTPServerTransport({
1819
+ enableJsonResponse: true,
1820
+ });
1821
+ const mcp = buildOpenGeniMcpProtocolServer(grant);
1822
+ try {
1823
+ await mcp.connect(transport);
1824
+ return await handleMcpRequestWithClientAbort(transport, boundedRequest, c.req.raw.signal);
1825
+ } finally {
1826
+ await mcp.close().catch(() => undefined);
1827
+ }
1828
+ }
1798
1829
  // Admin actions dispatch back into this API as a person. Capture the
1799
1830
  // context now, before this request takes on the agent's database and
1800
1831
  // billing scope, so none of it carries into those actions.
@@ -1804,7 +1835,8 @@ export function createAppComposition(deps: AppDependencies): {
1804
1835
  return await withSessionAuthorizationReadReuse((reads) =>
1805
1836
  withAccessGrantSessionRlsContext(routeDeps, grant, async () => {
1806
1837
  const boundSessionId = grant.metadata?.sessionId;
1807
- if (typeof boundSessionId === "string") {
1838
+ const authorizeBoundSession = async () => {
1839
+ if (typeof boundSessionId !== "string") return;
1808
1840
  try {
1809
1841
  await requireSessionAuthorization(routeDeps, grant, {
1810
1842
  sessionId: boundSessionId,
@@ -1822,16 +1854,14 @@ export function createAppComposition(deps: AppDependencies): {
1822
1854
  }
1823
1855
  throw error;
1824
1856
  }
1825
- }
1826
- if (c.req.method === "GET") return unsupportedGet();
1827
- reads.handOffToToolDispatch();
1828
- const workspace = await getWorkspace(routeDeps.db, workspaceId);
1829
- const workspaceMemoryEnabled = resolveWorkspaceMemoryEnabled(workspace?.settings);
1830
- const workspaceMemoryPromptMode = resolveWorkspaceMemoryPromptMode();
1857
+ };
1831
1858
  const transport = new WebStandardStreamableHTTPServerTransport({
1832
1859
  enableJsonResponse: true,
1833
1860
  });
1834
- if (!grantUsesAttemptScopedMcp(grant)) {
1861
+ if (c.req.method === "GET" || !grantUsesAttemptScopedMcp(grant)) {
1862
+ await authorizeBoundSession();
1863
+ if (c.req.method === "GET") return unsupportedGet();
1864
+ reads.handOffToToolDispatch();
1835
1865
  const prepared = await prepareWorkspaceToolGateway(routeDeps, authorization);
1836
1866
  const mcp = buildWorkspaceToolGatewayMcpServer(prepared, grant, routeDeps.observability);
1837
1867
  try {
@@ -1845,39 +1875,58 @@ export function createAppComposition(deps: AppDependencies): {
1845
1875
  await Promise.allSettled([mcp.close(), prepared.close()]);
1846
1876
  }
1847
1877
  }
1848
- const mcpDeps = await resolveWorkspaceMcpRouteDeps(routeDeps, grant);
1849
- // The bound session's frozen Memory selector (migration 0427) decides
1850
- // which Memory tools the attempt receives and which typed layers they
1851
- // read and write. A missing row resolves to no Memory tools.
1852
- const sessionMemory =
1853
- typeof boundSessionId === "string"
1854
- ? ((await resolveSessionMemoryAgentScope(
1855
- routeDeps.db,
1856
- workspaceId,
1857
- boundSessionId,
1858
- grant.metadata,
1859
- )) ?? {
1860
- mode: "off" as const,
1861
- userSubjectId: null,
1862
- rootSessionId: null,
1863
- })
1864
- : null;
1865
- // Checked live: allowed by the organization, given to this session,
1866
- // and the person who gave it is still an owner or admin.
1867
- const sessionAdminAccess =
1868
- typeof boundSessionId === "string" &&
1869
- (await resolveSessionAdminAuthority(routeDeps.db, {
1870
- accountId: grant.accountId,
1871
- workspaceId,
1872
- sessionId: boundSessionId,
1873
- }))
1874
- ? {
1875
- origin: new URL(c.req.url).origin,
1876
- dispatch: (request: Request) =>
1877
- outsideAgentContext(async () => await app.fetch(request, c.env)),
1878
- signal: c.req.raw.signal,
1879
- }
1880
- : null;
1878
+ // The tool surface is four independent reads: workspace settings, the
1879
+ // model catalog, the session's frozen Memory selector and its admin
1880
+ // access. Read them together, alongside the bound-session check, which
1881
+ // still decides before any of them reaches the response.
1882
+ const [, [workspaceSettings, mcpDeps, sessionMemory, sessionAdminAuthority]] =
1883
+ await alongsideAuthorization(authorizeBoundSession(), () =>
1884
+ Promise.all([
1885
+ getWorkspaceSettings(routeDeps.db, workspaceId),
1886
+ resolveWorkspaceMcpRouteDeps(routeDeps, grant),
1887
+ // The bound session's frozen Memory selector (migration 0427)
1888
+ // decides which Memory tools the attempt receives and which typed
1889
+ // layers they read and write. A missing row resolves to no Memory
1890
+ // tools.
1891
+ typeof boundSessionId === "string"
1892
+ ? resolveSessionMemoryAgentScope(
1893
+ routeDeps.db,
1894
+ workspaceId,
1895
+ boundSessionId,
1896
+ grant.metadata,
1897
+ ).then(
1898
+ (scope) =>
1899
+ scope ?? {
1900
+ mode: "off" as const,
1901
+ userSubjectId: null,
1902
+ rootSessionId: null,
1903
+ },
1904
+ )
1905
+ : null,
1906
+ // Checked live: allowed by the organization, given to this
1907
+ // session, and the person who gave it is still an owner or admin.
1908
+ typeof boundSessionId === "string"
1909
+ ? resolveSessionAdminAuthority(routeDeps.db, {
1910
+ accountId: grant.accountId,
1911
+ workspaceId,
1912
+ sessionId: boundSessionId,
1913
+ })
1914
+ : false,
1915
+ ]),
1916
+ );
1917
+ reads.handOffToToolDispatch();
1918
+ const workspaceMemoryEnabled = resolveWorkspaceMemoryEnabled(
1919
+ workspaceSettings ?? undefined,
1920
+ );
1921
+ const workspaceMemoryPromptMode = resolveWorkspaceMemoryPromptMode();
1922
+ const sessionAdminAccess = sessionAdminAuthority
1923
+ ? {
1924
+ origin: new URL(c.req.url).origin,
1925
+ dispatch: (request: Request) =>
1926
+ outsideAgentContext(async () => await app.fetch(request, c.env)),
1927
+ signal: c.req.raw.signal,
1928
+ }
1929
+ : null;
1881
1930
  const mcp = buildOpenGeniMcpServer(mcpDeps, grant, {
1882
1931
  requestOrigin: new URL(c.req.url).origin,
1883
1932
  workspaceMemoryEnabled,
@@ -2494,6 +2543,19 @@ async function validateCodexAccountTarget(request: Request): Promise<void> {
2494
2543
  }
2495
2544
  }
2496
2545
 
2546
+ /** Whether a POST carries only MCP protocol messages that touch no tool. */
2547
+ async function isProtocolOnlyMcpRequest(request: Request): Promise<boolean> {
2548
+ let body: unknown;
2549
+ try {
2550
+ body = await request.clone().json();
2551
+ } catch {
2552
+ // The transport answers malformed bodies itself, on the full path.
2553
+ return false;
2554
+ }
2555
+ const messages = Array.isArray(body) ? body : [body];
2556
+ return messages.length > 0 && messages.every(isOpenGeniMcpProtocolOnlyMessage);
2557
+ }
2558
+
2497
2559
  async function requireMcpAccessGrantAuthorization(
2498
2560
  c: Parameters<typeof requireAccessGrant>[0],
2499
2561
  deps: ApiRouteDeps,
@@ -0,0 +1,40 @@
1
+ import type Stripe from "stripe";
2
+
3
+ export interface StripeCustomerTransaction {
4
+ read(): Promise<string | null>;
5
+ account(): Promise<{ name: string } | null>;
6
+ save(customer: Stripe.Customer): Promise<void>;
7
+ }
8
+
9
+ /** The adapter must hold the account advisory lock for this whole callback,
10
+ * including provider I/O and persistence. The stable provider key also recovers
11
+ * creation when Stripe succeeds but the database transaction rolls back. */
12
+ export async function serializedStripeCustomer(input: {
13
+ stripe: Stripe;
14
+ accountId: string;
15
+ provider: string;
16
+ email?: string;
17
+ locked: (run: (tx: StripeCustomerTransaction) => Promise<string>) => Promise<string>;
18
+ }): Promise<string> {
19
+ return await input.locked(async (tx) => {
20
+ const existing = await tx.read();
21
+ if (existing) return existing;
22
+ const account = await tx.account();
23
+ if (!account) throw new Error("account not found");
24
+ const created = await input.stripe.customers.create(
25
+ {
26
+ metadata: { opengeni_account_id: input.accountId },
27
+ },
28
+ { idempotencyKey: `billing-customer:${input.provider}:${input.accountId}` },
29
+ );
30
+ // Creation parameters must stay identical even if a different administrator
31
+ // retries after a rollback, or the organization is renamed in between.
32
+ // Profile updates are idempotent sets and do not create another customer.
33
+ const customer = await input.stripe.customers.update(created.id, {
34
+ name: account.name,
35
+ ...(input.email ? { email: input.email } : {}),
36
+ });
37
+ await tx.save(customer);
38
+ return customer.id;
39
+ });
40
+ }
@@ -0,0 +1,121 @@
1
+ import type { BillingPlanBalance, SubscriptionPlan } from "@opengeni/contracts";
2
+ import {
3
+ assertBillingSubscriptionOperationRoot,
4
+ beginBillingSubscriptionOperation,
5
+ completeBillingSubscriptionOperation,
6
+ getBillingSubscriptionOperation,
7
+ nestedPostgresSqlState,
8
+ expirePlanCreditPeriod,
9
+ getBillingCustomer,
10
+ getManagedAccount,
11
+ getOrganizationPlanBalance,
12
+ readSubscriptionPlanCatalog,
13
+ replacePlanCreditPeriod,
14
+ upsertOrganizationPlan,
15
+ withRlsContext,
16
+ type Database,
17
+ type OrganizationPlanStateInput,
18
+ type PlanCreditPeriodInput,
19
+ type BillingSubscriptionOperation,
20
+ type BillingSubscriptionOperationKind,
21
+ } from "@opengeni/db";
22
+ import { HTTPException } from "hono/http-exception";
23
+ import { organizationPlans, planCreditPeriods } from "@opengeni/db/schema";
24
+ import { and, eq, isNull, sql } from "drizzle-orm";
25
+
26
+ export type StoredSubscriptionPlan = {
27
+ plan_key: string;
28
+ stripe_subscription_id: string | null;
29
+ };
30
+
31
+ export interface SubscriptionAccountStore {
32
+ operation(key: string): Promise<BillingSubscriptionOperation | null>;
33
+ beginOperation(input: {
34
+ operationKey: string;
35
+ kind: BillingSubscriptionOperationKind;
36
+ request: Record<string, unknown>;
37
+ intent: Record<string, unknown>;
38
+ }): Promise<BillingSubscriptionOperation>;
39
+ completeOperation(key: string, result: Record<string, unknown>): Promise<void>;
40
+ exists(): Promise<boolean>;
41
+ state(): Promise<StoredSubscriptionPlan | null>;
42
+ customer(provider: string): Promise<string | null>;
43
+ balance(): Promise<BillingPlanBalance | null>;
44
+ save(input: Omit<OrganizationPlanStateInput, "accountId">): Promise<void>;
45
+ replace(input: Omit<PlanCreditPeriodInput, "accountId">): Promise<void>;
46
+ expire(subscriptionId: string): Promise<void>;
47
+ }
48
+
49
+ export interface SubscriptionStore {
50
+ catalog(): Promise<{ revision: number; plans: SubscriptionPlan[] }>;
51
+ locked<T>(accountId: string, run: (store: SubscriptionAccountStore) => Promise<T>): Promise<T>;
52
+ }
53
+
54
+ /** Reuse the SQL seams in one outer transaction, membership/org fence before
55
+ * the credit-account lock. Stripe reads also run under this fence: concurrent
56
+ * deliveries cannot commit an older provider snapshot after a newer one. */
57
+ export function subscriptionStore(db: Database): SubscriptionStore {
58
+ assertBillingSubscriptionOperationRoot(db);
59
+ return {
60
+ catalog: () => readSubscriptionPlanCatalog(db),
61
+ locked: (accountId, run) =>
62
+ withRlsContext(db, { accountId, workspaceId: null }, async (tx) => {
63
+ await tx.execute(
64
+ sql`select pg_advisory_xact_lock(hashtextextended(${`organization-membership:${accountId}`}, 0))`,
65
+ );
66
+ return await run({
67
+ operation: (key) => getBillingSubscriptionOperation(tx, accountId, key),
68
+ beginOperation: async (input) => {
69
+ try {
70
+ return await beginBillingSubscriptionOperation(tx, { ...input, accountId });
71
+ } catch (error) {
72
+ if (nestedPostgresSqlState(error) === "P0S02")
73
+ throw new HTTPException(409, {
74
+ message: "Retry the pending subscription operation before starting another",
75
+ });
76
+ throw error;
77
+ }
78
+ },
79
+ completeOperation: async (operationKey, result) => {
80
+ await completeBillingSubscriptionOperation(tx, { accountId, operationKey, result });
81
+ },
82
+ exists: async () => Boolean(await getManagedAccount(tx, accountId)),
83
+ state: async () => {
84
+ const [row] = await tx
85
+ .select({
86
+ plan_key: organizationPlans.planKey,
87
+ stripe_subscription_id: organizationPlans.stripeSubscriptionId,
88
+ })
89
+ .from(organizationPlans)
90
+ .where(eq(organizationPlans.accountId, accountId))
91
+ .limit(1);
92
+ return row ?? null;
93
+ },
94
+ customer: async (provider) =>
95
+ (await getBillingCustomer(tx, accountId, provider))?.providerCustomerId ?? null,
96
+ balance: () => getOrganizationPlanBalance(tx, accountId),
97
+ save: async (input) => {
98
+ await upsertOrganizationPlan(tx, { ...input, accountId });
99
+ },
100
+ replace: async (input) => {
101
+ await replacePlanCreditPeriod(tx, { ...input, accountId });
102
+ },
103
+ expire: async (subscriptionId) => {
104
+ const periods = await tx
105
+ .select({ id: planCreditPeriods.id })
106
+ .from(planCreditPeriods)
107
+ .where(
108
+ and(
109
+ eq(planCreditPeriods.accountId, accountId),
110
+ eq(planCreditPeriods.stripeSubscriptionId, subscriptionId),
111
+ isNull(planCreditPeriods.expiredAt),
112
+ ),
113
+ );
114
+ for (const period of periods) {
115
+ await expirePlanCreditPeriod(tx, { accountId, periodId: period.id, force: true });
116
+ }
117
+ },
118
+ });
119
+ }),
120
+ };
121
+ }