@opengeni/api-router 0.2.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/app.d.ts +16 -0
- package/dist/app.js +35 -0
- package/dist/app.js.map +1 -0
- package/dist/chunk-XSYUDIX3.js +6331 -0
- package/dist/chunk-XSYUDIX3.js.map +1 -0
- package/dist/index.d.ts +19 -0
- package/dist/index.js +567 -0
- package/dist/index.js.map +1 -0
- package/package.json +74 -0
- package/src/app.ts +351 -0
- package/src/auth/managed-auth.ts +237 -0
- package/src/http/auth.ts +92 -0
- package/src/http/common.ts +16 -0
- package/src/http/sse.ts +89 -0
- package/src/index.ts +362 -0
- package/src/mcp/documents.ts +57 -0
- package/src/mcp/server.ts +961 -0
- package/src/mcp/session-view.ts +281 -0
- package/src/routes/api-keys.ts +65 -0
- package/src/routes/billing.ts +495 -0
- package/src/routes/capabilities.ts +80 -0
- package/src/routes/codex.ts +393 -0
- package/src/routes/documents.ts +185 -0
- package/src/routes/enrollments.ts +357 -0
- package/src/routes/environments.ts +175 -0
- package/src/routes/files.ts +148 -0
- package/src/routes/github.ts +341 -0
- package/src/routes/install.ts +218 -0
- package/src/routes/machines.ts +107 -0
- package/src/routes/packs.ts +241 -0
- package/src/routes/scheduled-tasks.ts +126 -0
- package/src/routes/sessions.ts +1083 -0
- package/src/routes/social.ts +119 -0
- package/src/routes/workspaces.ts +206 -0
- package/src/sandbox/access.ts +89 -0
- package/src/sandbox/auth-callout.ts +178 -0
- package/src/sandbox/channel-a.ts +265 -0
- package/src/sandbox/enrollment.ts +498 -0
- package/src/sandbox/machines.ts +255 -0
- package/src/sandbox/metrics-ingestion.ts +289 -0
- package/src/sandbox/viewer.ts +993 -0
|
@@ -0,0 +1,495 @@
|
|
|
1
|
+
import {
|
|
2
|
+
CreateCheckoutRequest,
|
|
3
|
+
CreateCheckoutResponse,
|
|
4
|
+
type AccessContext,
|
|
5
|
+
type Permission,
|
|
6
|
+
} from "@opengeni/contracts";
|
|
7
|
+
import { configuredEntitlements } from "@opengeni/config";
|
|
8
|
+
import {
|
|
9
|
+
applyCreditLedgerEntry,
|
|
10
|
+
getBillingBalance,
|
|
11
|
+
getBillingCustomer,
|
|
12
|
+
hasCreditLedgerEntry,
|
|
13
|
+
isStripeWebhookProcessed,
|
|
14
|
+
listUsageEvents,
|
|
15
|
+
getManagedAccount,
|
|
16
|
+
markStripeWebhookProcessed,
|
|
17
|
+
recordStripeWebhookEvent,
|
|
18
|
+
upsertBillingCustomer,
|
|
19
|
+
} from "@opengeni/db";
|
|
20
|
+
import type { Hono } from "hono";
|
|
21
|
+
import { HTTPException } from "hono/http-exception";
|
|
22
|
+
import Stripe from "stripe";
|
|
23
|
+
import { requireAccessContext } from "@opengeni/core";
|
|
24
|
+
import type { ApiRouteDeps } from "@opengeni/core";
|
|
25
|
+
|
|
26
|
+
export function registerBillingRoutes(app: Hono, deps: ApiRouteDeps): void {
|
|
27
|
+
app.get("/v1/billing", async (c) => {
|
|
28
|
+
const context = await requireAccessContext(c, deps);
|
|
29
|
+
const accountId = requireSelectedAccount(context, c.req.query("accountId"), "billing:read");
|
|
30
|
+
return c.json({ mode: deps.settings.billingMode, balance: await getBillingBalance(deps.db, accountId) });
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
app.get("/v1/billing/usage", async (c) => {
|
|
34
|
+
const context = await requireAccessContext(c, deps);
|
|
35
|
+
const accountId = requireSelectedAccount(context, c.req.query("accountId"), "billing:read");
|
|
36
|
+
const workspaceId = c.req.query("workspaceId");
|
|
37
|
+
if (workspaceId && !context.workspaceGrants.some((grant) => grant.accountId === accountId && grant.workspaceId === workspaceId)) {
|
|
38
|
+
throw new HTTPException(403, { message: "missing workspace access for usage query" });
|
|
39
|
+
}
|
|
40
|
+
return c.json({
|
|
41
|
+
balance: await getBillingBalance(deps.db, accountId),
|
|
42
|
+
usage: await listUsageEvents(deps.db, {
|
|
43
|
+
accountId,
|
|
44
|
+
...(workspaceId ? { workspaceId } : {}),
|
|
45
|
+
limit: 100,
|
|
46
|
+
}),
|
|
47
|
+
});
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
app.get("/v1/billing/entitlements", async (c) => {
|
|
51
|
+
const context = await requireAccessContext(c, deps);
|
|
52
|
+
const accountId = requireSelectedAccount(context, c.req.query("accountId"), "billing:read");
|
|
53
|
+
return c.json({ accountId, mode: deps.settings.entitlementsMode, entitlements: configuredEntitlements(deps.settings) });
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
app.post("/v1/billing/checkout", async (c) => {
|
|
57
|
+
if (deps.settings.billingMode !== "stripe") {
|
|
58
|
+
throw new HTTPException(404, { message: "stripe billing is not enabled" });
|
|
59
|
+
}
|
|
60
|
+
const context = await requireAccessContext(c, deps);
|
|
61
|
+
const parsed = CreateCheckoutRequest.safeParse(await c.req.json());
|
|
62
|
+
if (!parsed.success) {
|
|
63
|
+
throw new HTTPException(400, { message: parsed.error.issues[0]?.message ?? "invalid checkout request" });
|
|
64
|
+
}
|
|
65
|
+
const body = parsed.data;
|
|
66
|
+
const accountId = requireSelectedAccount(context, body.accountId, "billing:manage");
|
|
67
|
+
const amountCents = usdToCents(body.amountUsd);
|
|
68
|
+
const amountMicros = centsToMicros(amountCents);
|
|
69
|
+
const stripe = stripeClient(deps);
|
|
70
|
+
const customerId = await getOrCreateStripeCustomer(deps, stripe, context, accountId);
|
|
71
|
+
const idempotencyKey = `checkout:${accountId}:${amountMicros}:${crypto.randomUUID()}`;
|
|
72
|
+
const session = await stripe.checkout.sessions.create(stripeCheckoutSessionCreateParams({
|
|
73
|
+
accountId,
|
|
74
|
+
customerId,
|
|
75
|
+
amountCents,
|
|
76
|
+
amountMicros,
|
|
77
|
+
creditsProductId: deps.settings.stripeCreditsProductId,
|
|
78
|
+
publicBaseUrl: deps.settings.publicBaseUrl,
|
|
79
|
+
successUrl: body.successUrl,
|
|
80
|
+
cancelUrl: body.cancelUrl,
|
|
81
|
+
idempotencyKey,
|
|
82
|
+
}), { idempotencyKey });
|
|
83
|
+
if (!session.url) {
|
|
84
|
+
throw new HTTPException(502, { message: "Stripe did not return a checkout URL" });
|
|
85
|
+
}
|
|
86
|
+
return c.json(CreateCheckoutResponse.parse({
|
|
87
|
+
checkoutSessionId: session.id,
|
|
88
|
+
url: session.url,
|
|
89
|
+
}));
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
app.post("/v1/webhooks/stripe", async (c) => {
|
|
93
|
+
if (deps.settings.billingMode !== "stripe") {
|
|
94
|
+
throw new HTTPException(404, { message: "stripe billing is not enabled" });
|
|
95
|
+
}
|
|
96
|
+
const signature = c.req.header("stripe-signature");
|
|
97
|
+
if (!signature) {
|
|
98
|
+
throw new HTTPException(400, { message: "missing stripe-signature" });
|
|
99
|
+
}
|
|
100
|
+
const payload = await c.req.text();
|
|
101
|
+
let event: Stripe.Event;
|
|
102
|
+
try {
|
|
103
|
+
event = await stripeClient(deps).webhooks.constructEventAsync(payload, signature, deps.settings.stripeWebhookSecret!);
|
|
104
|
+
} catch (error) {
|
|
105
|
+
throw new HTTPException(400, { message: error instanceof Error ? error.message : "invalid stripe signature" });
|
|
106
|
+
}
|
|
107
|
+
const firstSeen = await recordStripeWebhookEvent(deps.db, {
|
|
108
|
+
id: event.id,
|
|
109
|
+
type: event.type,
|
|
110
|
+
livemode: event.livemode,
|
|
111
|
+
payload: event,
|
|
112
|
+
});
|
|
113
|
+
if (!firstSeen) {
|
|
114
|
+
if (await isStripeWebhookProcessed(deps.db, event.id)) {
|
|
115
|
+
return c.json({ received: true, duplicate: true });
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
try {
|
|
119
|
+
await handleStripeWebhookEvent(deps, stripeClient(deps), event);
|
|
120
|
+
await markStripeWebhookProcessed(deps.db, event.id);
|
|
121
|
+
return c.json({ received: true });
|
|
122
|
+
} catch (error) {
|
|
123
|
+
throw new HTTPException(500, { message: error instanceof Error ? error.message : String(error) });
|
|
124
|
+
}
|
|
125
|
+
});
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
export function stripeCheckoutSessionCreateParams(input: {
|
|
129
|
+
accountId: string;
|
|
130
|
+
customerId: string;
|
|
131
|
+
amountCents: number;
|
|
132
|
+
amountMicros: number;
|
|
133
|
+
creditsProductId?: string | undefined;
|
|
134
|
+
publicBaseUrl?: string | undefined;
|
|
135
|
+
successUrl?: string | undefined;
|
|
136
|
+
cancelUrl?: string | undefined;
|
|
137
|
+
idempotencyKey: string;
|
|
138
|
+
}): Stripe.Checkout.SessionCreateParams {
|
|
139
|
+
const successUrl = checkoutReturnUrl(input.publicBaseUrl, input.successUrl, "/billing?checkout=success", "successUrl");
|
|
140
|
+
const cancelUrl = checkoutReturnUrl(input.publicBaseUrl, input.cancelUrl, "/billing?checkout=cancelled", "cancelUrl");
|
|
141
|
+
return {
|
|
142
|
+
mode: "payment",
|
|
143
|
+
customer: input.customerId,
|
|
144
|
+
customer_update: {
|
|
145
|
+
address: "auto",
|
|
146
|
+
name: "auto",
|
|
147
|
+
},
|
|
148
|
+
success_url: successUrl,
|
|
149
|
+
cancel_url: cancelUrl,
|
|
150
|
+
automatic_tax: { enabled: true },
|
|
151
|
+
billing_address_collection: "auto",
|
|
152
|
+
line_items: [{
|
|
153
|
+
quantity: 1,
|
|
154
|
+
price_data: {
|
|
155
|
+
currency: "usd",
|
|
156
|
+
unit_amount: input.amountCents,
|
|
157
|
+
...(input.creditsProductId
|
|
158
|
+
? { product: input.creditsProductId }
|
|
159
|
+
: {
|
|
160
|
+
product_data: {
|
|
161
|
+
name: "OpenGeni credits",
|
|
162
|
+
metadata: {
|
|
163
|
+
app: "opengeni",
|
|
164
|
+
billing_model: "prepaid_credits",
|
|
165
|
+
},
|
|
166
|
+
},
|
|
167
|
+
}),
|
|
168
|
+
},
|
|
169
|
+
}],
|
|
170
|
+
metadata: {
|
|
171
|
+
opengeni_account_id: input.accountId,
|
|
172
|
+
opengeni_credit_amount_usd: (input.amountCents / 100).toFixed(2),
|
|
173
|
+
opengeni_credit_micros: String(input.amountMicros),
|
|
174
|
+
opengeni_credit_idempotency_key: input.idempotencyKey,
|
|
175
|
+
},
|
|
176
|
+
payment_intent_data: {
|
|
177
|
+
metadata: {
|
|
178
|
+
opengeni_account_id: input.accountId,
|
|
179
|
+
opengeni_credit_amount_usd: (input.amountCents / 100).toFixed(2),
|
|
180
|
+
opengeni_credit_micros: String(input.amountMicros),
|
|
181
|
+
opengeni_credit_idempotency_key: input.idempotencyKey,
|
|
182
|
+
},
|
|
183
|
+
},
|
|
184
|
+
};
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
function checkoutReturnUrl(publicBaseUrl: string | undefined, candidate: string | undefined, fallbackPath: string, field: string): string {
|
|
188
|
+
if (!publicBaseUrl) {
|
|
189
|
+
throw new HTTPException(500, { message: "OPENGENI_PUBLIC_BASE_URL is required for Stripe checkout" });
|
|
190
|
+
}
|
|
191
|
+
const base = new URL(publicBaseUrl);
|
|
192
|
+
const fallback = new URL(fallbackPath, base).toString();
|
|
193
|
+
if (!candidate) {
|
|
194
|
+
return fallback;
|
|
195
|
+
}
|
|
196
|
+
const parsed = new URL(candidate);
|
|
197
|
+
if (parsed.origin !== base.origin) {
|
|
198
|
+
throw new HTTPException(400, { message: `${field} must use the OpenGeni public origin` });
|
|
199
|
+
}
|
|
200
|
+
return parsed.toString();
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
async function handleStripeWebhookEvent(deps: ApiRouteDeps, stripe: Stripe, event: Stripe.Event): Promise<void> {
|
|
204
|
+
switch (event.type) {
|
|
205
|
+
case "checkout.session.completed":
|
|
206
|
+
await handleCheckoutSessionCompleted(deps, event);
|
|
207
|
+
return;
|
|
208
|
+
case "checkout.session.expired":
|
|
209
|
+
case "payment_intent.succeeded":
|
|
210
|
+
case "payment_intent.payment_failed":
|
|
211
|
+
case "payment_intent.canceled":
|
|
212
|
+
await mirrorPaymentIntentCustomer(deps, event);
|
|
213
|
+
return;
|
|
214
|
+
case "charge.refunded":
|
|
215
|
+
await handleChargeRefunded(deps, stripe, event);
|
|
216
|
+
return;
|
|
217
|
+
case "refund.created":
|
|
218
|
+
case "refund.updated":
|
|
219
|
+
await handleRefundEvent(deps, stripe, event);
|
|
220
|
+
return;
|
|
221
|
+
case "refund.failed":
|
|
222
|
+
return;
|
|
223
|
+
case "charge.dispute.created":
|
|
224
|
+
case "charge.dispute.funds_withdrawn":
|
|
225
|
+
await holdDisputedCredits(deps, stripe, event);
|
|
226
|
+
return;
|
|
227
|
+
case "charge.dispute.closed":
|
|
228
|
+
case "charge.dispute.funds_reinstated":
|
|
229
|
+
await releaseDisputedCredits(deps, stripe, event);
|
|
230
|
+
return;
|
|
231
|
+
case "charge.dispute.updated":
|
|
232
|
+
return;
|
|
233
|
+
case "customer.created":
|
|
234
|
+
case "customer.updated":
|
|
235
|
+
await mirrorCustomer(deps, event, event.data.object as Stripe.Customer);
|
|
236
|
+
return;
|
|
237
|
+
default:
|
|
238
|
+
return;
|
|
239
|
+
}
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
async function handleCheckoutSessionCompleted(deps: ApiRouteDeps, event: Stripe.Event): Promise<void> {
|
|
243
|
+
const session = event.data.object as Stripe.Checkout.Session;
|
|
244
|
+
if (session.mode !== "payment" || session.payment_status !== "paid") {
|
|
245
|
+
return;
|
|
246
|
+
}
|
|
247
|
+
const credit = creditMetadata(session.metadata, `Stripe checkout session ${session.id}`);
|
|
248
|
+
const customerId = typeof session.customer === "string" ? session.customer : session.customer?.id;
|
|
249
|
+
if (customerId) {
|
|
250
|
+
await upsertBillingCustomer(deps.db, {
|
|
251
|
+
accountId: credit.accountId,
|
|
252
|
+
provider: stripeCustomerProvider(event),
|
|
253
|
+
providerCustomerId: customerId,
|
|
254
|
+
email: session.customer_details?.email ?? session.customer_email ?? null,
|
|
255
|
+
});
|
|
256
|
+
}
|
|
257
|
+
await applyCreditLedgerEntry(deps.db, {
|
|
258
|
+
accountId: credit.accountId,
|
|
259
|
+
type: "credit_topup",
|
|
260
|
+
amountMicros: credit.amountMicros,
|
|
261
|
+
sourceType: "stripe_checkout_session",
|
|
262
|
+
sourceId: session.id,
|
|
263
|
+
idempotencyKey: credit.idempotencyKey,
|
|
264
|
+
metadata: {
|
|
265
|
+
stripeEventId: event.id,
|
|
266
|
+
stripePaymentIntentId: typeof session.payment_intent === "string" ? session.payment_intent : session.payment_intent?.id ?? null,
|
|
267
|
+
stripePackageId: credit.packageId,
|
|
268
|
+
stripeCreditAmountUsd: credit.amountUsd,
|
|
269
|
+
},
|
|
270
|
+
});
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
async function mirrorPaymentIntentCustomer(deps: ApiRouteDeps, event: Stripe.Event): Promise<void> {
|
|
274
|
+
const intent = event.data.object as Stripe.PaymentIntent;
|
|
275
|
+
const accountId = intent.metadata?.opengeni_account_id;
|
|
276
|
+
const customerId = typeof intent.customer === "string" ? intent.customer : intent.customer?.id;
|
|
277
|
+
if (accountId && customerId) {
|
|
278
|
+
await upsertBillingCustomer(deps.db, {
|
|
279
|
+
accountId,
|
|
280
|
+
provider: stripeCustomerProvider(event),
|
|
281
|
+
providerCustomerId: customerId,
|
|
282
|
+
email: null,
|
|
283
|
+
});
|
|
284
|
+
}
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
async function handleChargeRefunded(deps: ApiRouteDeps, stripe: Stripe, event: Stripe.Event): Promise<void> {
|
|
288
|
+
const charge = event.data.object as Stripe.Charge;
|
|
289
|
+
for (const refund of charge.refunds?.data ?? []) {
|
|
290
|
+
await applyRefundDebit(deps, stripe, refund);
|
|
291
|
+
}
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
async function handleRefundEvent(deps: ApiRouteDeps, stripe: Stripe, event: Stripe.Event): Promise<void> {
|
|
295
|
+
await applyRefundDebit(deps, stripe, event.data.object as Stripe.Refund);
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
async function applyRefundDebit(deps: ApiRouteDeps, stripe: Stripe, refund: Stripe.Refund): Promise<void> {
|
|
299
|
+
if (refund.status && refund.status !== "succeeded") {
|
|
300
|
+
return;
|
|
301
|
+
}
|
|
302
|
+
const metadata = await metadataForRefund(stripe, refund);
|
|
303
|
+
const accountId = metadata?.opengeni_account_id;
|
|
304
|
+
if (!accountId) {
|
|
305
|
+
return;
|
|
306
|
+
}
|
|
307
|
+
await applyCreditLedgerEntry(deps.db, {
|
|
308
|
+
accountId,
|
|
309
|
+
type: "credit_refund",
|
|
310
|
+
amountMicros: -centsToMicros(refund.amount),
|
|
311
|
+
sourceType: "stripe_refund",
|
|
312
|
+
sourceId: refund.id,
|
|
313
|
+
idempotencyKey: `stripe:refund:${refund.id}`,
|
|
314
|
+
metadata: {
|
|
315
|
+
stripeRefundId: refund.id,
|
|
316
|
+
stripePaymentIntentId: paymentIntentId(refund.payment_intent),
|
|
317
|
+
},
|
|
318
|
+
});
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
async function holdDisputedCredits(deps: ApiRouteDeps, stripe: Stripe, event: Stripe.Event): Promise<void> {
|
|
322
|
+
const dispute = event.data.object as Stripe.Dispute;
|
|
323
|
+
const metadata = await metadataForDispute(stripe, dispute);
|
|
324
|
+
const accountId = metadata?.opengeni_account_id;
|
|
325
|
+
if (!accountId) {
|
|
326
|
+
return;
|
|
327
|
+
}
|
|
328
|
+
await applyCreditLedgerEntry(deps.db, {
|
|
329
|
+
accountId,
|
|
330
|
+
type: "credit_dispute_hold",
|
|
331
|
+
amountMicros: -centsToMicros(dispute.amount),
|
|
332
|
+
sourceType: "stripe_dispute",
|
|
333
|
+
sourceId: dispute.id,
|
|
334
|
+
idempotencyKey: `stripe:dispute_hold:${dispute.id}`,
|
|
335
|
+
metadata: { stripeDisputeId: dispute.id, stripeEventType: event.type },
|
|
336
|
+
});
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
async function releaseDisputedCredits(deps: ApiRouteDeps, stripe: Stripe, event: Stripe.Event): Promise<void> {
|
|
340
|
+
const dispute = event.data.object as Stripe.Dispute;
|
|
341
|
+
if (event.type === "charge.dispute.closed" && dispute.status !== "won") {
|
|
342
|
+
return;
|
|
343
|
+
}
|
|
344
|
+
const metadata = await metadataForDispute(stripe, dispute);
|
|
345
|
+
const accountId = metadata?.opengeni_account_id;
|
|
346
|
+
if (!accountId) {
|
|
347
|
+
return;
|
|
348
|
+
}
|
|
349
|
+
const holdIdempotencyKey = `stripe:dispute_hold:${dispute.id}`;
|
|
350
|
+
if (!await hasCreditLedgerEntry(deps.db, accountId, holdIdempotencyKey)) {
|
|
351
|
+
return;
|
|
352
|
+
}
|
|
353
|
+
await applyCreditLedgerEntry(deps.db, {
|
|
354
|
+
accountId,
|
|
355
|
+
type: "credit_dispute_release",
|
|
356
|
+
amountMicros: centsToMicros(dispute.amount),
|
|
357
|
+
sourceType: "stripe_dispute",
|
|
358
|
+
sourceId: dispute.id,
|
|
359
|
+
idempotencyKey: `stripe:dispute_release:${dispute.id}`,
|
|
360
|
+
metadata: { stripeDisputeId: dispute.id, stripeEventType: event.type },
|
|
361
|
+
});
|
|
362
|
+
}
|
|
363
|
+
|
|
364
|
+
async function mirrorCustomer(deps: ApiRouteDeps, event: Stripe.Event, customer: Stripe.Customer): Promise<void> {
|
|
365
|
+
const accountId = customer.metadata?.opengeni_account_id;
|
|
366
|
+
if (!accountId || customer.deleted) {
|
|
367
|
+
return;
|
|
368
|
+
}
|
|
369
|
+
await upsertBillingCustomer(deps.db, {
|
|
370
|
+
accountId,
|
|
371
|
+
provider: stripeCustomerProvider(event),
|
|
372
|
+
providerCustomerId: customer.id,
|
|
373
|
+
email: typeof customer.email === "string" ? customer.email : null,
|
|
374
|
+
});
|
|
375
|
+
}
|
|
376
|
+
|
|
377
|
+
async function metadataForRefund(stripe: Stripe, refund: Stripe.Refund): Promise<Stripe.Metadata | null> {
|
|
378
|
+
if (Object.keys(refund.metadata ?? {}).length > 0) {
|
|
379
|
+
return refund.metadata;
|
|
380
|
+
}
|
|
381
|
+
const paymentIntent = paymentIntentId(refund.payment_intent);
|
|
382
|
+
return paymentIntent ? (await stripe.paymentIntents.retrieve(paymentIntent)).metadata : null;
|
|
383
|
+
}
|
|
384
|
+
|
|
385
|
+
async function metadataForDispute(stripe: Stripe, dispute: Stripe.Dispute): Promise<Stripe.Metadata | null> {
|
|
386
|
+
if (Object.keys(dispute.metadata ?? {}).length > 0) {
|
|
387
|
+
return dispute.metadata;
|
|
388
|
+
}
|
|
389
|
+
const paymentIntent = paymentIntentId((dispute as unknown as { payment_intent?: string | Stripe.PaymentIntent | null }).payment_intent);
|
|
390
|
+
if (paymentIntent) {
|
|
391
|
+
return (await stripe.paymentIntents.retrieve(paymentIntent)).metadata;
|
|
392
|
+
}
|
|
393
|
+
const chargeId = typeof dispute.charge === "string" ? dispute.charge : dispute.charge?.id;
|
|
394
|
+
if (!chargeId) {
|
|
395
|
+
return null;
|
|
396
|
+
}
|
|
397
|
+
const charge = await stripe.charges.retrieve(chargeId);
|
|
398
|
+
const chargePaymentIntent = paymentIntentId(charge.payment_intent);
|
|
399
|
+
return chargePaymentIntent ? (await stripe.paymentIntents.retrieve(chargePaymentIntent)).metadata : charge.metadata;
|
|
400
|
+
}
|
|
401
|
+
|
|
402
|
+
function creditMetadata(metadata: Stripe.Metadata | null | undefined, label: string): {
|
|
403
|
+
accountId: string;
|
|
404
|
+
amountMicros: number;
|
|
405
|
+
idempotencyKey: string;
|
|
406
|
+
amountUsd?: string;
|
|
407
|
+
packageId?: string;
|
|
408
|
+
} {
|
|
409
|
+
const accountId = metadata?.opengeni_account_id;
|
|
410
|
+
const amountMicros = Number(metadata?.opengeni_credit_micros);
|
|
411
|
+
const idempotencyKey = metadata?.opengeni_credit_idempotency_key;
|
|
412
|
+
if (!accountId || !Number.isSafeInteger(amountMicros) || amountMicros <= 0 || !idempotencyKey) {
|
|
413
|
+
throw new Error(`${label} is missing OpenGeni credit metadata`);
|
|
414
|
+
}
|
|
415
|
+
return {
|
|
416
|
+
accountId,
|
|
417
|
+
amountMicros,
|
|
418
|
+
idempotencyKey,
|
|
419
|
+
...(metadata?.opengeni_credit_amount_usd ? { amountUsd: metadata.opengeni_credit_amount_usd } : {}),
|
|
420
|
+
...(metadata?.opengeni_package_id ? { packageId: metadata.opengeni_package_id } : {}),
|
|
421
|
+
};
|
|
422
|
+
}
|
|
423
|
+
|
|
424
|
+
async function getOrCreateStripeCustomer(deps: ApiRouteDeps, stripe: Stripe, context: AccessContext, accountId: string): Promise<string> {
|
|
425
|
+
const provider = stripeCustomerProvider(deps);
|
|
426
|
+
const existing = await getBillingCustomer(deps.db, accountId, provider);
|
|
427
|
+
if (existing) {
|
|
428
|
+
return existing.providerCustomerId;
|
|
429
|
+
}
|
|
430
|
+
const account = await getManagedAccount(deps.db, accountId);
|
|
431
|
+
if (!account) {
|
|
432
|
+
throw new HTTPException(404, { message: "account not found" });
|
|
433
|
+
}
|
|
434
|
+
const customer = await stripe.customers.create({
|
|
435
|
+
name: account.name,
|
|
436
|
+
...(looksLikeEmail(context.subjectLabel) ? { email: context.subjectLabel } : {}),
|
|
437
|
+
metadata: {
|
|
438
|
+
opengeni_account_id: accountId,
|
|
439
|
+
},
|
|
440
|
+
});
|
|
441
|
+
await upsertBillingCustomer(deps.db, {
|
|
442
|
+
accountId,
|
|
443
|
+
provider,
|
|
444
|
+
providerCustomerId: customer.id,
|
|
445
|
+
email: customer.email,
|
|
446
|
+
});
|
|
447
|
+
return customer.id;
|
|
448
|
+
}
|
|
449
|
+
|
|
450
|
+
export function stripeCustomerProvider(input: ApiRouteDeps | Stripe.Event): "stripe:live" | "stripe:test" {
|
|
451
|
+
if ("livemode" in input) {
|
|
452
|
+
return input.livemode ? "stripe:live" : "stripe:test";
|
|
453
|
+
}
|
|
454
|
+
return input.settings.stripeSecretKey?.startsWith("sk_live_") || input.settings.stripeSecretKey?.startsWith("rk_live_")
|
|
455
|
+
? "stripe:live"
|
|
456
|
+
: "stripe:test";
|
|
457
|
+
}
|
|
458
|
+
|
|
459
|
+
function requireSelectedAccount(context: AccessContext, requested: string | undefined, permission: Permission): string {
|
|
460
|
+
const accountId = requested ?? context.defaultAccountId ?? undefined;
|
|
461
|
+
if (!accountId) {
|
|
462
|
+
throw new HTTPException(409, { message: "account selection is required" });
|
|
463
|
+
}
|
|
464
|
+
const grant = context.accountGrants.find((candidate) => candidate.accountId === accountId);
|
|
465
|
+
if (!grant || (!grant.permissions.includes(permission) && !grant.permissions.includes("account:admin"))) {
|
|
466
|
+
throw new HTTPException(403, { message: `missing permission: ${permission}` });
|
|
467
|
+
}
|
|
468
|
+
return accountId;
|
|
469
|
+
}
|
|
470
|
+
|
|
471
|
+
function stripeClient(deps: ApiRouteDeps): Stripe {
|
|
472
|
+
if (!deps.settings.stripeSecretKey) {
|
|
473
|
+
throw new HTTPException(500, { message: "Stripe secret key is not configured" });
|
|
474
|
+
}
|
|
475
|
+
return new Stripe(deps.settings.stripeSecretKey);
|
|
476
|
+
}
|
|
477
|
+
|
|
478
|
+
function centsToMicros(cents: number): number {
|
|
479
|
+
return cents * 10_000;
|
|
480
|
+
}
|
|
481
|
+
|
|
482
|
+
function usdToCents(amountUsd: number): number {
|
|
483
|
+
return Math.round(amountUsd * 100);
|
|
484
|
+
}
|
|
485
|
+
|
|
486
|
+
function paymentIntentId(value: string | Stripe.PaymentIntent | null | undefined): string | null {
|
|
487
|
+
if (!value) {
|
|
488
|
+
return null;
|
|
489
|
+
}
|
|
490
|
+
return typeof value === "string" ? value : value.id;
|
|
491
|
+
}
|
|
492
|
+
|
|
493
|
+
function looksLikeEmail(value: string | undefined): value is string {
|
|
494
|
+
return Boolean(value && value.includes("@"));
|
|
495
|
+
}
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
import {
|
|
2
|
+
CapabilityCatalogResponse,
|
|
3
|
+
CreateCapabilityCatalogItemRequest,
|
|
4
|
+
DiscoverMcpCapabilitiesResponse,
|
|
5
|
+
EnableCapabilityRequest,
|
|
6
|
+
} from "@opengeni/contracts";
|
|
7
|
+
import type { Hono } from "hono";
|
|
8
|
+
import { requireAccessGrant } from "@opengeni/core";
|
|
9
|
+
import type { ApiRouteDeps } from "@opengeni/core";
|
|
10
|
+
import {
|
|
11
|
+
buildCapabilityCatalog,
|
|
12
|
+
createCatalogItem,
|
|
13
|
+
disableCapability,
|
|
14
|
+
discoverMcpRegistryCapabilities,
|
|
15
|
+
enableCapability,
|
|
16
|
+
officialMcpRegistryUrl,
|
|
17
|
+
} from "@opengeni/core";
|
|
18
|
+
import { boundedLimit } from "../http/common";
|
|
19
|
+
|
|
20
|
+
export function registerCapabilityRoutes(app: Hono, deps: ApiRouteDeps): void {
|
|
21
|
+
const { db, settings } = deps;
|
|
22
|
+
|
|
23
|
+
app.get("/v1/workspaces/:workspaceId/capabilities", async (c) => {
|
|
24
|
+
const workspaceId = c.req.param("workspaceId");
|
|
25
|
+
await requireAccessGrant(c, deps, workspaceId, "workspace:read");
|
|
26
|
+
return c.json(CapabilityCatalogResponse.parse(await buildCapabilityCatalog({ db, workspaceId, settings })));
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
app.post("/v1/workspaces/:workspaceId/capabilities", async (c) => {
|
|
30
|
+
const workspaceId = c.req.param("workspaceId");
|
|
31
|
+
const grant = await requireAccessGrant(c, deps, workspaceId, "workspace:admin");
|
|
32
|
+
const payload = CreateCapabilityCatalogItemRequest.parse(await c.req.json());
|
|
33
|
+
return c.json(await createCatalogItem({ db, accountId: grant.accountId, workspaceId, payload }), 201);
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
app.get("/v1/workspaces/:workspaceId/capabilities/discovery/mcp-registry", async (c) => {
|
|
37
|
+
const workspaceId = c.req.param("workspaceId");
|
|
38
|
+
await requireAccessGrant(c, deps, workspaceId, "workspace:read");
|
|
39
|
+
const query = c.req.query("query");
|
|
40
|
+
const options: { query?: string; limit?: number } = { limit: boundedLimit(c.req.query("limit")) };
|
|
41
|
+
if (query) {
|
|
42
|
+
options.query = query;
|
|
43
|
+
}
|
|
44
|
+
const items = await discoverMcpRegistryCapabilities(options);
|
|
45
|
+
return c.json(DiscoverMcpCapabilitiesResponse.parse({
|
|
46
|
+
items,
|
|
47
|
+
source: "official_mcp_registry",
|
|
48
|
+
sourceUrl: officialMcpRegistryUrl,
|
|
49
|
+
}));
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
app.post("/v1/workspaces/:workspaceId/capabilities/:capabilityId/enable", async (c) => {
|
|
53
|
+
const workspaceId = c.req.param("workspaceId");
|
|
54
|
+
const grant = await requireAccessGrant(c, deps, workspaceId, "workspace:admin");
|
|
55
|
+
const payload = EnableCapabilityRequest.parse(await c.req.json());
|
|
56
|
+
const installation = await enableCapability({
|
|
57
|
+
db,
|
|
58
|
+
grant,
|
|
59
|
+
accountId: grant.accountId,
|
|
60
|
+
workspaceId,
|
|
61
|
+
settings,
|
|
62
|
+
capabilityId: decodeURIComponent(c.req.param("capabilityId")),
|
|
63
|
+
payload,
|
|
64
|
+
});
|
|
65
|
+
return c.json(installation, 201);
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
app.post("/v1/workspaces/:workspaceId/capabilities/:capabilityId/disable", async (c) => {
|
|
69
|
+
const workspaceId = c.req.param("workspaceId");
|
|
70
|
+
const grant = await requireAccessGrant(c, deps, workspaceId, "workspace:admin");
|
|
71
|
+
const installation = await disableCapability({
|
|
72
|
+
db,
|
|
73
|
+
accountId: grant.accountId,
|
|
74
|
+
workspaceId,
|
|
75
|
+
settings,
|
|
76
|
+
capabilityId: decodeURIComponent(c.req.param("capabilityId")),
|
|
77
|
+
});
|
|
78
|
+
return c.json(installation);
|
|
79
|
+
});
|
|
80
|
+
}
|