@opengeni/api-router 3.3.0 → 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.
- package/dist/access-grant-rls.d.ts +15 -0
- package/dist/app.js +1 -1
- package/dist/billing-stripe-customer.d.ts +18 -0
- package/dist/billing-subscription-store.d.ts +34 -0
- package/dist/billing-subscriptions.d.ts +64 -0
- package/dist/{chunk-6CECHDZP.js → chunk-6DZ4H5JQ.js} +2684 -1410
- package/dist/{chunk-6CECHDZP.js.map → chunk-6DZ4H5JQ.js.map} +1 -1
- package/dist/index.js +3 -2
- package/dist/index.js.map +1 -1
- package/dist/integrations/marketplace-plugin.d.ts +43 -43
- package/dist/mcp/scheduled-task-view.d.ts +4 -4
- package/dist/mcp/server.d.ts +11 -1
- package/dist/routes/browser-sessions.d.ts +23 -2
- package/dist/routes/sessions.d.ts +1 -0
- package/package.json +24 -24
- package/src/access-grant-rls.ts +98 -10
- package/src/app.ts +105 -43
- package/src/billing-stripe-customer.ts +40 -0
- package/src/billing-subscription-store.ts +121 -0
- package/src/billing-subscriptions.ts +759 -0
- package/src/index.ts +3 -0
- package/src/mcp/action-catalog.gen.ts +113 -0
- package/src/mcp/action-schema-imports.gen.ts +5 -1
- package/src/mcp/server.ts +28 -0
- package/src/routes/billing.ts +161 -21
- package/src/routes/browser-sessions.ts +359 -9
- package/src/routes/organization-memberships.ts +12 -0
- package/src/routes/sessions.ts +8 -1
package/src/access-grant-rls.ts
CHANGED
|
@@ -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
|
-
|
|
67
|
-
|
|
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",
|
|
@@ -101,3 +164,28 @@ export async function withAccessGrantSessionRlsContext<T>(
|
|
|
101
164
|
throw error;
|
|
102
165
|
}
|
|
103
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 {
|
|
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 {
|
|
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
|
-
|
|
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
|
-
|
|
1849
|
-
//
|
|
1850
|
-
//
|
|
1851
|
-
//
|
|
1852
|
-
const sessionMemory =
|
|
1853
|
-
|
|
1854
|
-
|
|
1855
|
-
|
|
1856
|
-
|
|
1857
|
-
|
|
1858
|
-
|
|
1859
|
-
|
|
1860
|
-
|
|
1861
|
-
|
|
1862
|
-
|
|
1863
|
-
|
|
1864
|
-
|
|
1865
|
-
|
|
1866
|
-
|
|
1867
|
-
|
|
1868
|
-
|
|
1869
|
-
|
|
1870
|
-
|
|
1871
|
-
|
|
1872
|
-
|
|
1873
|
-
|
|
1874
|
-
|
|
1875
|
-
|
|
1876
|
-
|
|
1877
|
-
|
|
1878
|
-
|
|
1879
|
-
|
|
1880
|
-
|
|
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
|
+
}
|