@odla-ai/chapter 0.47.9 → 0.48.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/testing.cjs CHANGED
@@ -20,6 +20,8 @@ var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: tru
20
20
  // src/testing.ts
21
21
  var testing_exports = {};
22
22
  __export(testing_exports, {
23
+ assertCommercialQuote: () => assertCommercialQuote,
24
+ assessChapterReleaseEvidence: () => assessChapterReleaseEvidence,
23
25
  formatChapterAcceptanceRun: () => formatChapterAcceptanceRun,
24
26
  planChapterAcceptanceMatrix: () => planChapterAcceptanceMatrix,
25
27
  probeChapterJoinContracts: () => probeChapterJoinContracts,
@@ -28,7 +30,146 @@ __export(testing_exports, {
28
30
  });
29
31
  module.exports = __toCommonJS(testing_exports);
30
32
 
33
+ // src/testing-commercial.ts
34
+ var object = (value) => value !== null && typeof value === "object" && !Array.isArray(value) ? value : null;
35
+ var id = (value) => typeof value === "string" && /^[a-z0-9][a-z0-9_.:-]{1,159}$/.test(value);
36
+ var time = (value) => Number.isSafeInteger(value) && Number(value) > 0;
37
+ function validateCommercialCases(value, skus, path) {
38
+ if (value === void 0) return [];
39
+ if (!Array.isArray(value) || !value.length || value.length > 32) return [`${path} must be a non-empty array of at most 32 cases`];
40
+ const errors = [];
41
+ const ids = /* @__PURE__ */ new Set();
42
+ for (const [index, raw] of value.entries()) {
43
+ const item = object(raw);
44
+ const at = `${path}[${index}]`;
45
+ if (!item) {
46
+ errors.push(`${at} must be an object`);
47
+ continue;
48
+ }
49
+ if (!id(item.id) || ids.has(String(item.id))) errors.push(`${at}.id must be a unique stable id`);
50
+ ids.add(String(item.id));
51
+ const sku = skus.find((candidate) => candidate.id === item.skuId && candidate.active && candidate.kind === "membership" && candidate.priceCents > 0);
52
+ if (!sku) errors.push(`${at}.skuId must name an active paid base membership SKU`);
53
+ if (sku?.compareAtPriceCents !== void 0 || sku?.discountDuration !== void 0) {
54
+ errors.push(`${at} must not combine a discounted SKU with a separate commercial policy`);
55
+ }
56
+ if (!id(item.offeringVersionId)) errors.push(`${at}.offeringVersionId is required`);
57
+ if (typeof item.founding !== "boolean" || item.founding && !id(item.cohortPolicyVersionId) || item.cohortPolicyVersionId !== void 0 && !id(item.cohortPolicyVersionId)) errors.push(`${at}.founding requires a stable cohort policy identity`);
58
+ if (!time(item.purchasedAt) || !time(item.initialTermEndsAt) || item.initialTermEndsAt <= item.purchasedAt) {
59
+ errors.push(`${at} must specify a purchase instant and a later initial term end`);
60
+ }
61
+ const discount = object(item.discount);
62
+ if (item.discount !== void 0 && (!discount || !id(discount.policyVersionId) || !Number.isInteger(discount.percentOffBps) || Number(discount.percentOffBps) <= 0 || Number(discount.percentOffBps) >= 1e4 || !["first_term", "while_active", "fixed_end"].includes(String(discount.duration)) || (discount.duration === "fixed_end" ? !time(discount.endsAt) || Number(discount.endsAt) <= Number(item.purchasedAt) : discount.endsAt !== void 0))) {
63
+ errors.push(`${at}.discount must specify a version, percentage, and explicit expiry policy`);
64
+ }
65
+ if (sku) {
66
+ const due = Math.round(sku.priceCents * (1e4 - Number(discount?.percentOffBps ?? 0)) / 1e4);
67
+ const ongoing = discount?.duration === "while_active" || discount?.duration === "fixed_end" && Number(discount.endsAt) > Number(item.initialTermEndsAt);
68
+ if (item.dueTodayCents !== due || item.renewalCents !== (ongoing ? due : sku.priceCents)) {
69
+ errors.push(`${at} expected initial and renewal amounts must match the separate policy on the base price`);
70
+ }
71
+ }
72
+ }
73
+ return errors;
74
+ }
75
+ function assertCommercialQuote(expected, sku, actual) {
76
+ const quote = object(actual), lines = object(quote?.lineItems), terms = object(quote?.commercial);
77
+ return Boolean(quote && terms && sku.id === expected.skuId && quote.tierId === expected.skuId && quote.currency === sku.currency && quote.interval === sku.billing && lines?.standardCents === sku.priceCents && lines.discountCents === sku.priceCents - expected.dueTodayCents && lines.dueTodayCents === expected.dueTodayCents && terms.offeringVersionId === expected.offeringVersionId && terms.initialTermEndsAt === expected.initialTermEndsAt && terms.renewalCents === expected.renewalCents && terms.autoRenew === true && terms.discountPolicyVersionId === expected.discount?.policyVersionId && terms.discountDuration === expected.discount?.duration);
78
+ }
79
+
31
80
  // src/testing-probe.ts
81
+ var MAX_BODY_BYTES = 64 * 1024;
82
+ var TIMEOUT_MS = 1e4;
83
+ function record(value) {
84
+ return value !== null && typeof value === "object" && !Array.isArray(value) ? value : null;
85
+ }
86
+ async function readBoundedJson(response, signal) {
87
+ const reader = response.body?.getReader();
88
+ if (!reader) throw new Error("invalid_json");
89
+ const chunks = [];
90
+ let size = 0;
91
+ const abort = () => {
92
+ void reader.cancel().catch(() => {
93
+ });
94
+ };
95
+ signal.addEventListener("abort", abort, { once: true });
96
+ try {
97
+ if (signal.aborted) throw new Error("response_failed");
98
+ for (; ; ) {
99
+ const { done, value } = await reader.read();
100
+ if (done) break;
101
+ size += value.byteLength;
102
+ if (size > MAX_BODY_BYTES) throw new Error("response_too_large");
103
+ chunks.push(value);
104
+ }
105
+ const bytes = new Uint8Array(size);
106
+ let offset = 0;
107
+ for (const chunk of chunks) {
108
+ bytes.set(chunk, offset);
109
+ offset += chunk.byteLength;
110
+ }
111
+ try {
112
+ return JSON.parse(new TextDecoder().decode(bytes));
113
+ } catch {
114
+ throw new Error("invalid_json");
115
+ }
116
+ } finally {
117
+ signal.removeEventListener("abort", abort);
118
+ await reader.cancel().catch(() => {
119
+ });
120
+ reader.releaseLock();
121
+ }
122
+ }
123
+ async function probe(url, fetcher, json) {
124
+ const controller = new AbortController();
125
+ let timer;
126
+ const timeout = new Promise((resolve) => {
127
+ timer = setTimeout(() => {
128
+ controller.abort();
129
+ resolve({ status: 0, error: "request_timeout" });
130
+ }, TIMEOUT_MS);
131
+ });
132
+ try {
133
+ return await Promise.race([timeout, (async () => {
134
+ let response;
135
+ try {
136
+ response = await fetcher(url, {
137
+ headers: { "cache-control": "no-cache" },
138
+ redirect: "manual",
139
+ signal: controller.signal
140
+ });
141
+ } catch {
142
+ return { status: 0, error: "request_failed" };
143
+ }
144
+ if (!json || !response.ok) {
145
+ await response.body?.cancel().catch(() => {
146
+ });
147
+ return { status: response.status };
148
+ }
149
+ try {
150
+ return {
151
+ status: response.status,
152
+ json: await readBoundedJson(response, controller.signal),
153
+ identity: Object.fromEntries(["app-id", "chapter-id", "data-environment", "runtime"].map((name) => [name, slug(response.headers.get(`x-odla-${name}`) ?? void 0)]))
154
+ };
155
+ } catch (error) {
156
+ const code = error instanceof Error && ["invalid_json", "response_too_large"].includes(error.message) ? error.message : "response_failed";
157
+ return { status: response.status, error: code };
158
+ }
159
+ })()]);
160
+ } finally {
161
+ clearTimeout(timer);
162
+ }
163
+ }
164
+ function slug(value) {
165
+ return value === void 0 ? void 0 : typeof value === "string" && /^[a-z0-9][a-z0-9-]{1,62}$/.test(value) ? value : "invalid_value";
166
+ }
167
+ function amount(value) {
168
+ return value === void 0 ? void 0 : Number.isSafeInteger(value) && Number(value) >= 0 ? value : "invalid_value";
169
+ }
170
+ function boolean(value) {
171
+ return value === void 0 ? void 0 : typeof value === "boolean" ? value : "invalid_value";
172
+ }
32
173
  function activeMemberships(site) {
33
174
  return site.skus.filter((sku) => sku.active && sku.kind === "membership");
34
175
  }
@@ -37,31 +178,35 @@ async function probeChapterJoinContracts(manifest, fetcher = fetch) {
37
178
  if (!validation.ok) throw new TypeError(`invalid Chapter acceptance manifest: ${validation.errors.join("; ")}`);
38
179
  const findings = [];
39
180
  for (const site of manifest.targets) {
40
- let health;
41
- let configResponse;
42
- try {
43
- [health, configResponse] = await Promise.all([
44
- fetcher(`${site.origin}/api/health`, { headers: { "cache-control": "no-cache" } }),
45
- fetcher(`${site.origin}/api/join-config`, { headers: { "cache-control": "no-cache" } })
46
- ]);
47
- } catch (error) {
48
- findings.push({ targetId: site.targetId, siteId: site.siteId, path: "fetch", expected: "reachable", actual: error instanceof Error ? error.message : String(error) });
49
- continue;
50
- }
181
+ const [health, configResponse] = await Promise.all([
182
+ probe(`${site.origin}/api/health`, fetcher, true),
183
+ probe(`${site.origin}/api/join-config`, fetcher, true)
184
+ ]);
51
185
  const targetNote = (path, expected, actual) => {
52
186
  if (JSON.stringify(expected) !== JSON.stringify(actual)) findings.push({ targetId: site.targetId, siteId: site.siteId, path, expected, actual });
53
187
  };
54
- targetNote("/api/health status", 200, health.status);
55
- targetNote("/api/join-config status", 200, configResponse.status);
56
- if (!configResponse.ok) continue;
57
- let config;
58
- try {
59
- config = await configResponse.json();
60
- } catch {
61
- targetNote("/api/join-config JSON", "object", "invalid JSON");
188
+ targetNote("/api/health status", 200, health.error ?? health.status);
189
+ if (health.status === 200 && !health.error) targetNote("/api/health ok", true, boolean(record(health.json)?.ok));
190
+ if (health.status === 200 && !health.error) {
191
+ for (const [name, expected] of Object.entries({
192
+ "app-id": site.target.appId,
193
+ "chapter-id": site.target.chapterId,
194
+ "data-environment": site.target.dataEnvironment,
195
+ "runtime": site.target.runtime
196
+ })) targetNote(`/api/health x-odla-${name}`, expected, health.identity?.[name]);
197
+ }
198
+ targetNote("/api/join-config status", 200, configResponse.status || configResponse.error);
199
+ if (configResponse.status !== 200) continue;
200
+ if (configResponse.error) {
201
+ targetNote("/api/join-config JSON", "object", configResponse.error === "invalid_json" ? "invalid JSON" : configResponse.error);
202
+ continue;
203
+ }
204
+ const config = record(configResponse.json);
205
+ if (!config) {
206
+ targetNote("/api/join-config JSON", "object", "invalid shape");
62
207
  continue;
63
208
  }
64
- targetNote("id", site.target.chapterId, config.id);
209
+ targetNote("id", site.target.chapterId, slug(config.id));
65
210
  const actualTiers = Array.isArray(config.tiers) ? config.tiers : [];
66
211
  const expectedTiers = activeMemberships(site).map((sku) => ({
67
212
  id: sku.id,
@@ -71,17 +216,18 @@ async function probeChapterJoinContracts(manifest, fetcher = fetch) {
71
216
  free: sku.priceCents === 0
72
217
  }));
73
218
  const projected = actualTiers.map((tier) => {
74
- const value = tier;
219
+ const value = record(tier);
220
+ if (!value) return "invalid_tier";
75
221
  return {
76
- id: value.id,
77
- priceCents: value.priceCents,
78
- currency: value.currency,
79
- interval: value.interval,
80
- free: value.free
222
+ id: slug(value.id),
223
+ priceCents: amount(value.priceCents),
224
+ currency: value.currency === void 0 ? void 0 : typeof value.currency === "string" && /^[a-z]{3}$/.test(value.currency) ? value.currency : "invalid_value",
225
+ interval: value.interval === void 0 ? void 0 : ["month", "year", "once"].includes(String(value.interval)) ? value.interval : "invalid_value",
226
+ free: boolean(value.free)
81
227
  };
82
228
  });
83
229
  targetNote("tiers", expectedTiers, projected);
84
- targetNote("paymentsReady", expectedTiers.some((tier) => !tier.free), config.paymentsReady);
230
+ targetNote("paymentsReady", expectedTiers.some((tier) => !tier.free), boolean(config.paymentsReady));
85
231
  }
86
232
  return { ok: findings.length === 0, findings };
87
233
  }
@@ -89,16 +235,21 @@ async function probeChapterJoinContracts(manifest, fetcher = fetch) {
89
235
  // src/testing-runner.ts
90
236
  var SAFE_LABEL = /^[a-z0-9][a-z0-9._:-]{0,159}$/;
91
237
  var SAFE_REFERENCE = /^[A-Za-z0-9_:.=-]{3,240}$/;
238
+ var CREDENTIAL = /(?:^|[_.:-])(?:sk|rk|pk)_(?:test|live)_|whsec_|_secret_|eyJ[A-Za-z0-9_-]+\./i;
239
+ var ERROR_CODES = /* @__PURE__ */ new Set(["invalid_evidence", "assertion_failed", "preflight_failed", "unsupported_scenario", "execution_failed"]);
240
+ function safeValue(value, pattern) {
241
+ return pattern.test(value) && !CREDENTIAL.test(value);
242
+ }
92
243
  function safeErrorCode(error) {
93
244
  if (error && typeof error === "object") {
94
245
  const code = error.code;
95
- if (typeof code === "string" && SAFE_LABEL.test(code)) return code;
246
+ if (typeof code === "string" && ERROR_CODES.has(code)) return code;
96
247
  }
97
248
  return "execution_failed";
98
249
  }
99
250
  function validateLabels(values, path) {
100
251
  if (!Array.isArray(values) || values.length === 0) throw Object.assign(new TypeError(`${path} must be non-empty`), { code: "invalid_evidence" });
101
- if (values.length > 64 || values.some((value) => typeof value !== "string" || !SAFE_LABEL.test(value))) {
252
+ if (values.length > 64 || values.some((value) => typeof value !== "string" || !safeValue(value, SAFE_LABEL))) {
102
253
  throw Object.assign(new TypeError(`${path} contains an unsafe or unbounded label`), { code: "invalid_evidence" });
103
254
  }
104
255
  }
@@ -107,7 +258,7 @@ function validateReferences(values) {
107
258
  if (!Array.isArray(values) || values.length > 64) throw Object.assign(new TypeError("references must be bounded"), { code: "invalid_evidence" });
108
259
  const kinds = /* @__PURE__ */ new Set(["stripe_event", "stripe_object", "clerk_user", "calendar_event", "email_message", "event_digest"]);
109
260
  for (const reference of values) {
110
- if (!reference || !kinds.has(reference.kind) || typeof reference.value !== "string" || !SAFE_REFERENCE.test(reference.value) || reference.value.includes("@")) {
261
+ if (!reference || !kinds.has(reference.kind) || typeof reference.value !== "string" || !safeValue(reference.value, SAFE_REFERENCE) || reference.value.includes("@")) {
111
262
  throw Object.assign(new TypeError("references may contain only opaque provider ids or digests"), { code: "invalid_evidence" });
112
263
  }
113
264
  }
@@ -116,52 +267,70 @@ function validateEvidence(evidence, provider) {
116
267
  if (!evidence || typeof evidence !== "object") throw Object.assign(new TypeError("executor returned no evidence"), { code: "invalid_evidence" });
117
268
  validateLabels(evidence.checks, "evidence.checks");
118
269
  validateReferences(evidence.references);
119
- if (provider && (typeof evidence.deploymentVersion !== "string" || !SAFE_REFERENCE.test(evidence.deploymentVersion))) {
270
+ if ((provider || evidence.deploymentVersion !== void 0) && (typeof evidence.deploymentVersion !== "string" || !safeValue(evidence.deploymentVersion, SAFE_REFERENCE))) {
120
271
  throw Object.assign(new TypeError("provider evidence requires a safe deploymentVersion"), { code: "invalid_evidence" });
121
272
  }
273
+ return {
274
+ checks: [...evidence.checks],
275
+ ...evidence.deploymentVersion !== void 0 ? { deploymentVersion: evidence.deploymentVersion } : {},
276
+ ...evidence.references !== void 0 ? {
277
+ references: evidence.references.map(({ kind, value }) => ({ kind, value }))
278
+ } : {}
279
+ };
122
280
  }
123
281
  function validateCleanup(cleanup) {
124
282
  if (!cleanup || cleanup.status !== "complete" && cleanup.status !== "incomplete") {
125
283
  throw Object.assign(new TypeError("executor returned invalid cleanup evidence"), { code: "cleanup_failed" });
126
284
  }
127
285
  validateLabels(cleanup.checks, "cleanup.checks");
286
+ return { status: cleanup.status, checks: [...cleanup.checks] };
128
287
  }
129
- function contextFor(manifest, id) {
130
- const scenario2 = planChapterAcceptanceMatrix(manifest).find((item) => item.id === id);
131
- if (!scenario2) throw new TypeError(`unknown acceptance scenario ${id}`);
288
+ function contextFor(manifest, id2) {
289
+ const scenario2 = planChapterAcceptanceMatrix(manifest).find((item) => item.id === id2);
290
+ if (!scenario2) throw new TypeError(`unknown acceptance scenario ${id2}`);
132
291
  const target = manifest.targets.find((item) => item.targetId === scenario2.targetId);
133
292
  return {
134
293
  scenario: scenario2,
135
294
  target,
136
295
  ...scenario2.skuId ? { sku: target.skus.find((item) => item.id === scenario2.skuId) } : {},
296
+ ...scenario2.parentSkuId ? { parentSku: target.skus.find((item) => item.id === scenario2.parentSkuId) } : {},
297
+ ...scenario2.commercialCaseId ? { commercialCase: target.commercialCases?.find((item) => item.id === scenario2.commercialCaseId) } : {},
137
298
  ...scenario2.siblingTargetId ? {
138
299
  siblingTarget: manifest.targets.find((item) => item.targetId === scenario2.siblingTargetId)
139
300
  } : {}
140
301
  };
141
302
  }
142
303
  async function runChapterAcceptanceSuite(manifest, suite, executor) {
304
+ if (suite !== "hermetic" && suite !== "provider_dev") throw new TypeError("unknown acceptance suite");
143
305
  const scenarios = planChapterAcceptanceMatrix(manifest).filter((item) => item.suite === suite);
144
306
  if (suite === "provider_dev" && typeof executor.cleanup !== "function") {
145
307
  throw new TypeError("provider_dev acceptance requires a cleanup executor before any case can run");
146
308
  }
147
309
  const cases = [];
310
+ let cleanupFailed = false;
148
311
  for (const scenario2 of scenarios) {
312
+ if (cleanupFailed) {
313
+ cases.push({ id: scenario2.id, suite, outcome: "failed", errorCode: "not_run_cleanup_failed" });
314
+ continue;
315
+ }
149
316
  const context = contextFor(manifest, scenario2.id);
150
317
  let evidence;
151
318
  let errorCode;
152
319
  let cleanup;
153
320
  try {
154
- evidence = await executor.execute(context);
155
- validateEvidence(evidence, suite === "provider_dev");
321
+ evidence = validateEvidence(await executor.execute(context), suite === "provider_dev");
156
322
  } catch (error) {
157
323
  errorCode = safeErrorCode(error);
158
324
  } finally {
159
325
  if (suite === "provider_dev") {
160
326
  try {
161
- cleanup = await executor.cleanup(context);
162
- validateCleanup(cleanup);
163
- if (cleanup.status !== "complete") errorCode = "cleanup_incomplete";
327
+ cleanup = validateCleanup(await executor.cleanup(context));
328
+ if (cleanup.status !== "complete") {
329
+ cleanupFailed = true;
330
+ errorCode = "cleanup_incomplete";
331
+ }
164
332
  } catch {
333
+ cleanupFailed = true;
165
334
  errorCode = "cleanup_failed";
166
335
  }
167
336
  }
@@ -186,6 +355,66 @@ function formatChapterAcceptanceRun(result) {
186
355
  return lines.join("\n");
187
356
  }
188
357
 
358
+ // src/testing-release.ts
359
+ var ID = /^[a-z0-9][a-z0-9_.:-]{1,199}$/;
360
+ var validId = (value) => typeof value === "string" && ID.test(value);
361
+ var opaque = (value) => typeof value === "string" && /^[A-Za-z0-9][A-Za-z0-9_:-]{2,159}$/.test(value) && !/(?:sk|rk|pk|whsec)_(?:test_|live_)?|_secret_|bearer|password|token/i.test(value);
362
+ var keys = ["targetId", "appId", "chapterId", "runtime", "environment", "dataEnvironment", "stripeMode", "sourceCommit", "deploymentVersion"];
363
+ function assessChapterReleaseEvidence(plan, proofs) {
364
+ const blockers = [];
365
+ const block = (requirementId, code) => blockers.push({ requirementId, code });
366
+ const targets = /* @__PURE__ */ new Map();
367
+ if (!plan || plan.schemaVersion !== 1 || !Array.isArray(plan.targets) || !plan.targets.length || plan.targets.length > 64 || !Array.isArray(plan.requirements) || !plan.requirements.length || plan.requirements.length > 4096 || !Array.isArray(proofs) || proofs.length > 8192) {
368
+ return {
369
+ readyForReview: false,
370
+ productionAuthorized: false,
371
+ required: 0,
372
+ passed: 0,
373
+ blockers: [{ requirementId: "plan", code: "invalid_release_plan" }]
374
+ };
375
+ }
376
+ for (const target of plan.targets) {
377
+ if (!target || !validId(target.targetId) || targets.has(target.targetId) || ![target.appId, target.chapterId, target.runtime].every(validId) || target.environment !== "dev" || target.dataEnvironment !== "dev" || target.stripeMode !== "test" || typeof target.sourceCommit !== "string" || !/^[0-9a-f]{40}$/.test(target.sourceCommit) || !opaque(target.deploymentVersion)) {
378
+ block("plan", "invalid_or_undeployed_target");
379
+ continue;
380
+ }
381
+ targets.set(target.targetId, target);
382
+ }
383
+ const requirementIds = /* @__PURE__ */ new Set();
384
+ let passed = 0;
385
+ for (const requirement of plan.requirements) {
386
+ if (!requirement || !validId(requirement.id) || requirementIds.has(requirement.id) || !["deterministic", "deployed_contract", "provider_dev", "browser_dev"].includes(requirement.phase) || !Array.isArray(requirement.assertions) || !requirement.assertions.length || !requirement.assertions.every(validId) || new Set(requirement.assertions).size !== requirement.assertions.length || !Array.isArray(requirement.providers) || requirement.providers.some((kind) => !["stripe", "clerk", "calendar", "email"].includes(kind)) || !Array.isArray(requirement.cleanup) || !requirement.cleanup.every(validId) || requirement.phase === "provider_dev" && (!requirement.providers.length || !requirement.cleanup.length)) {
387
+ block("plan", "invalid_requirement");
388
+ continue;
389
+ }
390
+ requirementIds.add(requirement.id);
391
+ const target = targets.get(requirement.targetId);
392
+ if (!target) {
393
+ block(requirement.id, "missing_candidate_target");
394
+ continue;
395
+ }
396
+ const matching = proofs.filter((proof2) => proof2?.requirementId === requirement.id);
397
+ if (matching.length !== 1) {
398
+ block(requirement.id, matching.length ? "duplicate_proof" : "missing_proof");
399
+ continue;
400
+ }
401
+ const proof = matching[0];
402
+ const before = blockers.length;
403
+ if (!proof.target || keys.some((key) => proof.target[key] !== target[key])) block(requirement.id, "candidate_mismatch");
404
+ if (proof.phase !== requirement.phase) block(requirement.id, "wrong_execution_phase");
405
+ if (proof.outcome !== "passed") block(requirement.id, "attempt_not_passed");
406
+ if (!proof.assertions || requirement.assertions.some((id2) => proof.assertions[id2] !== true)) block(requirement.id, "assertions_incomplete");
407
+ if (!Array.isArray(proof.references) || proof.references.some((reference) => !reference || !opaque(reference.id) || !["stripe", "clerk", "calendar", "email"].includes(reference.provider)) || requirement.providers.some((provider) => !proof.references.some((reference) => reference.provider === provider))) block(requirement.id, "provider_evidence_incomplete");
408
+ if (!proof.cleanup || requirement.cleanup.some((id2) => proof.cleanup[id2] !== true)) block(requirement.id, "cleanup_incomplete");
409
+ if (blockers.length === before) passed++;
410
+ }
411
+ if (proofs.some((proof) => !proof || !requirementIds.has(proof.requirementId))) block("plan", "unexpected_proof");
412
+ for (const target of targets.values()) {
413
+ if (!plan.requirements.some((requirement) => requirement?.targetId === target.targetId)) block("plan", "uncovered_target");
414
+ }
415
+ return { readyForReview: blockers.length === 0, productionAuthorized: false, required: plan.requirements.length, passed, blockers };
416
+ }
417
+
189
418
  // src/testing.ts
190
419
  var SLUG = /^[a-z0-9][a-z0-9-]{1,62}$/;
191
420
  var CURRENCY = /^[a-z]{3}$/;
@@ -193,15 +422,12 @@ var STRIPE_PRICE = /^price_[A-Za-z0-9]{8,}$/;
193
422
  function activeMemberships2(site) {
194
423
  return site.skus.filter((sku) => sku.active && sku.kind === "membership");
195
424
  }
196
- function representativePaidMembership(site) {
197
- return activeMemberships2(site).find((sku) => sku.priceCents > 0);
198
- }
199
- function record(value) {
425
+ function record2(value) {
200
426
  return value !== null && typeof value === "object" && !Array.isArray(value) ? value : null;
201
427
  }
202
428
  function validateChapterAcceptanceManifest(value) {
203
429
  const errors = [];
204
- const input = record(value);
430
+ const input = record2(value);
205
431
  if (!input) return { ok: false, errors: ["manifest must be an object"] };
206
432
  if (input.schemaVersion !== 1) errors.push("schemaVersion must be 1");
207
433
  const inputTargets = Array.isArray(input.targets) ? input.targets : [];
@@ -212,7 +438,7 @@ function validateChapterAcceptanceManifest(value) {
212
438
  const priceOwners = /* @__PURE__ */ new Map();
213
439
  for (const [siteIndex, rawSite] of inputTargets.entries()) {
214
440
  const path = `targets[${siteIndex}]`;
215
- const site = record(rawSite);
441
+ const site = record2(rawSite);
216
442
  if (!site) {
217
443
  errors.push(`${path} must be an object`);
218
444
  continue;
@@ -227,13 +453,13 @@ function validateChapterAcceptanceManifest(value) {
227
453
  try {
228
454
  if (typeof site.origin !== "string") throw new TypeError("origin");
229
455
  const origin = new URL(site.origin);
230
- if (origin.protocol !== "https:" || origin.pathname !== "/" || origin.search || origin.hash) {
456
+ if (origin.protocol !== "https:" || origin.pathname !== "/" || origin.search || origin.hash || origin.username || origin.password) {
231
457
  errors.push(`${path}.origin must be an HTTPS origin without a path, query, or fragment`);
232
458
  }
233
459
  } catch {
234
460
  errors.push(`${path}.origin must be a valid HTTPS origin`);
235
461
  }
236
- const target = record(site.target);
462
+ const target = record2(site.target);
237
463
  const targetValues = {};
238
464
  for (const key of ["appId", "environment", "dataEnvironment", "runtime", "chapterId"]) {
239
465
  targetValues[key] = typeof target?.[key] === "string" ? String(target[key]) : "";
@@ -254,15 +480,15 @@ function validateChapterAcceptanceManifest(value) {
254
480
  const skus = [];
255
481
  for (const [skuIndex, rawSku] of inputSkus.entries()) {
256
482
  const skuPath = `${path}.skus[${skuIndex}]`;
257
- const sku = record(rawSku);
483
+ const sku = record2(rawSku);
258
484
  if (!sku) {
259
485
  errors.push(`${skuPath} must be an object`);
260
486
  continue;
261
487
  }
262
- const id = typeof sku.id === "string" ? sku.id : "";
263
- if (!SLUG.test(id)) errors.push(`${skuPath}.id must be a stable lowercase slug`);
264
- if (skuIds.has(id)) errors.push(`${skuPath}.id duplicates ${JSON.stringify(id)}`);
265
- if (id) skuIds.add(id);
488
+ const id2 = typeof sku.id === "string" ? sku.id : "";
489
+ if (!SLUG.test(id2)) errors.push(`${skuPath}.id must be a stable lowercase slug`);
490
+ if (skuIds.has(id2)) errors.push(`${skuPath}.id duplicates ${JSON.stringify(id2)}`);
491
+ if (id2) skuIds.add(id2);
266
492
  if (sku.kind !== "membership" && sku.kind !== "add_on") errors.push(`${skuPath}.kind must be membership or add_on`);
267
493
  if (typeof sku.active !== "boolean") errors.push(`${skuPath}.active must be boolean`);
268
494
  const priceCents = typeof sku.priceCents === "number" ? sku.priceCents : Number.NaN;
@@ -279,7 +505,7 @@ function validateChapterAcceptanceManifest(value) {
279
505
  }
280
506
  if (priceCents === 0 && sku.stripePriceId) errors.push(`${skuPath}.stripePriceId must be absent for a free SKU`);
281
507
  if (typeof sku.stripePriceId === "string" && sku.stripePriceId) {
282
- const owner = `${siteId}/${id}`;
508
+ const owner = `${siteId}/${id2}`;
283
509
  const previous = priceOwners.get(sku.stripePriceId);
284
510
  if (previous && previous !== owner) errors.push(`${skuPath}.stripePriceId is already owned by ${previous}`);
285
511
  else priceOwners.set(sku.stripePriceId, owner);
@@ -297,12 +523,13 @@ function validateChapterAcceptanceManifest(value) {
297
523
  skus.push(sku);
298
524
  }
299
525
  if (!skus.some((sku) => sku.active && sku.kind === "membership")) errors.push(`${path}.skus must contain an active membership SKU`);
300
- const options = record(site.options);
526
+ errors.push(...validateCommercialCases(site.commercialCases, skus, `${path}.commercialCases`));
527
+ const options = record2(site.options);
301
528
  if (!options) errors.push(`${path}.options must be an object`);
302
529
  if (options?.ordinaryInterview !== true) errors.push(`${path}.options.ordinaryInterview must be true`);
303
530
  if (typeof options?.invitationInterviewWaiver !== "boolean") errors.push(`${path}.options.invitationInterviewWaiver must be boolean`);
304
531
  if (options?.autoRenew !== true) errors.push(`${path}.options.autoRenew must be true`);
305
- const seat = record(options?.additionalSeat);
532
+ const seat = record2(options?.additionalSeat);
306
533
  if (options?.additionalSeat !== void 0 && !seat) errors.push(`${path}.options.additionalSeat must be an object`);
307
534
  if (seat) {
308
535
  const seatSku = skus.find((sku) => sku.id === seat.skuId);
@@ -325,17 +552,23 @@ function validateChapterAcceptanceManifest(value) {
325
552
  function scenario(input) {
326
553
  return {
327
554
  ...input,
328
- id: [input.suite, input.targetId, input.skuId, input.siblingTargetId, input.kind].filter(Boolean).join(":")
555
+ id: [input.suite, input.targetId, input.skuId, input.parentSkuId, input.commercialCaseId, input.siblingTargetId, input.kind].filter(Boolean).join(":")
329
556
  };
330
557
  }
331
558
  function planChapterAcceptanceMatrix(manifest) {
332
559
  const validation = validateChapterAcceptanceManifest(manifest);
333
560
  if (!validation.ok) throw new TypeError(`invalid Chapter acceptance manifest: ${validation.errors.join("; ")}`);
334
- if (manifest.targets.some((site) => site.stripeMode !== "test")) {
335
- throw new TypeError("provider acceptance runs are development-only and require Stripe test mode on every target");
561
+ if (manifest.targets.some((site) => site.stripeMode !== "test" || site.target.environment !== "dev" || site.target.dataEnvironment !== "dev")) {
562
+ throw new TypeError("provider acceptance runs are development-only and require Stripe test mode and dev runtime/data environments on every target");
336
563
  }
337
564
  const scenarios = [];
338
565
  for (const site of manifest.targets) {
566
+ for (const vector of site.commercialCases ?? []) {
567
+ const base = { targetId: site.targetId, siteId: site.siteId, skuId: vector.skuId, commercialCaseId: vector.id };
568
+ scenarios.push(scenario({ ...base, suite: "hermetic", kind: "commercial_policy_replay" }));
569
+ scenarios.push(scenario({ ...base, suite: "provider_dev", kind: "commercial_quote" }));
570
+ scenarios.push(scenario({ ...base, suite: "provider_dev", kind: "commercial_renewal" }));
571
+ }
339
572
  for (const sku of site.skus.filter((candidate) => candidate.active)) {
340
573
  scenarios.push(scenario({ suite: "hermetic", kind: "catalog_contract", targetId: site.targetId, siteId: site.siteId, skuId: sku.id }));
341
574
  if (sku.priceCents > 0) {
@@ -364,40 +597,27 @@ function planChapterAcceptanceMatrix(manifest) {
364
597
  skuId: sku.id
365
598
  }));
366
599
  }
367
- const paid = representativePaidMembership(site);
368
- if (paid) {
369
- scenarios.push(scenario({ suite: "provider_dev", kind: "ordinary_interview", targetId: site.targetId, siteId: site.siteId, skuId: paid.id }));
370
- scenarios.push(scenario({ suite: "provider_dev", kind: "renewal", targetId: site.targetId, siteId: site.siteId, skuId: paid.id }));
371
- scenarios.push(scenario({ suite: "provider_dev", kind: "refund_cancel", targetId: site.targetId, siteId: site.siteId, skuId: paid.id }));
600
+ for (const membership of activeMemberships2(site)) {
601
+ const base = { targetId: site.targetId, siteId: site.siteId, skuId: membership.id };
602
+ scenarios.push(scenario({ ...base, suite: "provider_dev", kind: "ordinary_interview" }));
372
603
  if (site.options.invitationInterviewWaiver) {
373
- scenarios.push(scenario({ suite: "provider_dev", kind: "invitation_interview_waiver", targetId: site.targetId, siteId: site.siteId, skuId: paid.id }));
374
- scenarios.push(scenario({ suite: "hermetic", kind: "expired_interview_waiver", targetId: site.targetId, siteId: site.siteId, skuId: paid.id }));
375
- scenarios.push(scenario({ suite: "hermetic", kind: "replayed_interview_waiver", targetId: site.targetId, siteId: site.siteId, skuId: paid.id }));
604
+ scenarios.push(scenario({ ...base, suite: "provider_dev", kind: "invitation_interview_waiver" }));
605
+ scenarios.push(scenario({ ...base, suite: "hermetic", kind: "expired_interview_waiver" }));
606
+ scenarios.push(scenario({ ...base, suite: "hermetic", kind: "replayed_interview_waiver" }));
376
607
  }
608
+ if (membership.priceCents === 0) continue;
609
+ scenarios.push(scenario({ ...base, suite: "provider_dev", kind: "renewal" }));
610
+ scenarios.push(scenario({ ...base, suite: "provider_dev", kind: "refund_cancel" }));
377
611
  if (site.options.additionalSeat) {
378
- scenarios.push(scenario({
379
- suite: "provider_dev",
380
- kind: "named_seat",
381
- targetId: site.targetId,
382
- siteId: site.siteId,
383
- skuId: site.options.additionalSeat.skuId
384
- }));
385
- scenarios.push(scenario({
386
- suite: "hermetic",
387
- kind: "duplicate_seat_email",
388
- targetId: site.targetId,
389
- siteId: site.siteId,
390
- skuId: site.options.additionalSeat.skuId
391
- }));
392
- scenarios.push(scenario({
393
- suite: "hermetic",
394
- kind: "expired_seat_window",
395
- targetId: site.targetId,
396
- siteId: site.siteId,
397
- skuId: site.options.additionalSeat.skuId
398
- }));
612
+ const seat = { ...base, skuId: site.options.additionalSeat.skuId, parentSkuId: membership.id };
613
+ for (const kind of ["named_seat", "seat_declined_refund", "unaccepted_seat_refund", "seat_membership_alignment"]) {
614
+ scenarios.push(scenario({ ...seat, suite: "provider_dev", kind }));
615
+ }
616
+ for (const kind of ["duplicate_seat_email", "expired_seat_window"]) {
617
+ scenarios.push(scenario({ ...seat, suite: "hermetic", kind }));
618
+ }
399
619
  }
400
- scenarios.push(scenario({ suite: "hermetic", kind: "canceled_membership", targetId: site.targetId, siteId: site.siteId, skuId: paid.id }));
620
+ scenarios.push(scenario({ ...base, suite: "hermetic", kind: "canceled_membership" }));
401
621
  }
402
622
  }
403
623
  return scenarios;