@opengeni/api-router 1.0.2 → 1.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.js +1 -1
- package/dist/artifact-catalog-cursor.d.ts +1 -0
- package/dist/{chunk-6TZ5LG3O.js → chunk-BAZXDFT2.js} +353 -161
- package/dist/{chunk-6TZ5LG3O.js.map → chunk-BAZXDFT2.js.map} +1 -1
- package/dist/credit-promotion-snapshot.d.ts +6 -0
- package/dist/index.js +2 -3
- package/dist/index.js.map +1 -1
- package/dist/integrations/marketplace-plugin.d.ts +43 -43
- package/dist/mcp/server.d.ts +1 -1
- package/dist/routes/billing.d.ts +3 -1
- package/package.json +20 -20
- package/src/app.ts +8 -2
- package/src/artifact-catalog-cursor.ts +1 -0
- package/src/credit-promotion-snapshot.ts +36 -0
- package/src/index.ts +0 -2
- package/src/mcp/action-catalog.gen.ts +35 -16
- package/src/mcp/server.ts +3 -0
- package/src/mcp-oauth.ts +8 -2
- package/src/model-catalog.ts +1 -0
- package/src/routes/artifact-catalog.ts +92 -0
- package/src/routes/billing.ts +72 -15
- package/src/routes/managed-onboarding.ts +2 -0
- package/src/routes/organization-memberships.ts +2 -5
- package/src/routes/sessions.ts +11 -1
- package/src/routes/workspaces.ts +27 -12
|
@@ -1763,14 +1763,9 @@ export const ACTION_CATALOG: readonly ActionCatalogEntry[] = [
|
|
|
1763
1763
|
"method": "GET",
|
|
1764
1764
|
"path": "/v1/organizations/:accountId/insights/calls",
|
|
1765
1765
|
"request": [],
|
|
1766
|
-
"response": [
|
|
1767
|
-
|
|
1768
|
-
|
|
1769
|
-
"id": "GET /v1/organizations/:accountId/insights/usage",
|
|
1770
|
-
"method": "GET",
|
|
1771
|
-
"path": "/v1/organizations/:accountId/insights/usage",
|
|
1772
|
-
"request": [],
|
|
1773
|
-
"response": []
|
|
1766
|
+
"response": [
|
|
1767
|
+
"InsightsCallsResponse"
|
|
1768
|
+
]
|
|
1774
1769
|
},
|
|
1775
1770
|
{
|
|
1776
1771
|
"id": "GET /v1/organizations/:organizationId/codex/accounts",
|
|
@@ -1923,14 +1918,9 @@ export const ACTION_CATALOG: readonly ActionCatalogEntry[] = [
|
|
|
1923
1918
|
"method": "GET",
|
|
1924
1919
|
"path": "/v1/workspaces/:workspaceId/insights/calls",
|
|
1925
1920
|
"request": [],
|
|
1926
|
-
"response": [
|
|
1927
|
-
|
|
1928
|
-
|
|
1929
|
-
"id": "GET /v1/workspaces/:workspaceId/insights/usage",
|
|
1930
|
-
"method": "GET",
|
|
1931
|
-
"path": "/v1/workspaces/:workspaceId/insights/usage",
|
|
1932
|
-
"request": [],
|
|
1933
|
-
"response": []
|
|
1921
|
+
"response": [
|
|
1922
|
+
"InsightsCallsResponse"
|
|
1923
|
+
]
|
|
1934
1924
|
},
|
|
1935
1925
|
{
|
|
1936
1926
|
"id": "GET /v1/workspaces/:workspaceId/instruction-policies/:revisionId",
|
|
@@ -2491,6 +2481,15 @@ export const ACTION_CATALOG: readonly ActionCatalogEntry[] = [
|
|
|
2491
2481
|
"GetOrganizationCredentialProviderResponse"
|
|
2492
2482
|
]
|
|
2493
2483
|
},
|
|
2484
|
+
{
|
|
2485
|
+
"id": "getOrganizationInsightsUsage",
|
|
2486
|
+
"method": "GET",
|
|
2487
|
+
"path": "/v1/organizations/:accountId/insights/usage",
|
|
2488
|
+
"request": [],
|
|
2489
|
+
"response": [
|
|
2490
|
+
"InsightsUsageResponse"
|
|
2491
|
+
]
|
|
2492
|
+
},
|
|
2494
2493
|
{
|
|
2495
2494
|
"id": "getOrganizationIntegrationCatalog",
|
|
2496
2495
|
"method": "GET",
|
|
@@ -2946,6 +2945,15 @@ export const ACTION_CATALOG: readonly ActionCatalogEntry[] = [
|
|
|
2946
2945
|
"WorkspaceInsightsResponse"
|
|
2947
2946
|
]
|
|
2948
2947
|
},
|
|
2948
|
+
{
|
|
2949
|
+
"id": "getWorkspaceInsightsUsage",
|
|
2950
|
+
"method": "GET",
|
|
2951
|
+
"path": "/v1/workspaces/:workspaceId/insights/usage",
|
|
2952
|
+
"request": [],
|
|
2953
|
+
"response": [
|
|
2954
|
+
"InsightsUsageResponse"
|
|
2955
|
+
]
|
|
2956
|
+
},
|
|
2949
2957
|
{
|
|
2950
2958
|
"id": "getWorkspaceLearningHistory",
|
|
2951
2959
|
"method": "GET",
|
|
@@ -6106,6 +6114,17 @@ export const ACTION_CATALOG: readonly ActionCatalogEntry[] = [
|
|
|
6106
6114
|
"PrReviewAppRegistration"
|
|
6107
6115
|
]
|
|
6108
6116
|
},
|
|
6117
|
+
{
|
|
6118
|
+
"id": "updateArtifactPin",
|
|
6119
|
+
"method": "PUT",
|
|
6120
|
+
"path": "/v1/workspaces/:workspaceId/artifact-catalog/:kind/:artifactId/pin",
|
|
6121
|
+
"request": [
|
|
6122
|
+
"ArtifactCatalogKind"
|
|
6123
|
+
],
|
|
6124
|
+
"response": [
|
|
6125
|
+
"ArtifactPinResponse"
|
|
6126
|
+
]
|
|
6127
|
+
},
|
|
6109
6128
|
{
|
|
6110
6129
|
"id": "updateBrowserIdentity",
|
|
6111
6130
|
"method": "PATCH",
|
package/src/mcp/server.ts
CHANGED
|
@@ -9,6 +9,7 @@ import {
|
|
|
9
9
|
setSessionChannel,
|
|
10
10
|
} from "@opengeni/db";
|
|
11
11
|
import { createHash, randomUUID } from "node:crypto";
|
|
12
|
+
import { assertGoalResumeAllowed } from "@opengeni/core";
|
|
12
13
|
import { mintEnrollToken } from "../sandbox/enrollment";
|
|
13
14
|
import { capabilityAccountReadiness } from "./capability-account-readiness";
|
|
14
15
|
import {
|
|
@@ -3076,6 +3077,8 @@ function registerGoalTools(
|
|
|
3076
3077
|
sessionId,
|
|
3077
3078
|
{
|
|
3078
3079
|
status: "active",
|
|
3080
|
+
beforeResume: (tx, session, causalTurn) =>
|
|
3081
|
+
assertGoalResumeAllowed({ ...deps, db: tx }, session, causalTurn),
|
|
3079
3082
|
event: { type: "goal.resumed", actor: "agent" },
|
|
3080
3083
|
},
|
|
3081
3084
|
);
|
package/src/mcp-oauth.ts
CHANGED
|
@@ -79,7 +79,10 @@ export type McpOAuthRouteAccess = {
|
|
|
79
79
|
};
|
|
80
80
|
|
|
81
81
|
export function registerMcpOAuthRoutes(app: Hono, deps: ApiRouteDeps): void {
|
|
82
|
-
|
|
82
|
+
// RFC 8414 path-aware discovery: MCP clients (Claude among them) ask for the
|
|
83
|
+
// metadata at /.well-known/oauth-authorization-server/<resource path> first
|
|
84
|
+
// and give up on anything but JSON. Serve the same document at both.
|
|
85
|
+
const authorizationServerMetadata = (c: Context) => {
|
|
83
86
|
requireMcpOAuthEnabled(deps);
|
|
84
87
|
const issuer = mcpOAuthIssuer(deps);
|
|
85
88
|
c.header("cache-control", "public, max-age=300");
|
|
@@ -97,7 +100,9 @@ export function registerMcpOAuthRoutes(app: Hono, deps: ApiRouteDeps): void {
|
|
|
97
100
|
authorization_response_iss_parameter_supported: true,
|
|
98
101
|
}),
|
|
99
102
|
);
|
|
100
|
-
}
|
|
103
|
+
};
|
|
104
|
+
app.get("/.well-known/oauth-authorization-server", authorizationServerMetadata);
|
|
105
|
+
app.get("/.well-known/oauth-authorization-server/*", authorizationServerMetadata);
|
|
101
106
|
|
|
102
107
|
app.get("/.well-known/oauth-protected-resource/*", (c) => {
|
|
103
108
|
requireMcpOAuthEnabled(deps);
|
|
@@ -563,6 +568,7 @@ export function mcpOAuthConsentToolIdentities(catalog: ToolGatewayCatalog): Tool
|
|
|
563
568
|
export function isMcpOAuthPublicProtocolPath(pathname: string): boolean {
|
|
564
569
|
return (
|
|
565
570
|
pathname === "/.well-known/oauth-authorization-server" ||
|
|
571
|
+
pathname.startsWith("/.well-known/oauth-authorization-server/") ||
|
|
566
572
|
pathname.startsWith("/.well-known/oauth-protected-resource/") ||
|
|
567
573
|
pathname === "/oauth/register" ||
|
|
568
574
|
pathname === "/oauth/authorize" ||
|
package/src/model-catalog.ts
CHANGED
|
@@ -74,6 +74,7 @@ export function projectClientModel(model: ConfiguredModel): ClientModel {
|
|
|
74
74
|
id: model.id,
|
|
75
75
|
label: model.label,
|
|
76
76
|
...(model.shortLabel ? { shortLabel: model.shortLabel } : {}),
|
|
77
|
+
...(model.logoUrl ? { logoUrl: model.logoUrl } : {}),
|
|
77
78
|
...publicProvider,
|
|
78
79
|
...(source === undefined ? {} : { source }),
|
|
79
80
|
api: model.api,
|
|
@@ -4,6 +4,8 @@ import {
|
|
|
4
4
|
ArtifactCatalogKind,
|
|
5
5
|
ArtifactCatalogListQuery,
|
|
6
6
|
ArtifactCatalogListResponse,
|
|
7
|
+
ArtifactPinResponse,
|
|
8
|
+
UpdateArtifactPinRequest,
|
|
7
9
|
retainedArtifactReferenceFromFile,
|
|
8
10
|
retainedGeneratedImageReferenceFromFile,
|
|
9
11
|
type AccessGrant,
|
|
@@ -26,6 +28,8 @@ import {
|
|
|
26
28
|
getGeneratedImageArtifact,
|
|
27
29
|
getSessionAuthorityProjection,
|
|
28
30
|
listArtifactCatalogCandidates,
|
|
31
|
+
nestedPostgresSqlState,
|
|
32
|
+
updateArtifactPin,
|
|
29
33
|
withSessionRlsActorContext,
|
|
30
34
|
type ArtifactCatalogCandidate,
|
|
31
35
|
type ArtifactCatalogPosition,
|
|
@@ -33,6 +37,7 @@ import {
|
|
|
33
37
|
} from "@opengeni/db";
|
|
34
38
|
import type { Hono } from "hono";
|
|
35
39
|
import { HTTPException } from "hono/http-exception";
|
|
40
|
+
import { z } from "zod";
|
|
36
41
|
import { artifactCatalogCursorCodec } from "../artifact-catalog-cursor";
|
|
37
42
|
import {
|
|
38
43
|
editableArtifactActorForGrant,
|
|
@@ -51,9 +56,95 @@ const position = (row: ArtifactCatalogCandidate): ArtifactCatalogPosition => ({
|
|
|
51
56
|
key: row.sort_key,
|
|
52
57
|
kind: row.kind,
|
|
53
58
|
id: row.id,
|
|
59
|
+
pinned: row.pinned ?? false,
|
|
54
60
|
});
|
|
55
61
|
|
|
56
62
|
export function registerArtifactCatalogRoutes(app: Hono, deps: Dependencies): void {
|
|
63
|
+
app.put("/v1/workspaces/:workspaceId/artifact-catalog/:kind/:artifactId/pin", async (context) => {
|
|
64
|
+
const workspaceId = context.req.param("workspaceId");
|
|
65
|
+
const authorization = await requireAccessGrantAuthorization(
|
|
66
|
+
context,
|
|
67
|
+
deps,
|
|
68
|
+
workspaceId,
|
|
69
|
+
"artifacts:publish",
|
|
70
|
+
);
|
|
71
|
+
const grant = authorization.grant;
|
|
72
|
+
if (!hasPermission(grant.permissions, "artifacts:publish"))
|
|
73
|
+
throw new HTTPException(403, { message: "Artifact publish access required" });
|
|
74
|
+
const kind = ArtifactCatalogKind.safeParse(context.req.param("kind"));
|
|
75
|
+
const body = UpdateArtifactPinRequest.safeParse(await context.req.json().catch(() => null));
|
|
76
|
+
const requestedId = context.req.param("artifactId");
|
|
77
|
+
if (
|
|
78
|
+
!kind.success ||
|
|
79
|
+
!body.success ||
|
|
80
|
+
!(editableKinds.has(kind.data)
|
|
81
|
+
? /^[0-9a-f]{32}$/.test(requestedId)
|
|
82
|
+
: z.string().uuid().safeParse(requestedId).success)
|
|
83
|
+
)
|
|
84
|
+
throw new HTTPException(422, { message: "Invalid artifact pin request" });
|
|
85
|
+
const artifactId = editableKinds.has(kind.data) ? requestedId : requestedId.toLowerCase();
|
|
86
|
+
const fileKind = kind.data === "file" || kind.data === "image";
|
|
87
|
+
if (!hasPermission(grant.permissions, fileKind ? "files:read" : "artifacts:read"))
|
|
88
|
+
throw new HTTPException(403, { message: "Artifact read access required" });
|
|
89
|
+
const actor = fileKind
|
|
90
|
+
? await fileOwnerContextForAccess(deps, authorization, "files:read")
|
|
91
|
+
: { subjectId: grant.subjectId, privateFileOwnerSubjectId: null };
|
|
92
|
+
context.header("cache-control", "private, no-store");
|
|
93
|
+
return withSessionRlsActorContext(actor, async () => {
|
|
94
|
+
if (editableKinds.has(kind.data)) {
|
|
95
|
+
if (!deps.editableArtifacts)
|
|
96
|
+
throw new HTTPException(503, { message: "Editable artifacts are unavailable" });
|
|
97
|
+
try {
|
|
98
|
+
const scope = editableArtifactScope({ accountId: grant.accountId, workspaceId });
|
|
99
|
+
const artifact = await deps.editableArtifacts.readArtifact({
|
|
100
|
+
scope,
|
|
101
|
+
actor: editableArtifactActorForGrant(grant, "0000000000000001"),
|
|
102
|
+
artifactId: editableArtifactId(artifactId),
|
|
103
|
+
});
|
|
104
|
+
if (
|
|
105
|
+
artifact.scope.accountId !== scope.accountId ||
|
|
106
|
+
artifact.scope.workspaceId !== scope.workspaceId ||
|
|
107
|
+
artifact.id !== artifactId ||
|
|
108
|
+
artifact.modality !== kind.data
|
|
109
|
+
)
|
|
110
|
+
throw new HTTPException(404, { message: "Artifact not found" });
|
|
111
|
+
} catch (error) {
|
|
112
|
+
if (isInvisibleListCandidate(error))
|
|
113
|
+
throw new HTTPException(404, { message: "Artifact not found" });
|
|
114
|
+
throw editableArtifactHttpError(error);
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
if (fileKind) {
|
|
118
|
+
const subjectId = await fileAuthoritySubjectIdForGrant(deps, grant);
|
|
119
|
+
const [file] = await getFilesForSubject(deps.db, {
|
|
120
|
+
accountId: grant.accountId,
|
|
121
|
+
workspaceId,
|
|
122
|
+
subjectId,
|
|
123
|
+
fileIds: [artifactId],
|
|
124
|
+
});
|
|
125
|
+
if (!file || file.status !== "ready")
|
|
126
|
+
throw new HTTPException(404, { message: "Artifact not found" });
|
|
127
|
+
}
|
|
128
|
+
try {
|
|
129
|
+
await updateArtifactPin(
|
|
130
|
+
deps.db,
|
|
131
|
+
{ accountId: grant.accountId, workspaceId },
|
|
132
|
+
{
|
|
133
|
+
kind: kind.data,
|
|
134
|
+
artifactId,
|
|
135
|
+
pinned: body.data.pinned,
|
|
136
|
+
},
|
|
137
|
+
);
|
|
138
|
+
} catch (error) {
|
|
139
|
+
if (nestedPostgresSqlState(error) === "42501")
|
|
140
|
+
throw new HTTPException(404, { message: "Artifact not found" });
|
|
141
|
+
throw error;
|
|
142
|
+
}
|
|
143
|
+
return context.json(
|
|
144
|
+
ArtifactPinResponse.parse({ kind: kind.data, artifactId, pinned: body.data.pinned }),
|
|
145
|
+
);
|
|
146
|
+
});
|
|
147
|
+
});
|
|
57
148
|
app.get("/v1/workspaces/:workspaceId/artifact-catalog", async (context) => {
|
|
58
149
|
const workspaceId = context.req.param("workspaceId");
|
|
59
150
|
const authorization = await requireAccessGrantAuthorization(context, deps, workspaceId);
|
|
@@ -210,6 +301,7 @@ async function projectCandidate(
|
|
|
210
301
|
status: row.status,
|
|
211
302
|
createdAt: iso(row.created_at),
|
|
212
303
|
updatedAt: iso(row.updated_at),
|
|
304
|
+
...(row.pinned !== undefined ? { pinned: row.pinned } : {}),
|
|
213
305
|
};
|
|
214
306
|
if (row.version_id) item.versionId = row.version_id;
|
|
215
307
|
if (editableKinds.has(row.kind)) {
|
package/src/routes/billing.ts
CHANGED
|
@@ -8,12 +8,15 @@ import {
|
|
|
8
8
|
OrganizationUsageWorkspacePageQuery,
|
|
9
9
|
type AccessContext,
|
|
10
10
|
type Permission,
|
|
11
|
+
type PromotionalCreditScope,
|
|
11
12
|
} from "@opengeni/contracts";
|
|
12
13
|
import { OrganizationModelUsageQuery } from "@opengeni/contracts/organization-model-usage";
|
|
13
|
-
import { configuredEntitlements } from "@opengeni/config";
|
|
14
|
+
import { configuredEntitlements, promotionalCreditScope } from "@opengeni/config";
|
|
15
|
+
import { creditScopeMetadata, creditScopeFromMetadata } from "../credit-promotion-snapshot";
|
|
14
16
|
import {
|
|
15
17
|
applyCreditLedgerEntry,
|
|
16
18
|
getBillingBalance,
|
|
19
|
+
readCreditPromotionPolicy,
|
|
17
20
|
getBillingCustomer,
|
|
18
21
|
getCreditLedgerEntry,
|
|
19
22
|
hasCreditLedgerEntry,
|
|
@@ -23,6 +26,7 @@ import {
|
|
|
23
26
|
getOrganizationUsageSummary,
|
|
24
27
|
getOrganizationUsageWorkspacePage,
|
|
25
28
|
withSessionRlsActorContext,
|
|
29
|
+
withRlsContext,
|
|
26
30
|
getManagedAccount,
|
|
27
31
|
markStripeWebhookProcessed,
|
|
28
32
|
recordStripeWebhookEvent,
|
|
@@ -134,6 +138,15 @@ export function registerBillingRoutes(app: Hono, deps: ApiRouteDeps): void {
|
|
|
134
138
|
const promotion = body.promotionCode
|
|
135
139
|
? await resolveCheckoutPromotionCode(stripe, body.promotionCode)
|
|
136
140
|
: null;
|
|
141
|
+
const promotionalScope = promotion
|
|
142
|
+
? promotionalCreditScope(
|
|
143
|
+
{
|
|
144
|
+
creditPromotionPolicy:
|
|
145
|
+
(await readCreditPromotionPolicy(deps.db)) ?? deps.settings.creditPromotionPolicy,
|
|
146
|
+
},
|
|
147
|
+
promotion.couponId,
|
|
148
|
+
)
|
|
149
|
+
: undefined;
|
|
137
150
|
const amountCents =
|
|
138
151
|
body.amountUsd !== undefined
|
|
139
152
|
? usdToCents(body.amountUsd)
|
|
@@ -150,6 +163,15 @@ export function registerBillingRoutes(app: Hono, deps: ApiRouteDeps): void {
|
|
|
150
163
|
message: "Credits must be between $5 and $10,000.",
|
|
151
164
|
});
|
|
152
165
|
}
|
|
166
|
+
if (
|
|
167
|
+
promotionalScope &&
|
|
168
|
+
(!promotion?.amountOffCents || amountCents !== promotion.amountOffCents)
|
|
169
|
+
) {
|
|
170
|
+
throw new HTTPException(422, {
|
|
171
|
+
message:
|
|
172
|
+
"Redeem this offer for its exact credit amount. Buy additional credits separately.",
|
|
173
|
+
});
|
|
174
|
+
}
|
|
153
175
|
const amountMicros = centsToMicros(amountCents);
|
|
154
176
|
const customerId = await getOrCreateStripeCustomer(deps, stripe, context, accountId);
|
|
155
177
|
const idempotencyKey = `checkout:${accountId}:${amountMicros}:${crypto.randomUUID()}`;
|
|
@@ -167,9 +189,11 @@ export function registerBillingRoutes(app: Hono, deps: ApiRouteDeps): void {
|
|
|
167
189
|
successUrl: body.successUrl,
|
|
168
190
|
cancelUrl: body.cancelUrl,
|
|
169
191
|
idempotencyKey,
|
|
192
|
+
promotionalScope,
|
|
170
193
|
...(promotion
|
|
171
194
|
? {
|
|
172
195
|
promotionCodeId: promotion.id,
|
|
196
|
+
couponId: promotion.couponId,
|
|
173
197
|
fullyDiscounted:
|
|
174
198
|
promotion.percentOff === 100 || (promotion.amountOffCents ?? 0) >= amountCents,
|
|
175
199
|
}
|
|
@@ -193,6 +217,7 @@ export function registerBillingRoutes(app: Hono, deps: ApiRouteDeps): void {
|
|
|
193
217
|
checkoutSessionId: session.id,
|
|
194
218
|
url: session.url,
|
|
195
219
|
amountUsd: amountCents / 100,
|
|
220
|
+
promotionalScope,
|
|
196
221
|
}),
|
|
197
222
|
);
|
|
198
223
|
});
|
|
@@ -241,6 +266,7 @@ export function registerBillingRoutes(app: Hono, deps: ApiRouteDeps): void {
|
|
|
241
266
|
state: entry ? "granted" : "pending",
|
|
242
267
|
amountMicros: entry?.amountMicros ?? credit.amountMicros,
|
|
243
268
|
currency: "usd",
|
|
269
|
+
promotionalScope: creditScopeFromMetadata(session.metadata),
|
|
244
270
|
free: entry
|
|
245
271
|
? entry.sourceType === "stripe_checkout_coupon"
|
|
246
272
|
: isFreeCouponCheckout(session),
|
|
@@ -340,6 +366,8 @@ export function stripeCheckoutSessionCreateParams(input: {
|
|
|
340
366
|
idempotencyKey: string;
|
|
341
367
|
/** Apply this promotion code up front instead of letting Stripe ask for one. */
|
|
342
368
|
promotionCodeId?: string | undefined;
|
|
369
|
+
couponId?: string | undefined;
|
|
370
|
+
promotionalScope?: PromotionalCreditScope | undefined;
|
|
343
371
|
/**
|
|
344
372
|
* The applied code covers the whole package, so the total is $0. Nothing is
|
|
345
373
|
* taxable, so Checkout skips tax and with it the billing address: the
|
|
@@ -366,7 +394,7 @@ export function stripeCheckoutSessionCreateParams(input: {
|
|
|
366
394
|
// Stripe takes either a code field on its page or one discount up front.
|
|
367
395
|
...(input.promotionCodeId
|
|
368
396
|
? { discounts: [{ promotion_code: input.promotionCodeId }] }
|
|
369
|
-
: { allow_promotion_codes:
|
|
397
|
+
: { allow_promotion_codes: false }),
|
|
370
398
|
customer: input.customerId,
|
|
371
399
|
customer_update: {
|
|
372
400
|
address: "auto",
|
|
@@ -397,6 +425,8 @@ export function stripeCheckoutSessionCreateParams(input: {
|
|
|
397
425
|
},
|
|
398
426
|
],
|
|
399
427
|
metadata: {
|
|
428
|
+
...creditScopeMetadata(input.promotionalScope),
|
|
429
|
+
...(input.couponId ? { opengeni_credit_offer_id: input.couponId } : {}),
|
|
400
430
|
opengeni_account_id: input.accountId,
|
|
401
431
|
opengeni_credit_amount_usd: (input.amountCents / 100).toFixed(2),
|
|
402
432
|
opengeni_credit_micros: String(input.amountMicros),
|
|
@@ -607,6 +637,10 @@ async function grantCheckoutSessionCredits(
|
|
|
607
637
|
}
|
|
608
638
|
const credit = decision.credit;
|
|
609
639
|
const freeCouponCheckout = isFreeCouponCheckout(session);
|
|
640
|
+
const promotionalScope = creditScopeFromMetadata(session.metadata);
|
|
641
|
+
if (promotionalScope && !freeCouponCheckout) {
|
|
642
|
+
throw new Error("Scoped promotional credits require a fully discounted checkout");
|
|
643
|
+
}
|
|
610
644
|
if (!(await getManagedAccount(deps.db, credit.accountId))) {
|
|
611
645
|
// Another OpenGeni deployment sharing the Stripe account (or an account
|
|
612
646
|
// removed before a delayed payment settled). Retrying cannot succeed, so
|
|
@@ -636,10 +670,13 @@ async function grantCheckoutSessionCredits(
|
|
|
636
670
|
accountId: credit.accountId,
|
|
637
671
|
type: freeCouponCheckout ? "grant" : "credit_topup",
|
|
638
672
|
amountMicros: credit.amountMicros,
|
|
673
|
+
eligibleModelIds: promotionalScope?.eligibleModelIds,
|
|
639
674
|
sourceType: freeCouponCheckout ? "stripe_checkout_coupon" : "stripe_checkout_session",
|
|
640
675
|
sourceId: session.id,
|
|
641
676
|
idempotencyKey: credit.idempotencyKey,
|
|
642
677
|
metadata: {
|
|
678
|
+
...(promotionalScope ? { creditOfferLabel: promotionalScope.label } : {}),
|
|
679
|
+
creditOfferId: session.metadata?.opengeni_credit_offer_id ?? null,
|
|
643
680
|
stripeEventId: source.stripeEventId,
|
|
644
681
|
stripePaymentIntentId:
|
|
645
682
|
typeof session.payment_intent === "string"
|
|
@@ -665,7 +702,12 @@ async function grantCheckoutSessionCredits(
|
|
|
665
702
|
async function resolveCheckoutPromotionCode(
|
|
666
703
|
stripe: Stripe,
|
|
667
704
|
code: string,
|
|
668
|
-
): Promise<{
|
|
705
|
+
): Promise<{
|
|
706
|
+
id: string;
|
|
707
|
+
couponId: string;
|
|
708
|
+
amountOffCents: number | null;
|
|
709
|
+
percentOff: number | null;
|
|
710
|
+
}> {
|
|
669
711
|
const listed = await stripe.promotionCodes.list({
|
|
670
712
|
code,
|
|
671
713
|
active: true,
|
|
@@ -684,7 +726,12 @@ async function resolveCheckoutPromotionCode(
|
|
|
684
726
|
coupon.amount_off && coupon.currency === "usd"
|
|
685
727
|
? coupon.amount_off
|
|
686
728
|
: (coupon.currency_options?.usd?.amount_off ?? null);
|
|
687
|
-
return {
|
|
729
|
+
return {
|
|
730
|
+
id: promotionCode.id,
|
|
731
|
+
couponId: coupon.id,
|
|
732
|
+
amountOffCents,
|
|
733
|
+
percentOff: coupon.percent_off ?? null,
|
|
734
|
+
};
|
|
688
735
|
}
|
|
689
736
|
|
|
690
737
|
async function mirrorPaymentIntentCustomer(deps: ApiRouteDeps, event: Stripe.Event): Promise<void> {
|
|
@@ -792,18 +839,28 @@ async function releaseDisputedCredits(
|
|
|
792
839
|
return;
|
|
793
840
|
}
|
|
794
841
|
const holdIdempotencyKey = `stripe:dispute_hold:${dispute.id}`;
|
|
795
|
-
if (!(await hasCreditLedgerEntry(deps.db, accountId, holdIdempotencyKey))) {
|
|
796
|
-
return;
|
|
797
|
-
}
|
|
798
842
|
const debitMicros = await creditAdjustmentMicros(deps, metadata, dispute.amount);
|
|
799
|
-
await
|
|
800
|
-
|
|
801
|
-
|
|
802
|
-
|
|
803
|
-
|
|
804
|
-
|
|
805
|
-
|
|
806
|
-
|
|
843
|
+
await withRlsContext(deps.db, { accountId }, async (tx) => {
|
|
844
|
+
// Stripe can deliver the resolution before the hold. Materialize both
|
|
845
|
+
// receipts atomically so a late hold cannot withhold restored credits.
|
|
846
|
+
await applyCreditLedgerEntry(tx, {
|
|
847
|
+
accountId,
|
|
848
|
+
type: "credit_dispute_hold",
|
|
849
|
+
amountMicros: -debitMicros,
|
|
850
|
+
sourceType: "stripe_dispute",
|
|
851
|
+
sourceId: dispute.id,
|
|
852
|
+
idempotencyKey: holdIdempotencyKey,
|
|
853
|
+
metadata: { stripeDisputeId: dispute.id, stripeEventType: event.type },
|
|
854
|
+
});
|
|
855
|
+
await applyCreditLedgerEntry(tx, {
|
|
856
|
+
accountId,
|
|
857
|
+
type: "credit_dispute_release",
|
|
858
|
+
amountMicros: debitMicros,
|
|
859
|
+
sourceType: "stripe_dispute",
|
|
860
|
+
sourceId: dispute.id,
|
|
861
|
+
idempotencyKey: `stripe:dispute_release:${dispute.id}`,
|
|
862
|
+
metadata: { stripeDisputeId: dispute.id, stripeEventType: event.type },
|
|
863
|
+
});
|
|
807
864
|
});
|
|
808
865
|
}
|
|
809
866
|
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { signupCreditModelIds } from "@opengeni/config";
|
|
1
2
|
import {
|
|
2
3
|
CompleteSelfServiceOrganizationSetupRequest,
|
|
3
4
|
CompleteSelfServiceOrganizationSetupResponse,
|
|
@@ -80,6 +81,7 @@ export function registerManagedOnboardingRoutes(
|
|
|
80
81
|
organizationName,
|
|
81
82
|
operationId: parsed.data.operationId,
|
|
82
83
|
requestFingerprint,
|
|
84
|
+
trialCreditModelIds: signupCreditModelIds(deps.settings),
|
|
83
85
|
trialCreditsEnabled:
|
|
84
86
|
deps.settings.productAccessMode === "managed" &&
|
|
85
87
|
deps.settings.verifiedSignupTrialCreditsEnabled,
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { signupCreditModelIds } from "@opengeni/config";
|
|
1
2
|
import {
|
|
2
3
|
lookupExternalIdentityForRequest,
|
|
3
4
|
cancelExternalWorkspaceMemberGrantForRequest,
|
|
@@ -310,6 +311,7 @@ export function registerOrganizationMembershipRoutes(app: Hono, deps: ApiRouteDe
|
|
|
310
311
|
subjectId,
|
|
311
312
|
subjectLabel: session.user.email || session.user.name,
|
|
312
313
|
...payload,
|
|
314
|
+
trialCreditModelIds: signupCreditModelIds(deps.settings),
|
|
313
315
|
trialCreditsEnabled:
|
|
314
316
|
deps.settings.productAccessMode === "managed" &&
|
|
315
317
|
deps.settings.verifiedSignupTrialCreditsEnabled,
|
|
@@ -691,11 +693,6 @@ export function registerOrganizationMembershipRoutes(app: Hono, deps: ApiRouteDe
|
|
|
691
693
|
),
|
|
692
694
|
);
|
|
693
695
|
} catch (error) {
|
|
694
|
-
if (nestedPostgresSqlState(error) === "55000") {
|
|
695
|
-
throw new HTTPException(409, {
|
|
696
|
-
message: "private-session readiness is not activated for this organization",
|
|
697
|
-
});
|
|
698
|
-
}
|
|
699
696
|
rethrowMembershipError(error);
|
|
700
697
|
}
|
|
701
698
|
});
|
package/src/routes/sessions.ts
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { getRetainedProviderCommand } from "@opengeni/db/retained-provider-commands";
|
|
2
|
+
import { assertGoalResumeAllowed, GoalResumeBlockedError } from "@opengeni/core";
|
|
2
3
|
import { UnsupportedLatencyModeError } from "@opengeni/config";
|
|
3
4
|
import { searchSessionMessagesForSubject, SessionMessageSearchCursorError } from "@opengeni/db";
|
|
4
5
|
import { listSessionEventSlices } from "@opengeni/db/session-event-slices";
|
|
@@ -2748,6 +2749,15 @@ export function registerSessionRoutes(app: Hono, deps: SessionRouteDeps): void {
|
|
|
2748
2749
|
sessionId,
|
|
2749
2750
|
{
|
|
2750
2751
|
status: "active",
|
|
2752
|
+
beforeResume: (tx, session, causalTurn) =>
|
|
2753
|
+
assertGoalResumeAllowed({ ...deps, db: tx }, session, causalTurn).catch(
|
|
2754
|
+
(error: unknown) => {
|
|
2755
|
+
if (error instanceof GoalResumeBlockedError) {
|
|
2756
|
+
throw new HTTPException(422, { message: error.message, cause: error });
|
|
2757
|
+
}
|
|
2758
|
+
throw error;
|
|
2759
|
+
},
|
|
2760
|
+
),
|
|
2751
2761
|
event: { type: "goal.resumed", actor: "api" },
|
|
2752
2762
|
},
|
|
2753
2763
|
);
|
|
@@ -5152,7 +5162,7 @@ export function sessionTenancyHttpError(error: unknown): Error {
|
|
|
5152
5162
|
if (error instanceof SessionTenancyNotActivatedError) {
|
|
5153
5163
|
return new ApiHttpError(409, {
|
|
5154
5164
|
code: "conflict",
|
|
5155
|
-
message: "
|
|
5165
|
+
message: "Only-me chats are not enabled for this organization.",
|
|
5156
5166
|
retryable: false,
|
|
5157
5167
|
details: { reason: "not_activated" },
|
|
5158
5168
|
});
|
package/src/routes/workspaces.ts
CHANGED
|
@@ -48,6 +48,8 @@ import {
|
|
|
48
48
|
import { loadWorkspaceCodexModelAvailability } from "@opengeni/core";
|
|
49
49
|
import {
|
|
50
50
|
allWorkspacePermissions,
|
|
51
|
+
getBillingBalance,
|
|
52
|
+
spendableCreditMicros,
|
|
51
53
|
createWorkspace,
|
|
52
54
|
ensureWorkspaceByExternalIdentity,
|
|
53
55
|
findWorkspaceByExternalIdentity,
|
|
@@ -621,25 +623,38 @@ export function registerWorkspaceRoutes(app: Hono, deps: ApiRouteDeps): void {
|
|
|
621
623
|
// The same precedence the server applies when a new chat, API create, or
|
|
622
624
|
// scheduled occurrence names no model; published so pickers show it.
|
|
623
625
|
const workspaceSettings = workspace?.settings ?? {};
|
|
626
|
+
const creditBalance =
|
|
627
|
+
deps.settings.billingMode === "stripe"
|
|
628
|
+
? await getBillingBalance(deps.db, grant.accountId)
|
|
629
|
+
: undefined;
|
|
624
630
|
const defaultSelection = await resolveDefaultSessionModelForSelections(deps.db, {
|
|
625
631
|
settings: workspaceCatalogSettings,
|
|
626
632
|
accountId: grant.accountId,
|
|
627
633
|
workspaceSettings,
|
|
628
634
|
selections,
|
|
629
635
|
});
|
|
636
|
+
const catalog = projectWorkspaceModelCatalog(selections, {
|
|
637
|
+
defaultSelection,
|
|
638
|
+
creditsSelection: creditsDefaultSessionModel({
|
|
639
|
+
settings: workspaceCatalogSettings,
|
|
640
|
+
selections,
|
|
641
|
+
workspaceSettings,
|
|
642
|
+
}),
|
|
643
|
+
});
|
|
644
|
+
if (creditBalance) {
|
|
645
|
+
for (const model of catalog.models) {
|
|
646
|
+
if (model.cost !== "credits") continue;
|
|
647
|
+
model.creditFunding = creditBalance.promotionalCredits?.some(
|
|
648
|
+
(credit) => credit.remainingMicros > 0 && credit.eligibleModelIds.includes(model.id),
|
|
649
|
+
)
|
|
650
|
+
? "promotional"
|
|
651
|
+
: spendableCreditMicros(creditBalance, model.id) > 0
|
|
652
|
+
? "general"
|
|
653
|
+
: "unavailable";
|
|
654
|
+
}
|
|
655
|
+
}
|
|
630
656
|
c.header("cache-control", "private, no-store");
|
|
631
|
-
return c.json(
|
|
632
|
-
WorkspaceModelCatalogResponse.parse(
|
|
633
|
-
projectWorkspaceModelCatalog(selections, {
|
|
634
|
-
defaultSelection,
|
|
635
|
-
creditsSelection: creditsDefaultSessionModel({
|
|
636
|
-
settings: workspaceCatalogSettings,
|
|
637
|
-
selections,
|
|
638
|
-
workspaceSettings,
|
|
639
|
-
}),
|
|
640
|
-
}),
|
|
641
|
-
),
|
|
642
|
-
);
|
|
657
|
+
return c.json(WorkspaceModelCatalogResponse.parse(catalog));
|
|
643
658
|
});
|
|
644
659
|
|
|
645
660
|
app.get("/v1/workspaces/:workspaceId/gateway-custom-models", async (c) => {
|