@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.
@@ -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
- app.get("/.well-known/oauth-authorization-server", (c) => {
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" ||
@@ -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)) {
@@ -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: true }),
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<{ id: string; amountOffCents: number | null; percentOff: number | null }> {
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 { id: promotionCode.id, amountOffCents, percentOff: coupon.percent_off ?? null };
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 applyCreditLedgerEntry(deps.db, {
800
- accountId,
801
- type: "credit_dispute_release",
802
- amountMicros: debitMicros,
803
- sourceType: "stripe_dispute",
804
- sourceId: dispute.id,
805
- idempotencyKey: `stripe:dispute_release:${dispute.id}`,
806
- metadata: { stripeDisputeId: dispute.id, stripeEventType: event.type },
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
  });
@@ -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: "Session tenancy is not activated for this organization.",
5165
+ message: "Only-me chats are not enabled for this organization.",
5156
5166
  retryable: false,
5157
5167
  details: { reason: "not_activated" },
5158
5168
  });
@@ -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) => {