create-ailk 0.23.0 → 0.24.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/README.md +27 -14
- package/dist/add-module.js +6 -1
- package/dist/add-route.js +7 -1
- package/dist/derive-architecture.js +17 -0
- package/dist/lib/scaffold-render-sweep.d.ts +33 -0
- package/dist/lib/scaffold-render-sweep.js +45 -0
- package/dist/module-architecture.js +17 -0
- package/dist/sync-routes.d.ts +30 -4
- package/dist/sync-routes.js +271 -18
- package/package.json +3 -3
- package/templates/.env.example +4 -0
- package/templates/CLAUDE.md +4 -2
- package/templates/apps/api/.env.example +7 -0
- package/templates/apps/api/CLAUDE.md +25 -11
- package/templates/apps/api/src/lib/__mocks__/prisma.ts +4 -1
- package/templates/apps/api/src/lib/__tests__/site.test.ts +16 -0
- package/templates/apps/api/src/lib/site.ts +4 -1
- package/templates/apps/api/src/lib/stripe.ts +3 -0
- package/templates/apps/api/src/openapi/__tests__/openapi.test.ts +127 -0
- package/templates/apps/api/src/openapi/spec.ts +333 -0
- package/templates/apps/api/src/routes/billing/plan-checkout.ts +18 -11
- package/templates/apps/api/src/routes/checkout/__tests__/offerings-authorization.test.ts +126 -0
- package/templates/apps/api/src/routes/content/get-page.ts +34 -119
- package/templates/apps/api/src/routes/content/list-pages.ts +15 -36
- package/templates/apps/api/src/routes/content/search.ts +16 -45
- package/templates/apps/api/src/routes/leads/index.ts +364 -298
- package/templates/apps/api/src/routes/project-listings/__tests__/me.test.ts +20 -9
- package/templates/apps/api/src/routes/project-listings/__tests__/public.test.ts +17 -6
- package/templates/apps/api/src/routes/project-listings/__tests__/site-answers.test.ts +1 -1
- package/templates/apps/api/src/routes/webhooks/__tests__/stripe-flow-checkout.test.ts +1 -0
- package/templates/apps/api/src/routes/webhooks/__tests__/stripe-site-filter.test.ts +1 -0
- package/templates/apps/api/src/routes/webhooks/stripe.ts +2 -0
- package/templates/apps/api/src/server.ts +25 -0
- package/templates/apps/api/src/services/__tests__/abandon-email.test.ts +121 -4
- package/templates/apps/api/src/services/__tests__/deliverable-fulfillment.test.ts +10 -0
- package/templates/apps/api/src/services/__tests__/person.test.ts +28 -0
- package/templates/apps/api/src/services/__tests__/plan-offerings.test.ts +6 -0
- package/templates/apps/api/src/services/__tests__/project-listings.counts.test.ts +202 -0
- package/templates/apps/api/src/services/__tests__/project-listings.fence.test.ts +15 -18
- package/templates/apps/api/src/services/abandon-email.ts +28 -5
- package/templates/apps/api/src/services/deliverable-fulfillment.ts +2 -7
- package/templates/apps/api/src/services/offerings/__tests__/catalog.test.ts +53 -0
- package/templates/apps/api/src/services/offerings/__tests__/connection.test.ts +107 -0
- package/templates/apps/api/src/services/offerings/__tests__/org-products.test.ts +4 -0
- package/templates/apps/api/src/services/offerings/__tests__/readers.test.ts +4 -0
- package/templates/apps/api/src/services/offerings/catalog.ts +10 -0
- package/templates/apps/api/src/services/offerings/checkout-authorization.ts +23 -5
- package/templates/apps/api/src/services/offerings/connection.ts +83 -3
- package/templates/apps/api/src/services/offerings/index.ts +6 -6
- package/templates/apps/api/src/services/person.ts +23 -0
- package/templates/apps/api/src/services/project-listings.ts +43 -31
- package/templates/apps/mcp/CLAUDE.md +11 -5
- package/templates/apps/mcp/__tests__/app-entry.test.ts +2 -2
- package/templates/apps/mcp/__tests__/http-transport.test.ts +91 -10
- package/templates/apps/mcp/__tests__/lib/content-adapter-parity.test.ts +8 -3
- package/templates/apps/mcp/__tests__/lib/content-adapter.test.ts +0 -25
- package/templates/apps/mcp/__tests__/lib/fail-closed-tools.test.ts +22 -7
- package/templates/apps/mcp/__tests__/list_pages.test.ts +84 -44
- package/templates/apps/mcp/__tests__/locale.test.ts +41 -56
- package/templates/apps/mcp/__tests__/rate-limit-write.test.ts +189 -0
- package/templates/apps/mcp/__tests__/read_content.test.ts +115 -78
- package/templates/apps/mcp/__tests__/request_callback.test.ts +63 -128
- package/templates/apps/mcp/__tests__/search_content.test.ts +76 -61
- package/templates/apps/mcp/__tests__/submit_lead.test.ts +82 -167
- package/templates/apps/mcp/__tests__/subscribe_newsletter.test.ts +59 -130
- package/templates/apps/mcp/package.json +1 -0
- package/templates/apps/mcp/src/abuse/index.ts +18 -11
- package/templates/apps/mcp/src/http-transport.ts +47 -9
- package/templates/apps/mcp/src/lib/content-adapter.ts +0 -26
- package/templates/apps/mcp/src/lib/empty-content-adapter.ts +5 -4
- package/templates/apps/mcp/src/lib/site-config.ts +53 -0
- package/templates/apps/mcp/src/tools/capture_lead.ts +30 -28
- package/templates/apps/mcp/src/tools/index.ts +22 -21
- package/templates/apps/mcp/src/tools/list_pages.ts +27 -33
- package/templates/apps/mcp/src/tools/read_content.ts +46 -40
- package/templates/apps/mcp/src/tools/request_callback.ts +21 -28
- package/templates/apps/mcp/src/tools/search_content.ts +20 -24
- package/templates/apps/mcp/src/tools/subscribe_newsletter.ts +23 -26
- package/templates/apps/web/CLAUDE.md +12 -3
- package/templates/apps/web/app/[locale]/contact/__tests__/ConversationalIntakeWrapper.test.tsx +1 -1
- package/templates/apps/web/app/[locale]/contact/contact.actions.ts +14 -4
- package/templates/apps/web/app/[locale]/contact/intake-script.ts +1 -1
- package/templates/apps/web/app/[locale]/flows/[slug]/magnet-page.tsx +21 -4
- package/templates/apps/web/lib/__tests__/magnet-routes.test.ts +7 -0
- package/templates/apps/web/lib/magnet-routes.ts +3 -1
- package/templates/apps/web/lib/newsletter.actions.ts +9 -6
- package/templates/database/CHANGELOG.md +20 -0
- package/templates/database/__tests__/seed.test.ts +33 -1
- package/templates/database/__tests__/trial-magnet-migration.test.ts +186 -0
- package/templates/database/migrations/20261025120000_trial_magnet/migration.sql +28 -0
- package/templates/database/migrations/20261026120000_trial_magnet_proof_line/migration.sql +23 -0
- package/templates/database/package.json +1 -1
- package/templates/database/seed.ts +27 -0
- package/templates/tools/lint-architecture.ts +205 -1
- package/versions.json +34 -34
- package/templates/apps/api/src/routes/content/slug.ts +0 -9
|
@@ -35,7 +35,7 @@ jest.mock("@working-theory/license", () => ({
|
|
|
35
35
|
jest.mock("../../lib/prisma.js", () => ({
|
|
36
36
|
prisma: {
|
|
37
37
|
projectListing: { findMany: jest.fn() },
|
|
38
|
-
waitlistScore: {
|
|
38
|
+
waitlistScore: { groupBy: jest.fn() },
|
|
39
39
|
},
|
|
40
40
|
}));
|
|
41
41
|
|
|
@@ -102,25 +102,25 @@ function listingRow(id: string, experimentKey: string | null): Record<string, un
|
|
|
102
102
|
|
|
103
103
|
beforeEach(() => {
|
|
104
104
|
jest.clearAllMocks();
|
|
105
|
-
(prisma.waitlistScore.
|
|
105
|
+
(prisma.waitlistScore.groupBy as jest.Mock).mockResolvedValue([]);
|
|
106
106
|
});
|
|
107
107
|
|
|
108
108
|
describe("attachWaitlistCounts — the fenced count filters on workspaceId beside siteId (#6258 AC-6)", () => {
|
|
109
|
-
it("prisma.waitlistScore.
|
|
109
|
+
it("prisma.waitlistScore.groupBy's where carries workspaceId === the resolved scope id, by value", async () => {
|
|
110
110
|
(prisma.projectListing.findMany as jest.Mock).mockResolvedValue([
|
|
111
111
|
listingRow("plst_a", EXPERIMENT_A),
|
|
112
112
|
]);
|
|
113
113
|
|
|
114
114
|
await listProjectListings(SITE);
|
|
115
115
|
|
|
116
|
-
expect((prisma.waitlistScore.
|
|
116
|
+
expect((prisma.waitlistScore.groupBy as jest.Mock).mock.calls[0]![0]!.where).toEqual({
|
|
117
117
|
siteId: SITE,
|
|
118
118
|
workspaceId: SITE,
|
|
119
|
-
experimentKey: EXPERIMENT_A,
|
|
119
|
+
experimentKey: { in: [EXPERIMENT_A] },
|
|
120
120
|
});
|
|
121
121
|
});
|
|
122
122
|
|
|
123
|
-
it("every DISTINCT experimentKey
|
|
123
|
+
it("every DISTINCT experimentKey sits under the same two scope terms — one grouped read, no more (#9243)", async () => {
|
|
124
124
|
(prisma.projectListing.findMany as jest.Mock).mockResolvedValue([
|
|
125
125
|
listingRow("plst_a", EXPERIMENT_A),
|
|
126
126
|
listingRow("plst_b", EXPERIMENT_B),
|
|
@@ -129,17 +129,14 @@ describe("attachWaitlistCounts — the fenced count filters on workspaceId besid
|
|
|
129
129
|
|
|
130
130
|
await listProjectListings(SITE);
|
|
131
131
|
|
|
132
|
-
expect(prisma.waitlistScore.
|
|
133
|
-
const
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
}
|
|
140
|
-
expect(wheres.map((w) => w.experimentKey).sort()).toEqual(
|
|
141
|
-
[EXPERIMENT_A, EXPERIMENT_B].sort(),
|
|
142
|
-
);
|
|
132
|
+
expect(prisma.waitlistScore.groupBy).toHaveBeenCalledTimes(1);
|
|
133
|
+
const where = (prisma.waitlistScore.groupBy as jest.Mock).mock.calls[0]![0]!.where as Record<
|
|
134
|
+
string,
|
|
135
|
+
unknown
|
|
136
|
+
>;
|
|
137
|
+
expect(where.siteId).toBe(SITE);
|
|
138
|
+
expect(where.workspaceId).toBe(SITE);
|
|
139
|
+
expect(where.experimentKey).toEqual({ in: [EXPERIMENT_A, EXPERIMENT_B] });
|
|
143
140
|
});
|
|
144
141
|
|
|
145
142
|
it("no listing carries an experimentKey → no fenced read is issued at all", async () => {
|
|
@@ -149,6 +146,6 @@ describe("attachWaitlistCounts — the fenced count filters on workspaceId besid
|
|
|
149
146
|
|
|
150
147
|
await listProjectListings(SITE);
|
|
151
148
|
|
|
152
|
-
expect(prisma.waitlistScore.
|
|
149
|
+
expect(prisma.waitlistScore.groupBy).not.toHaveBeenCalled();
|
|
153
150
|
});
|
|
154
151
|
});
|
|
@@ -134,6 +134,16 @@ function buildResumeLink(
|
|
|
134
134
|
return `${resumeUrl}?fs=${sessionId}`;
|
|
135
135
|
}
|
|
136
136
|
|
|
137
|
+
// ─── Candidate paging (#9249) ───────────────────────────────────────────────────
|
|
138
|
+
|
|
139
|
+
/**
|
|
140
|
+
* How many candidates one run reads per flow definition. A flow with more
|
|
141
|
+
* past-delay sessions than this processes one page and leaves the rest —
|
|
142
|
+
* still `abandonEmailStatus: null` — for the next run. Same page as
|
|
143
|
+
* `STANDARD_SEED_PAGE` (lib/tenant-db.ts), this app's other background page.
|
|
144
|
+
*/
|
|
145
|
+
export const ABANDON_CANDIDATE_PAGE_SIZE = 500;
|
|
146
|
+
|
|
137
147
|
// ─── The sweep ──────────────────────────────────────────────────────────────────
|
|
138
148
|
|
|
139
149
|
/**
|
|
@@ -197,19 +207,21 @@ async function sweepAllDefinitions(
|
|
|
197
207
|
email: true,
|
|
198
208
|
locale: true,
|
|
199
209
|
},
|
|
210
|
+
// #9249 — oldest-updated first, so the page is deterministic (the same
|
|
211
|
+
// reason flow-aggregate.ts orders its capped read).
|
|
212
|
+
orderBy: { updatedAt: "asc" },
|
|
213
|
+
take: ABANDON_CANDIDATE_PAGE_SIZE,
|
|
200
214
|
})) as AbandonCandidateRow[];
|
|
201
215
|
|
|
202
216
|
summary.scanned += candidates.length;
|
|
203
217
|
|
|
218
|
+
const darkIds: string[] = [];
|
|
219
|
+
|
|
204
220
|
for (const session of candidates) {
|
|
205
221
|
summary.selected += 1;
|
|
206
222
|
|
|
207
223
|
if (policy.mode === "dark") {
|
|
208
|
-
|
|
209
|
-
where: { id: session.id },
|
|
210
|
-
data: { abandonEmailStatus: "dark", abandonEmailAt: now },
|
|
211
|
-
});
|
|
212
|
-
summary.dark += 1;
|
|
224
|
+
darkIds.push(session.id);
|
|
213
225
|
logOutcome(session, policy.mode, "dark");
|
|
214
226
|
continue;
|
|
215
227
|
}
|
|
@@ -265,6 +277,17 @@ async function sweepAllDefinitions(
|
|
|
265
277
|
logOutcome(session, policy.mode, "failed");
|
|
266
278
|
}
|
|
267
279
|
}
|
|
280
|
+
|
|
281
|
+
// #9249 — one stamp for the whole dark-mode page, not one per row. The
|
|
282
|
+
// `abandonEmailStatus: null` guard is the candidate read's own term, so a
|
|
283
|
+
// row an overlapping run stamped meanwhile keeps what that run wrote.
|
|
284
|
+
if (darkIds.length > 0) {
|
|
285
|
+
await prisma.flowSession.updateMany({
|
|
286
|
+
where: { id: { in: darkIds }, abandonEmailStatus: null },
|
|
287
|
+
data: { abandonEmailStatus: "dark", abandonEmailAt: now },
|
|
288
|
+
});
|
|
289
|
+
summary.dark += darkIds.length;
|
|
290
|
+
}
|
|
268
291
|
}
|
|
269
292
|
}
|
|
270
293
|
|
|
@@ -84,12 +84,6 @@ export type FulfillDeliverableInput = {
|
|
|
84
84
|
locale: string;
|
|
85
85
|
};
|
|
86
86
|
|
|
87
|
-
type DeliverableAssetRow = {
|
|
88
|
-
id: string;
|
|
89
|
-
siteId: string;
|
|
90
|
-
key: string;
|
|
91
|
-
};
|
|
92
|
-
|
|
93
87
|
// Derived from the schema type (not hand-rolled) so it never drifts from
|
|
94
88
|
// `deliverableAssetMapEntrySchema`'s actual Zod-inferred optional shape.
|
|
95
89
|
type DeliverableEntry = NonNullable<
|
|
@@ -479,7 +473,8 @@ export async function fulfillDeliverable(
|
|
|
479
473
|
// one row; `workspaceId` is what makes the tenant the QUERY's statement.
|
|
480
474
|
const asset = (await prisma.deliverableAsset.findUnique({
|
|
481
475
|
where: { siteId_key: { siteId, key: entry.assetKey }, workspaceId: siteId },
|
|
482
|
-
|
|
476
|
+
select: { id: true },
|
|
477
|
+
})) as { id: string } | null;
|
|
483
478
|
|
|
484
479
|
if (!asset) {
|
|
485
480
|
logOutcome(logCtx, "asset_missing");
|
|
@@ -10,6 +10,7 @@ import { join } from "node:path";
|
|
|
10
10
|
import { offeringsSnapshotProductSchema } from "@working-theory/validation";
|
|
11
11
|
|
|
12
12
|
import {
|
|
13
|
+
isOfferingsPlanProduct,
|
|
13
14
|
isOfferingsProduct,
|
|
14
15
|
normalizeCatalog,
|
|
15
16
|
type CatalogPrice,
|
|
@@ -286,6 +287,58 @@ describe("normalizeCatalog (#8876 D5, AC-5)", () => {
|
|
|
286
287
|
});
|
|
287
288
|
});
|
|
288
289
|
|
|
290
|
+
describe("the hub's own plans (#9338 AC-3)", () => {
|
|
291
|
+
const pro = product("prod_pro", { name: "AI Launch Kit Pro", metadata: { ailk_group: "plan", ailk_order: "10" } });
|
|
292
|
+
const proPlus = product("prod_pro_plus", {
|
|
293
|
+
name: "AI Launch Kit Pro Plus",
|
|
294
|
+
metadata: { ailk_group: "plan", ailk_order: "20" },
|
|
295
|
+
});
|
|
296
|
+
const yearly = { recurring: { interval: "year", interval_count: 1, usage_type: "licensed" } };
|
|
297
|
+
|
|
298
|
+
it("lists the re-keyed hub catalog as two plans with nothing held", () => {
|
|
299
|
+
const { offerings, held } = normalizeCatalog({
|
|
300
|
+
products: [pro, proPlus],
|
|
301
|
+
prices: [
|
|
302
|
+
price("price_pro_m", "prod_pro", { lookup_key: "ailk-pro_monthly" }),
|
|
303
|
+
price("price_pro_y", "prod_pro", { lookup_key: "ailk-pro_yearly", ...yearly }),
|
|
304
|
+
price("price_pp_m", "prod_pro_plus", { lookup_key: "ailk-pro-plus_monthly" }),
|
|
305
|
+
],
|
|
306
|
+
});
|
|
307
|
+
expect(held).toEqual([]);
|
|
308
|
+
expect(offerings.map((o) => [o.productId, o.ailkGroup, o.prices.map((p) => p.lookupKey)])).toEqual([
|
|
309
|
+
["prod_pro", "plan", ["ailk-pro_monthly", "ailk-pro_yearly"]],
|
|
310
|
+
["prod_pro_plus", "plan", ["ailk-pro-plus_monthly"]],
|
|
311
|
+
]);
|
|
312
|
+
});
|
|
313
|
+
|
|
314
|
+
it("holds today's Pro Plus (no group, key ailk-pro-plus) group_invalid", () => {
|
|
315
|
+
const today = product("prod_pro_plus", { name: "AI Launch Kit Pro Plus", metadata: {} });
|
|
316
|
+
expect(one(today, [price("price_pp", "prod_pro_plus", { lookup_key: "ailk-pro-plus", ...yearly })]).held).toEqual([
|
|
317
|
+
{ productId: "prod_pro_plus", name: "AI Launch Kit Pro Plus", reason: "group_invalid" },
|
|
318
|
+
]);
|
|
319
|
+
});
|
|
320
|
+
|
|
321
|
+
it("holds Pro Plus marked but still keyed ailk-pro-plus needs_lookup_key", () => {
|
|
322
|
+
expect(one(proPlus, [price("price_pp", "prod_pro_plus", { lookup_key: "ailk-pro-plus", ...yearly })]).held).toEqual([
|
|
323
|
+
{ productId: "prod_pro_plus", name: "AI Launch Kit Pro Plus", reason: "needs_lookup_key" },
|
|
324
|
+
]);
|
|
325
|
+
});
|
|
326
|
+
|
|
327
|
+
it("holds a yearly Price keyed _monthly needs_lookup_key", () => {
|
|
328
|
+
expect(
|
|
329
|
+
one(proPlus, [price("price_pp", "prod_pro_plus", { lookup_key: "ailk-pro-plus_monthly", ...yearly })]).held,
|
|
330
|
+
).toEqual([{ productId: "prod_pro_plus", name: "AI Launch Kit Pro Plus", reason: "needs_lookup_key" }]);
|
|
331
|
+
});
|
|
332
|
+
|
|
333
|
+
it("isOfferingsPlanProduct is true for ailk_group plan only", () => {
|
|
334
|
+
expect(isOfferingsPlanProduct(product("prod_a", { metadata: { ailk_group: "plan" } }))).toBe(true);
|
|
335
|
+
expect(isOfferingsPlanProduct(product("prod_a", { metadata: { ailk_group: "addon" } }))).toBe(false);
|
|
336
|
+
expect(isOfferingsPlanProduct(product("prod_a", { metadata: { ailk_group: "Plan" } }))).toBe(false);
|
|
337
|
+
expect(isOfferingsPlanProduct(product("prod_a", { metadata: {} }))).toBe(false);
|
|
338
|
+
expect(isOfferingsPlanProduct(product("prod_a", { metadata: null }))).toBe(false);
|
|
339
|
+
});
|
|
340
|
+
});
|
|
341
|
+
|
|
289
342
|
it("splits a lookup key only through parseLookupKey (AC-9)", () => {
|
|
290
343
|
const source = readFileSync(join(__dirname, "..", "catalog.ts"), "utf8");
|
|
291
344
|
expect(source).toContain("parseLookupKey(");
|
|
@@ -48,6 +48,9 @@ jest.mock("node:crypto", () => {
|
|
|
48
48
|
});
|
|
49
49
|
|
|
50
50
|
const mockStripeClientForConnection = jest.fn();
|
|
51
|
+
// `getStripeClient()` caches its client for the process, so a case that needs a
|
|
52
|
+
// refusal swaps it (#9285); every other case calls through to the real one.
|
|
53
|
+
const mockGetStripeClient = jest.fn();
|
|
51
54
|
// eslint-disable-next-line no-restricted-syntax -- lib/stripe.ts is the Stripe HTTP boundary; the real wrapper runs unless a case swaps in a fake client
|
|
52
55
|
jest.mock("../../../lib/stripe.js", () => {
|
|
53
56
|
const actual = jest.requireActual<typeof import("../../../lib/stripe.js")>("../../../lib/stripe.js");
|
|
@@ -55,6 +58,7 @@ jest.mock("../../../lib/stripe.js", () => {
|
|
|
55
58
|
...actual,
|
|
56
59
|
stripeClientForConnection: (...args: Parameters<typeof actual.stripeClientForConnection>) =>
|
|
57
60
|
mockStripeClientForConnection(...args),
|
|
61
|
+
getStripeClient: () => mockGetStripeClient(),
|
|
58
62
|
};
|
|
59
63
|
});
|
|
60
64
|
|
|
@@ -67,8 +71,11 @@ import {
|
|
|
67
71
|
encryptStripeConnectionValue,
|
|
68
72
|
findStripeConnectionByWebhookToken,
|
|
69
73
|
formatRekeyReport,
|
|
74
|
+
getEnvironmentStripeSource,
|
|
75
|
+
getOfferingsConnectionState,
|
|
70
76
|
getStripeConnectionSummary,
|
|
71
77
|
goLiveStripeConnection,
|
|
78
|
+
organizationOwnsEnvironmentStripeKey,
|
|
72
79
|
PROBE_TIMEOUT_MS,
|
|
73
80
|
probeStripeKey,
|
|
74
81
|
putStripeConnectionKey,
|
|
@@ -866,3 +873,103 @@ describe("stripeClientForOrganization (#8907 D12, AC-13)", () => {
|
|
|
866
873
|
expect(lib).toContain("productionBuild: isProductionBuild()");
|
|
867
874
|
});
|
|
868
875
|
});
|
|
876
|
+
|
|
877
|
+
// ─── #9285 AC-8, AC-9 — the environment key's owner ─────────────────────────
|
|
878
|
+
|
|
879
|
+
describe("the environment Stripe key and the Organization that owns it (#9285 AC-8, AC-9)", () => {
|
|
880
|
+
const saved = {
|
|
881
|
+
key: process.env["STRIPE_SECRET_KEY"],
|
|
882
|
+
modules: process.env["AILK_ENABLED_MODULES"],
|
|
883
|
+
owner: process.env["STRIPE_ENV_ORGANIZATION_ID"],
|
|
884
|
+
};
|
|
885
|
+
const restore = (name: string, value: string | undefined) => {
|
|
886
|
+
if (value === undefined) delete process.env[name];
|
|
887
|
+
else process.env[name] = value;
|
|
888
|
+
};
|
|
889
|
+
|
|
890
|
+
beforeEach(() => {
|
|
891
|
+
db.__db.stripeConnection = [];
|
|
892
|
+
mockGetStripeClient.mockReset().mockImplementation(() => actualStripe.getStripeClient());
|
|
893
|
+
process.env["STRIPE_SECRET_KEY"] = testKey();
|
|
894
|
+
process.env["AILK_ENABLED_MODULES"] = "multi-tenant";
|
|
895
|
+
delete process.env["STRIPE_ENV_ORGANIZATION_ID"];
|
|
896
|
+
});
|
|
897
|
+
afterAll(() => {
|
|
898
|
+
restore("STRIPE_SECRET_KEY", saved.key);
|
|
899
|
+
restore("AILK_ENABLED_MODULES", saved.modules);
|
|
900
|
+
restore("STRIPE_ENV_ORGANIZATION_ID", saved.owner);
|
|
901
|
+
});
|
|
902
|
+
|
|
903
|
+
it("with the multi-tenant module, only the Organization STRIPE_ENV_ORGANIZATION_ID names owns it", () => {
|
|
904
|
+
// Unset: nobody owns it (fail closed).
|
|
905
|
+
expect(organizationOwnsEnvironmentStripeKey(ORG_A)).toBe(false);
|
|
906
|
+
expect(organizationOwnsEnvironmentStripeKey(ORG_B)).toBe(false);
|
|
907
|
+
// Blank or whitespace: nobody.
|
|
908
|
+
for (const blank of ["", " "]) {
|
|
909
|
+
process.env["STRIPE_ENV_ORGANIZATION_ID"] = blank;
|
|
910
|
+
expect(organizationOwnsEnvironmentStripeKey(ORG_A)).toBe(false);
|
|
911
|
+
}
|
|
912
|
+
// Naming no Organization: nobody.
|
|
913
|
+
process.env["STRIPE_ENV_ORGANIZATION_ID"] = "org_unknown";
|
|
914
|
+
expect(organizationOwnsEnvironmentStripeKey(ORG_A)).toBe(false);
|
|
915
|
+
// Naming one: that one alone.
|
|
916
|
+
process.env["STRIPE_ENV_ORGANIZATION_ID"] = ORG_A;
|
|
917
|
+
expect(organizationOwnsEnvironmentStripeKey(ORG_A)).toBe(true);
|
|
918
|
+
expect(organizationOwnsEnvironmentStripeKey(ORG_B)).toBe(false);
|
|
919
|
+
});
|
|
920
|
+
|
|
921
|
+
it("without the multi-tenant module, the single Organization owns it and the variable is not read", () => {
|
|
922
|
+
process.env["AILK_ENABLED_MODULES"] = "";
|
|
923
|
+
process.env["STRIPE_ENV_ORGANIZATION_ID"] = "org_unknown";
|
|
924
|
+
expect(organizationOwnsEnvironmentStripeKey(ORG_A)).toBe(true);
|
|
925
|
+
delete process.env["AILK_ENABLED_MODULES"];
|
|
926
|
+
expect(organizationOwnsEnvironmentStripeKey(ORG_B)).toBe(true);
|
|
927
|
+
});
|
|
928
|
+
|
|
929
|
+
it("reports the source and the Offerings state to the owner alone, and never a key class it cannot sell through", async () => {
|
|
930
|
+
process.env["STRIPE_ENV_ORGANIZATION_ID"] = ORG_A;
|
|
931
|
+
expect(getEnvironmentStripeSource(ORG_A)).toEqual({ source: "environment", mode: "test" });
|
|
932
|
+
expect(await getOfferingsConnectionState(ORG_A)).toBe("environment");
|
|
933
|
+
expect(getEnvironmentStripeSource(ORG_B)).toBeNull();
|
|
934
|
+
expect(await getOfferingsConnectionState(ORG_B)).toBeNull();
|
|
935
|
+
// No usable key class: no source, even for the owner.
|
|
936
|
+
process.env["STRIPE_SECRET_KEY"] = ["pk", "test", "a".repeat(24)].join("_");
|
|
937
|
+
expect(getEnvironmentStripeSource(ORG_A)).toBeNull();
|
|
938
|
+
expect(await getOfferingsConnectionState(ORG_A)).toBeNull();
|
|
939
|
+
});
|
|
940
|
+
|
|
941
|
+
it("lets a stored connection win over the environment state", async () => {
|
|
942
|
+
process.env["STRIPE_ENV_ORGANIZATION_ID"] = ORG_A;
|
|
943
|
+
const now = new Date();
|
|
944
|
+
rows().push({
|
|
945
|
+
id: "stripecon_a",
|
|
946
|
+
organizationId: ORG_A,
|
|
947
|
+
mode: "test",
|
|
948
|
+
liveAt: null,
|
|
949
|
+
keyLast4: "aaaa",
|
|
950
|
+
webhookToken: "A".repeat(43),
|
|
951
|
+
webhookSecretCiphertext: null,
|
|
952
|
+
credentialCiphertext: encryptStripeConnectionValue(testKey(), ORG_A, "credential", KEY_A),
|
|
953
|
+
createdAt: now,
|
|
954
|
+
updatedAt: now,
|
|
955
|
+
});
|
|
956
|
+
expect(await getOfferingsConnectionState(ORG_A)).toMatchObject({ mode: "test", last4: "aaaa" });
|
|
957
|
+
});
|
|
958
|
+
|
|
959
|
+
it("sells an Organization with no stored row through the environment client, and no other", async () => {
|
|
960
|
+
process.env["STRIPE_ENV_ORGANIZATION_ID"] = ORG_A;
|
|
961
|
+
const owned = await stripeClientForOrganization(ORG_A);
|
|
962
|
+
expect(owned).toMatchObject({ kind: "ok", mode: "test" });
|
|
963
|
+
expect(await stripeClientForOrganization(ORG_B)).toEqual({ kind: "none" });
|
|
964
|
+
// The per-key factory is never used for the environment key.
|
|
965
|
+
expect(mockStripeClientForConnection).not.toHaveBeenCalled();
|
|
966
|
+
});
|
|
967
|
+
|
|
968
|
+
it("answers unusable when the owner's key is one the process refuses", async () => {
|
|
969
|
+
process.env["STRIPE_ENV_ORGANIZATION_ID"] = ORG_A;
|
|
970
|
+
mockGetStripeClient.mockImplementationOnce(() => {
|
|
971
|
+
throw new Error("refused");
|
|
972
|
+
});
|
|
973
|
+
expect(await stripeClientForOrganization(ORG_A)).toEqual({ kind: "unusable" });
|
|
974
|
+
});
|
|
975
|
+
});
|
|
@@ -189,6 +189,10 @@ function corruptConnection(): void {
|
|
|
189
189
|
const noValidate = { validate: () => undefined };
|
|
190
190
|
|
|
191
191
|
beforeEach(async () => {
|
|
192
|
+
// With the multi-tenant module and no STRIPE_ENV_ORGANIZATION_ID nobody owns the
|
|
193
|
+
// environment key (#9285 AC-8), so "no connection" stays no connection here.
|
|
194
|
+
process.env.AILK_ENABLED_MODULES = "multi-tenant";
|
|
195
|
+
delete process.env.STRIPE_ENV_ORGANIZATION_ID;
|
|
192
196
|
for (const key of Object.keys(db.__db)) delete db.__db[key];
|
|
193
197
|
jest.clearAllMocks();
|
|
194
198
|
resetOfferingsRefreshStateForTests();
|
|
@@ -135,6 +135,10 @@ const rejecting = () =>
|
|
|
135
135
|
});
|
|
136
136
|
|
|
137
137
|
beforeEach(async () => {
|
|
138
|
+
// With the multi-tenant module and no STRIPE_ENV_ORGANIZATION_ID nobody owns the
|
|
139
|
+
// environment key (#9285 AC-8), so "no connection" stays no connection here.
|
|
140
|
+
process.env.AILK_ENABLED_MODULES = "multi-tenant";
|
|
141
|
+
delete process.env.STRIPE_ENV_ORGANIZATION_ID;
|
|
138
142
|
for (const key of Object.keys(db.__db)) delete db.__db[key];
|
|
139
143
|
mockLog.mockClear();
|
|
140
144
|
db.workspaceOfferings.findUnique.mockClear();
|
|
@@ -117,6 +117,16 @@ export function isOfferingsProduct(product: CatalogProduct): boolean {
|
|
|
117
117
|
&& product.metadata.ailk_group.length > 0;
|
|
118
118
|
}
|
|
119
119
|
|
|
120
|
+
/**
|
|
121
|
+
* An Offerings `plan` Product: `metadata.ailk_group` exactly `"plan"` — the
|
|
122
|
+
* normalizer's own group read, the stricter sibling of `isOfferingsProduct`.
|
|
123
|
+
* The Billing tab's plan checkout sells only a Price whose Product passes
|
|
124
|
+
* this (#9338 DD4), so an unmarked hub plan never sells from it.
|
|
125
|
+
*/
|
|
126
|
+
export function isOfferingsPlanProduct(product: CatalogProduct): boolean {
|
|
127
|
+
return groupOf(product) === "plan";
|
|
128
|
+
}
|
|
129
|
+
|
|
120
130
|
function metadataOf(product: CatalogProduct): Record<string, unknown> {
|
|
121
131
|
return isRecord(product.metadata) ? product.metadata : {};
|
|
122
132
|
}
|
|
@@ -13,17 +13,21 @@
|
|
|
13
13
|
* key: that key sells the deployment's own products, not a spoke's (#9126).
|
|
14
14
|
*
|
|
15
15
|
* THE SALE. `startOfferingsCheckout` parses the lookup key by the one grammar
|
|
16
|
-
* before any read (A3),
|
|
16
|
+
* before any read (A3), refuses the hub's own plan keys at any period
|
|
17
|
+
* (#9338 DD5), authorizes it at click time against org-enabled ∩
|
|
17
18
|
* workspace-shown from the rows — never the snapshot (A4), and for a
|
|
18
19
|
* workspace saved per Price only a shown Price (#9070 D5) — resolves it live
|
|
19
20
|
* with the connection's own client, sells the authorized Price id in an
|
|
20
21
|
* anonymous hosted session stamped `{ site, ailk_offering }` by the server
|
|
21
22
|
* (A6, A7), and records the session before it answers; a failed record
|
|
22
23
|
* expires the session (A11). Every miss is one `unknown`; every failure one
|
|
23
|
-
* `unavailable`; there is never a snapshot
|
|
24
|
+
* `unavailable`; there is never a snapshot fallback (A5), and this path never
|
|
25
|
+
* sells on the environment key: an Organization that owns it checks out on the
|
|
26
|
+
* env path until it has a stored connection (#9296, #8936).
|
|
24
27
|
*
|
|
25
28
|
* THE READ. `readOfferingsCheckoutSession` reads a recorded session with its
|
|
26
|
-
* record's own connection, never
|
|
29
|
+
* record's own connection, never a caller value (A13). Only a stored connection
|
|
30
|
+
* records a session (#9296), so this read never runs on the environment key.
|
|
27
31
|
*
|
|
28
32
|
* LOGGING (D11, T6). Only `workspaceId` or `organizationId`, an 8-character
|
|
29
33
|
* session-id prefix, a fixed `reason`, and an error's class name. Never a
|
|
@@ -33,6 +37,7 @@
|
|
|
33
37
|
import { logger } from "@working-theory/observability";
|
|
34
38
|
import {
|
|
35
39
|
OFFERINGS_NAME_MAX,
|
|
40
|
+
isPlanPurchaseKey,
|
|
36
41
|
lookupKeyPeriodSchema,
|
|
37
42
|
parseLookupKey,
|
|
38
43
|
} from "@working-theory/validation";
|
|
@@ -55,7 +60,7 @@ import {
|
|
|
55
60
|
recordCheckoutSession,
|
|
56
61
|
} from "./checkout-session-record.js";
|
|
57
62
|
import {
|
|
58
|
-
|
|
63
|
+
getOfferingsConnectionState,
|
|
59
64
|
stripeClientForOrganization,
|
|
60
65
|
} from "./connection.js";
|
|
61
66
|
import { organizationIdOfWorkspace } from "./store.js";
|
|
@@ -106,10 +111,17 @@ export async function resolveOfferingsCheckoutPath(
|
|
|
106
111
|
: { kind: "env" };
|
|
107
112
|
const organizationId = await organizationIdOfWorkspace(workspaceId);
|
|
108
113
|
if (organizationId === null) return noConnection;
|
|
109
|
-
const summary = await
|
|
114
|
+
const summary = await getOfferingsConnectionState(organizationId);
|
|
110
115
|
if (summary === null) return noConnection;
|
|
111
116
|
// Never the rows, which read a corrupt connection as an empty set (404).
|
|
112
117
|
if (summary === "unreadable") return { kind: "unavailable" };
|
|
118
|
+
// An Organization on the environment key never takes the Offerings path
|
|
119
|
+
// (#9296): that path stamps `ailk_offering`, which the legacy webhook acks
|
|
120
|
+
// `offerings_path` without recording and the per-Organization receiver takes
|
|
121
|
+
// only for a stored connection's token, so a sale on the environment key
|
|
122
|
+
// would be charged and never fulfilled. Its checkout stays on the env path
|
|
123
|
+
// until environment-key fulfilment exists (#8936 stores the connection).
|
|
124
|
+
if (summary === "environment") return noConnection;
|
|
113
125
|
return { kind: "offerings", workspaceId, organizationId };
|
|
114
126
|
}
|
|
115
127
|
|
|
@@ -168,6 +180,12 @@ export async function startOfferingsCheckout(
|
|
|
168
180
|
const parsed = parseLookupKey(productCode);
|
|
169
181
|
if (parsed === null) return unknown();
|
|
170
182
|
|
|
183
|
+
// 1b. Never a hub plan (#9338 DD5): AILK's own Pro / Pro Plus, at any
|
|
184
|
+
// period, for every Organization. A sale here would stamp `ailk_offering`,
|
|
185
|
+
// which no billing path mints a licence for — the buyer would be charged
|
|
186
|
+
// with no key. Same 404, same log reason, before any read.
|
|
187
|
+
if (isPlanPurchaseKey(productCode)) return unknown();
|
|
188
|
+
|
|
171
189
|
// 2. The connection's own client (K6) — before the rows, so a key that
|
|
172
190
|
// cannot be used answers 502 whatever the rows hold.
|
|
173
191
|
let connection: Awaited<ReturnType<typeof stripeClientForOrganization>>;
|
|
@@ -49,9 +49,11 @@ import {
|
|
|
49
49
|
import { z } from "zod";
|
|
50
50
|
|
|
51
51
|
import { config } from "../../config/index.js";
|
|
52
|
+
import { isModuleEnabled } from "../../config/modules.js";
|
|
52
53
|
import { prisma } from "../../lib/prisma.js";
|
|
53
54
|
import {
|
|
54
55
|
classifyStripeKey,
|
|
56
|
+
getStripeClient,
|
|
55
57
|
isProductionBuild,
|
|
56
58
|
stripeClientForConnection,
|
|
57
59
|
type StripeConnectionMode,
|
|
@@ -365,6 +367,65 @@ const viewSchema = z
|
|
|
365
367
|
.refine((view) => (view.mode === "live") === (view.liveAt !== null), { path: ["liveAt"] });
|
|
366
368
|
export type StripeConnectionView = z.infer<typeof viewSchema>;
|
|
367
369
|
|
|
370
|
+
/**
|
|
371
|
+
* The read-only source the GET answers when the Organization has NO stored
|
|
372
|
+
* connection but OWNS the Stripe key in this API process's environment
|
|
373
|
+
* (#9285 AC-3, AC-8): the deployment's own `STRIPE_SECRET_KEY`. It names the
|
|
374
|
+
* mode the key's class implies and nothing else — never the key, its last
|
|
375
|
+
* four or a webhook path — and a publishable or unrecognised value is no
|
|
376
|
+
* source at all. A stored connection always wins; the caller decides that,
|
|
377
|
+
* not this function.
|
|
378
|
+
*/
|
|
379
|
+
export type StripeEnvironmentSource = { source: "environment"; mode: StripeConnectionMode };
|
|
380
|
+
|
|
381
|
+
/** The mode the process's own Stripe key implies, or `null` for no usable class. */
|
|
382
|
+
function environmentKeyMode(): StripeConnectionMode | null {
|
|
383
|
+
switch (classifyStripeKey(process.env["STRIPE_SECRET_KEY"])) {
|
|
384
|
+
case "restricted-test":
|
|
385
|
+
case "secret-test":
|
|
386
|
+
return "test";
|
|
387
|
+
case "restricted-live":
|
|
388
|
+
case "secret-live":
|
|
389
|
+
return "live";
|
|
390
|
+
default:
|
|
391
|
+
return null;
|
|
392
|
+
}
|
|
393
|
+
}
|
|
394
|
+
|
|
395
|
+
/**
|
|
396
|
+
* Whether this Organization owns the environment key (#9285 AC-8). Without
|
|
397
|
+
* the multi-tenant module a deployment has one Organization, and it owns the
|
|
398
|
+
* key. With it, only the Organization named by `STRIPE_ENV_ORGANIZATION_ID`
|
|
399
|
+
* does; unset, blank or naming no Organization, nobody does (fail closed), so
|
|
400
|
+
* one tenant's Offerings can never sell through another's account.
|
|
401
|
+
*/
|
|
402
|
+
export function organizationOwnsEnvironmentStripeKey(organizationId: string): boolean {
|
|
403
|
+
if (!isModuleEnabled("multi-tenant")) return true;
|
|
404
|
+
const owner = process.env["STRIPE_ENV_ORGANIZATION_ID"]?.trim();
|
|
405
|
+
return owner !== undefined && owner !== "" && owner === organizationId;
|
|
406
|
+
}
|
|
407
|
+
|
|
408
|
+
/** The environment source for this Organization, or `null` when it has none to see. */
|
|
409
|
+
export function getEnvironmentStripeSource(organizationId: string): StripeEnvironmentSource | null {
|
|
410
|
+
if (!organizationOwnsEnvironmentStripeKey(organizationId)) return null;
|
|
411
|
+
const mode = environmentKeyMode();
|
|
412
|
+
return mode === null ? null : { source: "environment", mode };
|
|
413
|
+
}
|
|
414
|
+
|
|
415
|
+
/**
|
|
416
|
+
* The connection state Offerings sells on (#9285 AC-9): the stored summary
|
|
417
|
+
* first, else `"environment"` for an Organization that owns the environment
|
|
418
|
+
* key, else `null`. `"unreadable"` stays a corrupt stored row. The one
|
|
419
|
+
* "is this Organization connected" read every Offerings path shares.
|
|
420
|
+
*/
|
|
421
|
+
export async function getOfferingsConnectionState(
|
|
422
|
+
organizationId: string,
|
|
423
|
+
): Promise<StripeConnectionSummary | "unreadable" | "environment" | null> {
|
|
424
|
+
const stored = await getStripeConnectionSummary(organizationId);
|
|
425
|
+
if (stored !== null) return stored;
|
|
426
|
+
return getEnvironmentStripeSource(organizationId) === null ? null : "environment";
|
|
427
|
+
}
|
|
428
|
+
|
|
368
429
|
/** What a write answers (D7): no `last4`, no `webhookPath` (SEC-8907-10). */
|
|
369
430
|
export type StripeConnectionSaved = {
|
|
370
431
|
mode: StripeConnectionMode;
|
|
@@ -837,13 +898,32 @@ export type StripeConnectionClient =
|
|
|
837
898
|
| { kind: "unusable" }
|
|
838
899
|
| { kind: "ok"; client: ConnectionClient; mode: StripeConnectionMode };
|
|
839
900
|
|
|
901
|
+
/**
|
|
902
|
+
* The environment key's client for the Organization that owns it (#9285
|
|
903
|
+
* AC-9), through `getStripeClient()` — the allow-list the env checkout path
|
|
904
|
+
* already meets (a test key, or the restricted live key the process admitted
|
|
905
|
+
* at boot). A key it refuses is `unusable`, not `none`: the Organization owns
|
|
906
|
+
* a key it cannot sell through.
|
|
907
|
+
*/
|
|
908
|
+
function environmentClientFor(organizationId: string): StripeConnectionClient {
|
|
909
|
+
const source = getEnvironmentStripeSource(organizationId);
|
|
910
|
+
if (source === null) return { kind: "none" };
|
|
911
|
+
try {
|
|
912
|
+
return { kind: "ok", client: getStripeClient(), mode: source.mode };
|
|
913
|
+
} catch {
|
|
914
|
+
return { kind: "unusable" };
|
|
915
|
+
}
|
|
916
|
+
}
|
|
917
|
+
|
|
840
918
|
/**
|
|
841
919
|
* A client built from the Organization's own key, in its own mode — what
|
|
842
920
|
* #8054's catalog source and #8861's checkout call. A live row on a
|
|
843
921
|
* non-production build is `unusable` before any decrypt (D3); so is a key
|
|
844
922
|
* that does not decrypt and one the factory refuses for the row's mode.
|
|
845
|
-
* Nothing is cached beyond the call (C8)
|
|
846
|
-
* the deployment's
|
|
923
|
+
* Nothing is cached beyond the call (C8). With no stored row, an
|
|
924
|
+
* Organization that owns the deployment's environment key (#9285 AC-8) is sold
|
|
925
|
+
* through it, by the same chokepoint the env checkout path uses; no other
|
|
926
|
+
* Organization ever falls back to it.
|
|
847
927
|
*/
|
|
848
928
|
export async function stripeClientForOrganization(
|
|
849
929
|
organizationId: string,
|
|
@@ -854,7 +934,7 @@ export async function stripeClientForOrganization(
|
|
|
854
934
|
select: { credentialCiphertext: true, mode: true, liveAt: true },
|
|
855
935
|
}),
|
|
856
936
|
);
|
|
857
|
-
if (row === null) return
|
|
937
|
+
if (row === null) return environmentClientFor(organizationId);
|
|
858
938
|
if (!isMode(row.mode)) {
|
|
859
939
|
logUnreadableRow(organizationId);
|
|
860
940
|
return { kind: "unusable" };
|
|
@@ -59,7 +59,7 @@ import {
|
|
|
59
59
|
type NormalizedCatalog,
|
|
60
60
|
type OfferingsCatalogSource,
|
|
61
61
|
} from "./catalog.js";
|
|
62
|
-
import {
|
|
62
|
+
import { getOfferingsConnectionState } from "./connection.js";
|
|
63
63
|
import {
|
|
64
64
|
OfferingsShapeError,
|
|
65
65
|
organizationIdOfWorkspace,
|
|
@@ -81,7 +81,7 @@ export {
|
|
|
81
81
|
type OfferingsCatalog,
|
|
82
82
|
type OfferingsCatalogSource,
|
|
83
83
|
} from "./catalog.js";
|
|
84
|
-
export { getStripeConnectionSummary } from "./connection.js";
|
|
84
|
+
export { getOfferingsConnectionState, getStripeConnectionSummary } from "./connection.js";
|
|
85
85
|
export { OfferingsShapeError } from "./store.js";
|
|
86
86
|
|
|
87
87
|
/** The snapshot is served for at most 24 hours from `snapshotAt` (C5). */
|
|
@@ -268,7 +268,7 @@ export async function getOrgEnabledProducts(
|
|
|
268
268
|
sourceFor: OfferingsSourceFor,
|
|
269
269
|
{ now = new Date() }: { now?: Date } = {},
|
|
270
270
|
): Promise<OrgEnabledProducts | null> {
|
|
271
|
-
const connection = await
|
|
271
|
+
const connection = await getOfferingsConnectionState(organizationId);
|
|
272
272
|
if (connection === null) return null;
|
|
273
273
|
const productIds = await readEnabledProductIds(organizationId);
|
|
274
274
|
if (connection === "unreadable") {
|
|
@@ -352,7 +352,7 @@ export async function listOrgProducts(
|
|
|
352
352
|
sourceFor: OfferingsSourceFor,
|
|
353
353
|
{ now = new Date() }: { now?: Date } = {},
|
|
354
354
|
): Promise<OrgProductList> {
|
|
355
|
-
const connection = await
|
|
355
|
+
const connection = await getOfferingsConnectionState(organizationId);
|
|
356
356
|
if (connection === null) return { connection: "none" };
|
|
357
357
|
if (connection === "unreadable") return { connection: "unreadable" };
|
|
358
358
|
const enabled = await readEnabledProductIds(organizationId);
|
|
@@ -378,7 +378,7 @@ export async function setOrgEnabledProducts(
|
|
|
378
378
|
validate,
|
|
379
379
|
}: { now?: Date; validate: (list: ConnectedOrgProductList) => void },
|
|
380
380
|
): Promise<SetOrgEnabledResult> {
|
|
381
|
-
const connection = await
|
|
381
|
+
const connection = await getOfferingsConnectionState(organizationId);
|
|
382
382
|
if (connection === null) return { kind: "not_connected" };
|
|
383
383
|
if (connection === "unreadable") return { kind: "connection_unreadable" };
|
|
384
384
|
const catalog = await catalogOrSourceError(organizationId, sourceFor, now);
|
|
@@ -443,7 +443,7 @@ async function resolveWorkspace(
|
|
|
443
443
|
): Promise<{ missing: "no_connection" | "no_selection" } | ResolvedWorkspace> {
|
|
444
444
|
const organizationId = await organizationIdOfWorkspace(workspaceId);
|
|
445
445
|
if (organizationId === null) return { missing: "no_selection" };
|
|
446
|
-
const connection = await
|
|
446
|
+
const connection = await getOfferingsConnectionState(organizationId);
|
|
447
447
|
if (connection === null) return { missing: "no_connection" };
|
|
448
448
|
// An unreadable connection serves nothing, with or without a selection
|
|
449
449
|
// row, and never reads as unconnected (SEC-PR8955-R2-1).
|