@farthershore/product 0.0.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/bin.js ADDED
@@ -0,0 +1,2766 @@
1
+ #!/usr/bin/env node
2
+ import { createRequire as __createRequire } from "node:module";const require=__createRequire(import.meta.url);
3
+
4
+ // src/bin.ts
5
+ import { writeFile } from "node:fs/promises";
6
+ import { resolve } from "node:path";
7
+ import { pathToFileURL } from "node:url";
8
+ import { tsImport } from "tsx/esm/api";
9
+
10
+ // src/errors.ts
11
+ var ManifestValidationError = class extends Error {
12
+ issues;
13
+ constructor(issues) {
14
+ const summary = issues.slice(0, 5).map((issue) => ` - ${issue.path || "(root)"}: ${issue.message}`).join("\n");
15
+ const more = issues.length > 5 ? `
16
+ \u2026and ${issues.length - 5} more` : "";
17
+ super(`Manifest validation failed:
18
+ ${summary}${more}`);
19
+ this.name = "ManifestValidationError";
20
+ this.issues = issues;
21
+ }
22
+ };
23
+ var ManifestBuilderError = class extends Error {
24
+ constructor(message) {
25
+ super(message);
26
+ this.name = "ManifestBuilderError";
27
+ }
28
+ };
29
+
30
+ // ../contracts/dist/plans/limits-schema.js
31
+ import { z } from "zod";
32
+ var limitDimensionSchema = z.string().min(1);
33
+ var namedWindowSchema = z.object({
34
+ type: z.literal("named"),
35
+ name: z.enum(["second", "minute", "hour", "day", "week", "month"])
36
+ });
37
+ var customWindowSchema = z.object({
38
+ type: z.literal("custom"),
39
+ seconds: z.number().int().positive(),
40
+ label: z.string().optional()
41
+ });
42
+ var limitWindowSpecSchema = z.discriminatedUnion("type", [
43
+ namedWindowSchema,
44
+ customWindowSchema
45
+ ]);
46
+ var planLimitRuleSchema = z.object({
47
+ dimension: limitDimensionSchema,
48
+ window: limitWindowSpecSchema,
49
+ capacity: z.number().nonnegative(),
50
+ enforcement: z.enum(["enforce", "track"]).optional(),
51
+ /**
52
+ * Opt-out for the `LIMIT_METER_UNREACHABLE` cross-validation
53
+ * (Option B / v0.41.0). When `true`, the limit is allowed to
54
+ * reference a meter no granted route emits — useful for soft
55
+ * budgets on derived dimensions (e.g. `dollars` computed from
56
+ * sibling meters by the billing engine), or for out-of-band
57
+ * tracking the runtime path doesn't enforce.
58
+ */
59
+ acknowledgeUnreachable: z.boolean().optional()
60
+ });
61
+ var planLimitsSchema = z.array(planLimitRuleSchema).max(20);
62
+
63
+ // ../contracts/dist/plans/spec/subscriber-change-policy.js
64
+ import { z as z2 } from "zod";
65
+ var subscriberChangeActionSchema = z2.enum([
66
+ "preserve_current_period",
67
+ "switch_immediately",
68
+ "switch_immediately_prorate",
69
+ "new_subscribers_only"
70
+ ]);
71
+ var subscriberChangePolicySchema = z2.object({
72
+ default: subscriberChangeActionSchema.default("preserve_current_period"),
73
+ proration: z2.enum(["none", "prorate", "credit"]).default("none"),
74
+ when: z2.object({
75
+ price_increase: subscriberChangeActionSchema.optional(),
76
+ price_decrease: subscriberChangeActionSchema.optional(),
77
+ feature_added: subscriberChangeActionSchema.optional(),
78
+ feature_removed: subscriberChangeActionSchema.optional(),
79
+ limit_increased: subscriberChangeActionSchema.optional(),
80
+ limit_reduced: subscriberChangeActionSchema.optional(),
81
+ credit_increased: subscriberChangeActionSchema.optional(),
82
+ credit_reduced: subscriberChangeActionSchema.optional(),
83
+ rating_changed: subscriberChangeActionSchema.optional()
84
+ }).strict().default({
85
+ price_increase: "preserve_current_period",
86
+ price_decrease: "switch_immediately",
87
+ feature_added: "switch_immediately",
88
+ feature_removed: "preserve_current_period",
89
+ limit_increased: "switch_immediately",
90
+ limit_reduced: "preserve_current_period",
91
+ credit_increased: "switch_immediately",
92
+ credit_reduced: "preserve_current_period",
93
+ rating_changed: "preserve_current_period"
94
+ }),
95
+ allowImmediatePriceIncrease: z2.boolean().default(false),
96
+ allowImmediateEntitlementReduction: z2.boolean().default(false)
97
+ }).strict().superRefine((policy, ctx) => {
98
+ const immediate = /* @__PURE__ */ new Set([
99
+ "switch_immediately",
100
+ "switch_immediately_prorate"
101
+ ]);
102
+ if (immediate.has(policy.when.price_increase ?? policy.default) && !policy.allowImmediatePriceIncrease) {
103
+ ctx.addIssue({
104
+ code: "custom",
105
+ path: ["when", "price_increase"],
106
+ message: "price_increase cannot switch immediately without allowImmediatePriceIncrease: true"
107
+ });
108
+ }
109
+ for (const key of [
110
+ "feature_removed",
111
+ "limit_reduced",
112
+ "credit_reduced"
113
+ ]) {
114
+ if (immediate.has(policy.when[key] ?? policy.default) && !policy.allowImmediateEntitlementReduction) {
115
+ ctx.addIssue({
116
+ code: "custom",
117
+ path: ["when", key],
118
+ message: `${key} cannot switch immediately without allowImmediateEntitlementReduction: true`
119
+ });
120
+ }
121
+ }
122
+ });
123
+
124
+ // ../contracts/dist/plans/spec/plan-pricing.js
125
+ import { z as z4 } from "zod";
126
+
127
+ // ../contracts/dist/plans/grants.js
128
+ import { z as z3 } from "zod";
129
+ var recurringGrantSchema = z3.object({
130
+ kind: z3.literal("recurring"),
131
+ amount_cents: z3.number().int().nonnegative()
132
+ });
133
+ var oneTimeGrantSchema = z3.object({
134
+ kind: z3.literal("one_time"),
135
+ amount_cents: z3.number().int().nonnegative()
136
+ });
137
+ var promotionalGrantSchema = z3.object({
138
+ kind: z3.literal("promotional"),
139
+ amount_cents: z3.number().int().nonnegative(),
140
+ label: z3.string().min(1).max(120),
141
+ expires_after_days: z3.number().int().positive().optional()
142
+ });
143
+ var trialGrantSchema = z3.object({
144
+ kind: z3.literal("trial")
145
+ });
146
+ var rolloverGrantSchema = z3.object({
147
+ kind: z3.literal("rollover"),
148
+ percent: z3.number().int().min(0).max(100)
149
+ });
150
+ var topUpGrantSchema = z3.object({
151
+ kind: z3.literal("top_up"),
152
+ /** Stable identifier surfaced to the customer (e.g. "tokens-50k"). */
153
+ sku: z3.string().min(1).max(120),
154
+ label: z3.string().min(1).max(200),
155
+ /** Price charged via Stripe checkout, in cents. */
156
+ price_cents: z3.number().int().positive(),
157
+ /** Credit balance granted when the pack is purchased, in cents.
158
+ * Typically >= price_cents (the difference is the bulk discount). */
159
+ credit_cents: z3.number().int().positive()
160
+ });
161
+ var autoRechargeGrantSchema = z3.object({
162
+ kind: z3.literal("auto_recharge"),
163
+ /** When current balance < this many cents, trigger a refill. */
164
+ threshold_cents: z3.number().int().nonnegative(),
165
+ /** Charge + grant this much on each refill, in cents. */
166
+ refill_cents: z3.number().int().positive()
167
+ });
168
+ var grantSchema = z3.discriminatedUnion("kind", [
169
+ recurringGrantSchema,
170
+ oneTimeGrantSchema,
171
+ promotionalGrantSchema,
172
+ trialGrantSchema,
173
+ rolloverGrantSchema,
174
+ topUpGrantSchema,
175
+ autoRechargeGrantSchema
176
+ ]);
177
+ function refineSingleCanonicalGrant(grants, ctx, path = ["grants"]) {
178
+ if (!grants)
179
+ return;
180
+ let recurringCount = 0;
181
+ let oneTimeCount = 0;
182
+ for (const g of grants) {
183
+ if (g.kind === "recurring")
184
+ recurringCount += 1;
185
+ else if (g.kind === "one_time")
186
+ oneTimeCount += 1;
187
+ }
188
+ if (recurringCount > 1) {
189
+ ctx.addIssue({
190
+ code: "custom",
191
+ message: "At most one `recurring` grant is allowed \u2014 recurring credit is a single canonical entry.",
192
+ path
193
+ });
194
+ }
195
+ if (oneTimeCount > 1) {
196
+ ctx.addIssue({
197
+ code: "custom",
198
+ message: "At most one `one_time` grant is allowed \u2014 one-time credit is a single canonical entry.",
199
+ path
200
+ });
201
+ }
202
+ }
203
+
204
+ // ../contracts/dist/plans/spec/plan-pricing.js
205
+ var meterKindSchema = z4.enum(["linear", "active_count"]);
206
+ var meterTierSchema = z4.object({
207
+ up_to: z4.number().int().positive().nullable(),
208
+ price_per_unit_micros: z4.number().int().nonnegative()
209
+ });
210
+ var tieredPricingSchema = z4.object({
211
+ strategy: z4.enum(["graduated", "volume"]),
212
+ tiers: z4.array(meterTierSchema).min(1).max(20)
213
+ });
214
+ var meterSchema = z4.object({
215
+ dimension: z4.string().min(1).max(64).regex(/^[a-z0-9_]+$/, "Meter dimension must be lowercase alphanumeric with underscores"),
216
+ /** Aggregation kind. `linear` (default) sums event quantities across
217
+ * the period. `active_count` samples the latest quantity in the
218
+ * period (use for seats / active-user / occupancy snapshots). */
219
+ kind: meterKindSchema.default("linear"),
220
+ /** Flat per-unit rate. Mutually exclusive with `tiered`. */
221
+ price_per_unit_micros: z4.number().int().nonnegative().default(0),
222
+ /** Tiered schedule. Mutually exclusive with a non-zero
223
+ * `price_per_unit_micros`. */
224
+ tiered: tieredPricingSchema.optional(),
225
+ /** Per-meter included allotment: the first N units of this dimension
226
+ * are free, every unit beyond N is billed at the flat
227
+ * `price_per_unit_micros` rate (R may be 0). Compiles to a 2-tier
228
+ * graduated schedule `[{up_to: N, micros: 0}, {up_to: null, micros: R}]`
229
+ * and emits a tracking `quota` constraint so the gateway knows how
230
+ * much is included.
231
+ *
232
+ * Mutually exclusive with `tiered` — a tiered schedule already
233
+ * expresses the full breakpoint ladder (model the free pool as a
234
+ * zero-priced first tier instead). Only meaningful on a flat meter. */
235
+ included_units: z4.number().int().positive().optional()
236
+ }).superRefine((m, ctx) => {
237
+ if (m.tiered && m.price_per_unit_micros > 0) {
238
+ ctx.addIssue({
239
+ code: "custom",
240
+ message: "`tiered` is mutually exclusive with a non-zero `price_per_unit_micros` \u2014 drop one",
241
+ path: ["tiered"]
242
+ });
243
+ }
244
+ if (m.included_units !== void 0 && m.tiered) {
245
+ ctx.addIssue({
246
+ code: "custom",
247
+ message: "`included_units` is mutually exclusive with `tiered` \u2014 a tiered schedule already expresses the included allotment as a zero-priced first tier",
248
+ path: ["included_units"]
249
+ });
250
+ }
251
+ if (m.tiered) {
252
+ let prevBound = 0;
253
+ let sawNull = false;
254
+ for (let i = 0; i < m.tiered.tiers.length; i++) {
255
+ const t = m.tiered.tiers[i];
256
+ if (sawNull) {
257
+ ctx.addIssue({
258
+ code: "custom",
259
+ message: "Open-ended tier (`up_to: null`) must be the final tier",
260
+ path: ["tiered", "tiers", i, "up_to"]
261
+ });
262
+ break;
263
+ }
264
+ if (t.up_to === null) {
265
+ sawNull = true;
266
+ continue;
267
+ }
268
+ if (t.up_to <= prevBound) {
269
+ ctx.addIssue({
270
+ code: "custom",
271
+ message: `Tier upper bounds must be strictly ascending (tier ${i} = ${t.up_to} <= previous ${prevBound})`,
272
+ path: ["tiered", "tiers", i, "up_to"]
273
+ });
274
+ }
275
+ prevBound = t.up_to;
276
+ }
277
+ if (!sawNull) {
278
+ ctx.addIssue({
279
+ code: "custom",
280
+ message: "The final tier of a `tiered` schedule must be open-ended (`up_to: null`)",
281
+ path: ["tiered", "tiers", m.tiered.tiers.length - 1, "up_to"]
282
+ });
283
+ }
284
+ }
285
+ });
286
+ var billingIntervalSchema = z4.enum(["month", "year"]);
287
+ var planPricingSchema = z4.object({
288
+ /** Per-dimension price list. Empty array = no metered billing. */
289
+ meters: z4.array(meterSchema).default([]),
290
+ /** Flat subscription fee per period, in cents. 0 = no recurring fee. */
291
+ recurring_fee_cents: z4.number().int().nonnegative().default(0),
292
+ /** Billing cadence for the recurring fee + metered usage. Defaults to
293
+ * `month`; set `year` for annual billing (drives both the Stripe price
294
+ * `recurring.interval` and the spend-cap / quota billing-period window
295
+ * length). */
296
+ billing_interval: billingIntervalSchema.default("month"),
297
+ /** Unified credit-grant array — the SINGLE credit surface. Recurring +
298
+ * one-time credit are canonical entries here (the legacy scalar knobs
299
+ * were removed). */
300
+ grants: z4.array(grantSchema).max(40).default([]),
301
+ /** Free-trial length in days. Trial enforcement runs through Stripe
302
+ * webhooks → `lifecycle_block` constraint at runtime. */
303
+ trial_days: z4.number().int().nonnegative().default(0),
304
+ /** Optional hard cap on monthly spend, in cents. Orthogonal to the
305
+ * billing math — the gateway blocks once this cap is reached even if
306
+ * credit balance remains. Stripe doesn't enforce this; the runtime
307
+ * does, via a `quota`-shaped constraint. */
308
+ max_monthly_spend_cents: z4.number().int().nonnegative().optional()
309
+ });
310
+
311
+ // ../contracts/dist/plans/spec/plan-variant.js
312
+ import { z as z5 } from "zod";
313
+ var proRationOnRollbackSchema = z5.enum(["NONE", "PRORATE", "CREDIT"]).default("NONE");
314
+ var billingKnobsShape = {
315
+ meters: z5.array(meterSchema).default([]),
316
+ recurring_fee_cents: z5.number().int().nonnegative().default(0),
317
+ /** Billing cadence (`month` default / `year`). Drives the Stripe price
318
+ * `recurring.interval` and the entitlement billing-period window. */
319
+ billing_interval: billingIntervalSchema.default("month"),
320
+ trial_days: z5.number().int().nonnegative().default(0),
321
+ /** Hard maximum on metered spend per period. Gateway blocks once
322
+ * cumulative usage cost reaches this cap. Orthogonal to the
323
+ * recurring fee — the fee is always charged. */
324
+ max_monthly_spend_cents: z5.number().int().nonnegative().optional(),
325
+ /** Minimum metered spend per period. When set, the invoice floors
326
+ * the metered total at this value (Railway-style minimum-spend).
327
+ * Orthogonal to `recurring_fee_cents` — the fee is added on top.
328
+ * Set to 0 / omit to disable. */
329
+ min_monthly_spend_cents: z5.number().int().nonnegative().optional(),
330
+ /**
331
+ * Unified grants array — the SINGLE credit surface (v0.56+). Each entry
332
+ * expresses one credit grant primitive: `recurring`, `one_time`,
333
+ * `promotional`, `trial`, `rollover`, `top_up`, `auto_recharge`.
334
+ *
335
+ * Recurring + one-time credit are declared here as canonical
336
+ * `{kind:"recurring"}` / `{kind:"one_time"}` entries (at most one of
337
+ * each — enforced by `refineSingleCanonicalGrant`). The legacy
338
+ * `recurring_credit_grant_cents` / `one_time_credit_grant_cents` scalar
339
+ * knobs were removed; call `getEffectiveGrants(plan)` to read the
340
+ * consolidated list.
341
+ */
342
+ grants: z5.array(grantSchema).max(40).default([])
343
+ };
344
+ var billingKnobsOptionalShape = {
345
+ meters: z5.array(meterSchema).optional(),
346
+ recurring_fee_cents: z5.number().int().nonnegative().optional(),
347
+ /** Variant override for the billing cadence. Absent → inherit the
348
+ * parent plan's `billing_interval`. */
349
+ billing_interval: billingIntervalSchema.optional(),
350
+ trial_days: z5.number().int().nonnegative().optional(),
351
+ max_monthly_spend_cents: z5.number().int().nonnegative().optional(),
352
+ min_monthly_spend_cents: z5.number().int().nonnegative().optional(),
353
+ /** Variant override for the grants array — the single credit surface.
354
+ * When present, fully replaces the parent plan's grants (no shallow
355
+ * merge — grants are collectively meaningful). */
356
+ grants: z5.array(grantSchema).max(40).optional()
357
+ };
358
+ var planVariantObjectSchema = z5.object({
359
+ /**
360
+ * Stable variant id — used as the second half of the CompiledPlan
361
+ * lineage key, so changing it counts as create-new + archive-old.
362
+ * Lower-case kebab-case to match URL-share-link readability.
363
+ */
364
+ id: z5.string().min(1).max(64).regex(/^[a-z0-9_-]+$/, "Variant id must be lowercase alphanumeric with hyphens/underscores"),
365
+ /** Human-readable label for dashboards / observability. */
366
+ label: z5.string().max(200).optional(),
367
+ /**
368
+ * Rollout percentage (0-100). 0 = paused (variant exists but no new
369
+ * assignments); 100 = full takeover (effectively a forced graduation
370
+ * for new subscribers, but legacy subs stay on parent until period end).
371
+ */
372
+ rolloutPercent: z5.number().int().min(0).max(100),
373
+ /**
374
+ * Seed for the deterministic hash function. Rotating the seed
375
+ * invalidates existing variant assignments — useful for re-running an
376
+ * experiment with a fresh cohort.
377
+ */
378
+ assignmentSeed: z5.string().min(1).max(100).default("default"),
379
+ /**
380
+ * What happens to billing when this variant gets rolled back AND the
381
+ * subscriber has already been billed for the experimental price:
382
+ * - NONE (default): next period bills at parent price; no refund
383
+ * - PRORATE: Stripe `proration_behavior=create_prorations` → mid-period credit
384
+ * - CREDIT: full credit for the variant-priced charge as customer balance
385
+ * Out-of-scope: auto-refund. Builders use `billing.refund` if needed.
386
+ */
387
+ prorationOnRollback: proRationOnRollbackSchema,
388
+ // Optional 5-knob overrides — missing fields inherit from the parent
389
+ // plan. The pre-0.53 `pricing: planPricingSchema.optional()` field is
390
+ // gone; variants now override the knobs directly.
391
+ ...billingKnobsOptionalShape,
392
+ limits: z5.array(planLimitRuleSchema).max(20).optional(),
393
+ featureGates: z5.record(z5.string(), z5.boolean()).optional(),
394
+ /** See `planSpecSchema.capability_limits`. Variant overrides are
395
+ * merged shallowly onto the parent plan's map — missing keys
396
+ * inherit from the parent. */
397
+ capability_limits: z5.record(z5.string().min(1).max(120), z5.union([z5.number().int().nonnegative(), z5.boolean()])).optional(),
398
+ overageBehavior: z5.enum(["block", "allow_and_bill"]).optional()
399
+ });
400
+ var planVariantSchema = planVariantObjectSchema.superRefine((variant, ctx) => {
401
+ refineSingleCanonicalGrant(variant.grants, ctx);
402
+ });
403
+ var planSpecObjectSchema = z5.object({
404
+ key: z5.string().min(1).max(64).regex(/^[a-z0-9_-]+$/, "Plan key must be lowercase alphanumeric with hyphens/underscores"),
405
+ name: z5.string().min(1).max(100),
406
+ description: z5.string().max(500).optional(),
407
+ details: z5.array(z5.string().max(200)).max(10).optional(),
408
+ // ---------------------------------------------------------------------
409
+ // 5-knob billing shape (v0.53.0)
410
+ // ---------------------------------------------------------------------
411
+ //
412
+ // Billing math:
413
+ // bill = recurring_fee
414
+ // + max(0, total_meter_cost − applied_credit_balance)
415
+ // − discounts
416
+ //
417
+ // Stripe applies grants at invoice finalization. Trial enforcement
418
+ // runs through Stripe webhooks → `lifecycle_block` constraint at
419
+ // runtime; there's no `trial_expiry` constraint kind anymore.
420
+ ...billingKnobsShape,
421
+ free: z5.boolean().default(false),
422
+ // `plan.features` removed in feat/feature-plans-link — features
423
+ // declare their plans via `feature.plans[]` (canonical, feature-first
424
+ // direction). Builders writing the manifest now go to the feature
425
+ // they're adding routes to and list which plans grant access. The
426
+ // compiler walks `spec.features` and includes a feature's routes for
427
+ // a plan when the plan's key appears in `feature.plans[]`. See
428
+ // shared-types/src/plans/spec/product.ts (featureCatalogEntrySchema).
429
+ limits: z5.array(planLimitRuleSchema).max(20).default([]),
430
+ featureGates: z5.record(z5.string(), z5.boolean()).optional(),
431
+ /**
432
+ * Control-plane capability limits (Phase 1a, v0.56+).
433
+ *
434
+ * Caps on the count of *control-plane objects* a subscriber can create
435
+ * on a given plan (webhooks, API keys, environments, custom domains,
436
+ * workflows, team-member seats, …) plus boolean feature toggles
437
+ * (enterprise_sso, audit_log, …).
438
+ *
439
+ * Map of `capabilityKey → maxCount | boolean`.
440
+ *
441
+ * Numeric limits are *inclusive* — a limit of 5 means up to 5
442
+ * instances are allowed. Boolean flags express feature enablement
443
+ * (true = enabled; false / missing = disabled).
444
+ *
445
+ * These are NOT edge enforcement concerns; the gateway only carries
446
+ * the projection for client UIs. Enforcement happens at the route
447
+ * handler via `assertCapacity()` in core.
448
+ *
449
+ * The platform doesn't enumerate valid keys — every product /
450
+ * route handler defines its own set. Conventionally lowercase with
451
+ * underscores.
452
+ *
453
+ * Example:
454
+ * capability_limits:
455
+ * webhooks: 5
456
+ * api_keys: 10
457
+ * environments: 1
458
+ * enterprise_sso: true
459
+ */
460
+ capability_limits: z5.record(z5.string().min(1).max(120), z5.union([z5.number().int().nonnegative(), z5.boolean()])).default({}),
461
+ overageBehavior: z5.enum(["block", "allow_and_bill"]).default("block"),
462
+ selfServeEnabled: z5.boolean().default(true),
463
+ /**
464
+ * Phase A0 — multi-stable plan support.
465
+ *
466
+ * `legacy: true` marks a plan version that should be kept alive
467
+ * indefinitely for the existing subscriber cohort, alongside a new
468
+ * ACTIVE plan in the same lineage. Compiles into `CompiledPlan` with
469
+ * status `LEGACY_STABLE`. The plan is hidden from the public Pricing
470
+ * page; new subscribers cannot join. Removing a `legacy: true` plan
471
+ * from YAML while subs are pinned to it is a compile error (would
472
+ * orphan the cohort).
473
+ */
474
+ legacy: z5.boolean().optional().default(false),
475
+ /**
476
+ * Phase A0 — A/B testing variants of this plan. Each variant compiles
477
+ * into a sibling `CompiledPlan` with status `EXPERIMENTAL` and
478
+ * `experimentParentId` pointing at this plan's CompiledPlan.
479
+ *
480
+ * Constraints (enforced at compile time):
481
+ * - Cannot coexist with `legacy: true` on the same plan
482
+ * - Variant `id` must be unique within the variants array
483
+ */
484
+ variants: z5.array(planVariantSchema).max(4).optional(),
485
+ archive: z5.object({
486
+ at: z5.string().datetime().optional(),
487
+ transitionTo: z5.string().optional(),
488
+ strategy: z5.enum(["auto", "explicit", "block"]).default("auto")
489
+ }).optional()
490
+ });
491
+ var planSpecSchema = planSpecObjectSchema.superRefine((plan, ctx) => {
492
+ refineSingleCanonicalGrant(plan.grants, ctx);
493
+ });
494
+
495
+ // ../contracts/dist/plans/spec/webhooks.js
496
+ import { z as z7 } from "zod";
497
+
498
+ // ../contracts/dist/webhooks/events.js
499
+ import { z as z6 } from "zod";
500
+ var WEBHOOK_EVENT_NAMES = [
501
+ "subscription.created",
502
+ "subscription.updated",
503
+ "subscription.canceled",
504
+ "payment.succeeded",
505
+ "payment.failed",
506
+ "rate_limit.exceeded",
507
+ "entitlement.changed",
508
+ "usage.threshold_reached"
509
+ ];
510
+ var webhookEventNameSchema = z6.enum(WEBHOOK_EVENT_NAMES);
511
+
512
+ // ../contracts/dist/plans/spec/webhooks.js
513
+ var WEBHOOK_SECRET_PLACEHOLDER_PATTERN = /^\$\{[A-Z][A-Z0-9_]{0,127}\}$/;
514
+ var webhookSecretSchema = z7.string().min(3).max(200).refine((value) => WEBHOOK_SECRET_PLACEHOLDER_PATTERN.test(value), {
515
+ message: "secret must use ${VAR} interpolation syntax (e.g. ${WEBHOOK_SECRET}); raw secrets in YAML are rejected. Define the env var in the per-product secret store and reference it here."
516
+ });
517
+ var webhookRetryPolicySchema = z7.object({
518
+ maxAttempts: z7.number().int().min(1).max(20).default(5),
519
+ backoff: z7.enum(["exponential", "fixed"]).default("exponential")
520
+ });
521
+ var webhookEndpointSchema = z7.object({
522
+ /**
523
+ * Stable endpoint id — used as the third key of the
524
+ * `(productId, environmentId, id)` uniqueness tuple. Idempotent upsert
525
+ * keys on this id; renaming an id is delete + recreate.
526
+ */
527
+ id: z7.string().min(1).max(64).regex(/^[a-z0-9_-]+$/, "Webhook endpoint id must be lowercase alphanumeric with hyphens/underscores"),
528
+ /** Public HTTPS URL the dispatcher POSTs to. */
529
+ url: z7.string().url("webhooks.endpoints[].url must be a valid URL"),
530
+ /**
531
+ * Signing secret. MUST be a `${VAR}` placeholder; raw secrets in YAML
532
+ * are rejected by invariant 8. The seal pass resolves this against the
533
+ * per-product secret store at compile time and stamps the resolved
534
+ * value onto the WebhookEndpoint row.
535
+ */
536
+ secret: webhookSecretSchema,
537
+ /**
538
+ * Subset of the central event catalog this endpoint subscribes to.
539
+ * Each value is validated against `webhookEventNameSchema` —
540
+ * unknown events fail invariant 9.
541
+ */
542
+ events: z7.array(webhookEventNameSchema).min(1, "webhooks.endpoints[].events must subscribe to \u2265 1 event"),
543
+ enabled: z7.boolean().default(true),
544
+ retryPolicy: webhookRetryPolicySchema.default({
545
+ maxAttempts: 5,
546
+ backoff: "exponential"
547
+ })
548
+ });
549
+ var webhooksBlockSchema = z7.object({
550
+ endpoints: z7.array(webhookEndpointSchema).max(50).default([])
551
+ });
552
+
553
+ // ../contracts/dist/plans/spec/environments.js
554
+ import { z as z8 } from "zod";
555
+ var planOverrideSchema = z8.object({
556
+ meters: z8.array(meterSchema).optional(),
557
+ recurring_fee_cents: z8.number().int().nonnegative().optional(),
558
+ /** Per-env override for the billing cadence. Absent → inherit the
559
+ * base plan's `billing_interval`. */
560
+ billing_interval: billingIntervalSchema.optional(),
561
+ /** Credit override — the unified grants array is the single credit
562
+ * surface. When present it fully replaces the parent plan's grants
563
+ * for this environment (the legacy recurring/one-time scalar knobs
564
+ * were removed). */
565
+ grants: z8.array(grantSchema).max(40).optional(),
566
+ trial_days: z8.number().int().nonnegative().optional(),
567
+ max_monthly_spend_cents: z8.number().int().nonnegative().optional(),
568
+ limits: z8.array(planLimitRuleSchema).max(20).optional(),
569
+ featureGates: z8.record(z8.string(), z8.boolean()).optional(),
570
+ overageBehavior: z8.enum(["block", "allow_and_bill"]).optional(),
571
+ selfServeEnabled: z8.boolean().optional(),
572
+ legacy: z8.boolean().optional()
573
+ }).strict();
574
+ var webhookEndpointOverrideSchema = z8.object({
575
+ url: z8.string().url().optional(),
576
+ secret: webhookSecretSchema.optional(),
577
+ events: z8.array(webhookEventNameSchema).optional(),
578
+ enabled: z8.boolean().optional(),
579
+ retryPolicy: webhookRetryPolicySchema.partial().optional()
580
+ }).strict();
581
+ var environmentOverrideBlockSchema = z8.object({
582
+ plans: z8.record(z8.string(), planOverrideSchema).optional(),
583
+ webhooks: z8.object({
584
+ endpoints: z8.record(z8.string(), webhookEndpointOverrideSchema).optional()
585
+ }).strict().optional()
586
+ }).strict();
587
+ var environmentsBlockSchema = z8.record(z8.string().min(1).max(64), environmentOverrideBlockSchema);
588
+
589
+ // ../contracts/dist/plans/spec/product.js
590
+ import { z as z13 } from "zod";
591
+
592
+ // ../contracts/dist/plans/spec/frontend-layer.js
593
+ import { z as z9 } from "zod";
594
+ var FRONTEND_MANIFEST_SCHEMA_VERSION = 1;
595
+ var KNOWN_FRONTEND_COMPONENT_IDS = [
596
+ "plans_table",
597
+ "usage_card",
598
+ "api_keys_panel",
599
+ "billing_summary",
600
+ "credit_balance",
601
+ "feature_panel",
602
+ "product_docs",
603
+ "audit_log",
604
+ "team_panel",
605
+ "markdown"
606
+ ];
607
+ var RESERVED_TEMPLATE_PATHS = [
608
+ "/",
609
+ "/pricing",
610
+ "/dashboard",
611
+ "/dashboard/*",
612
+ "/settings",
613
+ "/settings/*",
614
+ "/team",
615
+ "/audit-log",
616
+ "/docs",
617
+ "/docs/*",
618
+ "/terms",
619
+ "/privacy",
620
+ "/persona",
621
+ "/persona-sign-in"
622
+ ];
623
+ var frontendPathSchema = z9.string().min(1).max(200).regex(/^\/[a-zA-Z0-9_./-]*$/, "path must start with / and contain only [a-zA-Z0-9_./-]");
624
+ var frontendCapabilityRefSchema = z9.string().min(1).max(120).regex(/^[a-z0-9_-]+$/);
625
+ var frontendNavItemSchema = z9.object({
626
+ label: z9.string().min(1).max(80),
627
+ path: frontendPathSchema,
628
+ capability: frontendCapabilityRefSchema.optional()
629
+ });
630
+ var frontendComponentIdSchema = z9.enum(KNOWN_FRONTEND_COMPONENT_IDS);
631
+ var frontendComponentSchema = z9.object({
632
+ component: frontendComponentIdSchema,
633
+ props: z9.record(z9.string().min(1).max(80), z9.unknown()).optional(),
634
+ capability: frontendCapabilityRefSchema.optional(),
635
+ gateMode: z9.enum(["hide", "disable", "upsell"]).default("hide")
636
+ });
637
+ var frontendPageSchema = z9.object({
638
+ path: frontendPathSchema,
639
+ title: z9.string().min(1).max(120),
640
+ requiresAuth: z9.boolean(),
641
+ capability: frontendCapabilityRefSchema.optional(),
642
+ components: z9.array(frontendComponentSchema).max(12).default([])
643
+ });
644
+ var frontendManifestSchema = z9.object({
645
+ version: z9.literal(FRONTEND_MANIFEST_SCHEMA_VERSION),
646
+ nav: z9.array(frontendNavItemSchema).max(12).default([]),
647
+ pages: z9.array(frontendPageSchema).max(24).default([])
648
+ }).superRefine((manifest, ctx) => {
649
+ const seenPages = /* @__PURE__ */ new Set();
650
+ manifest.pages.forEach((page, index) => {
651
+ if (seenPages.has(page.path)) {
652
+ ctx.addIssue({
653
+ code: "custom",
654
+ message: `duplicate frontend page path "${page.path}"`,
655
+ path: ["pages", index, "path"]
656
+ });
657
+ }
658
+ seenPages.add(page.path);
659
+ if (isReservedTemplatePath(page.path)) {
660
+ ctx.addIssue({
661
+ code: "custom",
662
+ message: `frontend page path "${page.path}" is reserved by the template`,
663
+ path: ["pages", index, "path"]
664
+ });
665
+ }
666
+ });
667
+ });
668
+ function isReservedTemplatePath(path) {
669
+ return RESERVED_TEMPLATE_PATHS.some((reserved) => {
670
+ if (reserved.endsWith("/*")) {
671
+ const prefix = reserved.slice(0, -2);
672
+ return path === prefix || path.startsWith(`${prefix}/`);
673
+ }
674
+ return path === reserved;
675
+ });
676
+ }
677
+
678
+ // ../contracts/dist/plans/spec/migrations-layer.js
679
+ import { z as z10 } from "zod";
680
+ var planKeySchema = z10.string().min(1).max(64).regex(/^[a-z0-9_-]+$/, "Plan key must be lowercase alphanumeric with hyphens/underscores");
681
+ var versionRefSchema = z10.string().min(1).max(100);
682
+ var planVersionRefSchema = z10.object({
683
+ plan: planKeySchema,
684
+ version: versionRefSchema.optional()
685
+ });
686
+ var migrationTargetSchema = z10.object({
687
+ plan: planKeySchema,
688
+ version: z10.literal("head").default("head")
689
+ });
690
+ var pinnedPlanVersionSchema = z10.object({
691
+ plan: planKeySchema,
692
+ version: versionRefSchema
693
+ });
694
+ var migrationEffectiveSchema = z10.enum([
695
+ "grandfather",
696
+ "next_renewal",
697
+ "by_date",
698
+ "immediate",
699
+ "opt_in"
700
+ ]);
701
+ var migrationProrationSchema = z10.enum(["none", "prorate", "credit"]);
702
+ var migrationExistingCustomersSchema = z10.object({
703
+ effective: migrationEffectiveSchema,
704
+ date: z10.string().datetime().optional(),
705
+ proration: migrationProrationSchema.optional()
706
+ }).superRefine((policy, ctx) => {
707
+ if (policy.effective === "by_date" && policy.date === void 0) {
708
+ ctx.addIssue({
709
+ code: "custom",
710
+ path: ["date"],
711
+ message: "existingCustomers.date is required when effective is by_date"
712
+ });
713
+ }
714
+ });
715
+ var migrationPinSchema = z10.object({
716
+ subscriber: z10.string().min(1).max(200),
717
+ pinTo: pinnedPlanVersionSchema,
718
+ until: z10.string().datetime().optional(),
719
+ notes: z10.string().max(1e3).optional()
720
+ });
721
+ var migrationDeclSchema = z10.object({
722
+ id: z10.string().min(1).max(120).regex(/^[a-z0-9][a-z0-9_.-]*$/, "Migration id must be lowercase alphanumeric with dots, hyphens, or underscores"),
723
+ from: planVersionRefSchema,
724
+ to: migrationTargetSchema,
725
+ newCustomers: z10.literal("immediate").default("immediate"),
726
+ existingCustomers: migrationExistingCustomersSchema,
727
+ pins: z10.array(migrationPinSchema).max(50).default([])
728
+ });
729
+ var migrationDeclsSchema = z10.array(migrationDeclSchema).superRefine((migrations, ctx) => {
730
+ const seen = /* @__PURE__ */ new Set();
731
+ migrations.forEach((migration, index) => {
732
+ if (seen.has(migration.id)) {
733
+ ctx.addIssue({
734
+ code: "custom",
735
+ path: [index, "id"],
736
+ message: `duplicate migration id "${migration.id}"`
737
+ });
738
+ }
739
+ seen.add(migration.id);
740
+ });
741
+ });
742
+
743
+ // ../contracts/dist/plans/spec/counted-resources.js
744
+ import { z as z11 } from "zod";
745
+ var countedResourceScopeSchema = z11.enum(["subscription", "subject"]);
746
+ var countedResourceCountSourceSchema = z11.enum([
747
+ "reported",
748
+ "action_inferred"
749
+ ]);
750
+ var countedResourceNameSchema = z11.string().min(1).max(100).regex(/^[a-z0-9_.:-]+$/);
751
+ var countedResourceSchema = z11.object({
752
+ name: countedResourceNameSchema,
753
+ display: z11.string().min(1).max(120).optional(),
754
+ scope: countedResourceScopeSchema.default("subscription"),
755
+ subjectType: z11.string().min(1).max(64).regex(/^[a-zA-Z0-9_.:-]+$/).optional(),
756
+ countSource: countedResourceCountSourceSchema.default("reported")
757
+ }).superRefine((resource, ctx) => {
758
+ if (resource.scope === "subject" && !resource.subjectType) {
759
+ ctx.addIssue({
760
+ code: "custom",
761
+ path: ["subjectType"],
762
+ message: "subjectType is required when resource scope is subject"
763
+ });
764
+ }
765
+ if (resource.scope === "subscription" && resource.subjectType) {
766
+ ctx.addIssue({
767
+ code: "custom",
768
+ path: ["subjectType"],
769
+ message: "subjectType is only valid for subject-scoped counted resources"
770
+ });
771
+ }
772
+ });
773
+ var countedResourcesSchema = z11.array(countedResourceSchema).max(100).default([]);
774
+
775
+ // ../contracts/dist/plans/addons.js
776
+ import { z as z12 } from "zod";
777
+ var addOnSchema = z12.object({
778
+ /** Stable identifier — appears in `SubscriptionAddOn.addOnKey` and in
779
+ * Stripe metadata. Lowercase alphanumeric with hyphens/underscores
780
+ * to match the plan-key grammar. */
781
+ key: z12.string().min(1).max(64).regex(/^[a-z0-9_-]+$/, "AddOn key must be lowercase alphanumeric with hyphens/underscores"),
782
+ /** Display name (Pricing page chip, settings list, etc.). */
783
+ name: z12.string().min(1).max(100),
784
+ /** Short description for tooltips / chooser. */
785
+ description: z12.string().max(500).optional(),
786
+ /** Recurring fee added to the subscription invoice while the AddOn is
787
+ * active. 0 = no recurring fee (e.g. for boolean feature toggles or
788
+ * one-time-grant-only packs). */
789
+ recurring_fee_cents: z12.number().int().nonnegative().default(0),
790
+ /** Billing cadence for this add-on's recurring fee (`month` default /
791
+ * `year`). Drives the add-on's Stripe price `recurring.interval`. */
792
+ billing_interval: billingIntervalSchema.default("month"),
793
+ /** Additional metered dimensions/rates enabled by this AddOn. Merged
794
+ * into the effective entitlement's meter list (by dimension).
795
+ * Conflict resolution at compile time: AddOn meter wins over base
796
+ * plan meter for the same dimension (PR 2a-2 implements this). */
797
+ meters: z12.array(meterSchema).default([]),
798
+ /** Grants issued when the AddOn is activated and/or on each period
799
+ * rollover (kind-dependent). Same vocabulary as plan grants. */
800
+ grants: z12.array(grantSchema).max(40).default([]),
801
+ /** Capability bumps. Numeric limits combine with the base via MAX
802
+ * (so an "extra seats" addon raises the ceiling without lowering
803
+ * it). Boolean flags combine with OR (any true wins). */
804
+ capability_limits: z12.record(z12.string().min(1).max(120), z12.union([z12.number().int().nonnegative(), z12.boolean()])).default({}),
805
+ /** Additive feature gates. Keys true here are appended to the
806
+ * effective entitlement's gate set. */
807
+ featureGates: z12.record(z12.string(), z12.boolean()).optional(),
808
+ /** Whether the AddOn is visible in the subscriber-facing catalog.
809
+ * When false, only admin-issued (not self-serve). Defaults true. */
810
+ selfServeEnabled: z12.boolean().default(true)
811
+ });
812
+ var addOnsBlockSchema = z12.record(z12.string().min(1).max(64), addOnSchema).default({});
813
+ var subscriptionAddOnStatusSchema = z12.enum([
814
+ "active",
815
+ "canceled",
816
+ "paused",
817
+ "pending_activation"
818
+ ]);
819
+ var subscriptionAddOnSchema = z12.object({
820
+ /** References `product.add_ons.<addOnKey>`. */
821
+ addOnKey: z12.string().min(1).max(64),
822
+ /** Status drives entitlement composition: only `active` AddOns
823
+ * contribute to the effective entitlement. */
824
+ status: subscriptionAddOnStatusSchema.default("active"),
825
+ /** When the AddOn became active (ISO-8601). Drives one-time grant
826
+ * issuance and recurring-fee start. */
827
+ activatedAt: z12.string().datetime().optional(),
828
+ /** When the AddOn was canceled (ISO-8601). Null while active. */
829
+ canceledAt: z12.string().datetime().nullable().optional(),
830
+ /** Stripe subscription-item id for the recurring-fee line, when
831
+ * the AddOn declares a non-zero `recurring_fee_cents`. */
832
+ stripeSubscriptionItemId: z12.string().optional()
833
+ });
834
+
835
+ // ../contracts/dist/plans/spec/refinements.js
836
+ function rejectUsagePricing(spec, ctx) {
837
+ if (spec.usagePricing === void 0)
838
+ return;
839
+ ctx.addIssue({
840
+ code: "custom",
841
+ path: ["usagePricing"],
842
+ message: "usagePricing is not supported. Define usage.meters.<key>.rating instead."
843
+ });
844
+ }
845
+ function validateFreePlans(plans, ctx) {
846
+ const freePlans = plans.filter((plan) => plan.free);
847
+ if (freePlans.length > 1) {
848
+ ctx.addIssue({
849
+ code: "custom",
850
+ path: ["plans"],
851
+ message: "Only one free plan is allowed per product"
852
+ });
853
+ }
854
+ plans.forEach((plan, index) => {
855
+ if (!plan.free)
856
+ return;
857
+ validateFreePlanPrice(plan, index, ctx);
858
+ validateFreePlanHardLimit(plan, index, ctx);
859
+ });
860
+ }
861
+ function validateFreePlanPrice(plan, index, ctx) {
862
+ const monthly = planMonthlyPrice(plan);
863
+ if ((monthly ?? 0) === 0)
864
+ return;
865
+ ctx.addIssue({
866
+ code: "custom",
867
+ path: ["plans", index, "recurring_fee_cents"],
868
+ message: "Free plans must have zero price"
869
+ });
870
+ }
871
+ function validateFreePlanHardLimit(plan, index, ctx) {
872
+ const hasHardLimit = plan.limits.some((limit) => !limit.enforcement || limit.enforcement === "enforce");
873
+ if (hasHardLimit)
874
+ return;
875
+ ctx.addIssue({
876
+ code: "custom",
877
+ path: ["plans", index, "limits"],
878
+ message: "Free plans must include at least one hard enforced limit"
879
+ });
880
+ }
881
+ function validatePaidPlanPrices(plans, ctx) {
882
+ const priceKeys = /* @__PURE__ */ new Map();
883
+ plans.forEach((plan, index) => {
884
+ if (plan.free)
885
+ return;
886
+ const monthly = planMonthlyPrice(plan);
887
+ if (monthly === void 0 || monthly <= 0)
888
+ return;
889
+ const key = planPriceKey(plan, monthly);
890
+ const existing = priceKeys.get(key);
891
+ if (existing) {
892
+ ctx.addIssue({
893
+ code: "custom",
894
+ path: ["plans", index, "recurring_fee_cents"],
895
+ message: `Paid plan price duplicates plan "${existing}" (${key})`
896
+ });
897
+ return;
898
+ }
899
+ priceKeys.set(key, plan.key);
900
+ });
901
+ }
902
+ function validateMeterReferences(spec, ctx) {
903
+ const runtimeMeterKeys = new Set((spec.metering?.meters ?? []).map((m) => m.key).filter((k) => typeof k === "string" && k.length > 0));
904
+ const usageMeterKeys = new Set(Object.keys(spec.usage?.meters ?? {}));
905
+ spec.plans.forEach((plan, index) => {
906
+ const meters = plan.meters;
907
+ if (!meters)
908
+ return;
909
+ meters.forEach((meter, meterIdx) => {
910
+ const dim = meter.dimension;
911
+ if (typeof dim !== "string" || dim.length === 0)
912
+ return;
913
+ if (runtimeMeterKeys.has(dim))
914
+ return;
915
+ if (usageMeterKeys.has(dim))
916
+ return;
917
+ ctx.addIssue({
918
+ code: "custom",
919
+ path: ["plans", index, "meters", meterIdx, "dimension"],
920
+ message: `Plan "${plan.key}" prices meter "${dim}" but no metering.meters[] entry and no usage.meters[] entry declares it.`
921
+ });
922
+ });
923
+ });
924
+ }
925
+ function validateFeatureReferences(spec, ctx) {
926
+ if (!spec.features)
927
+ return;
928
+ const planKeys = new Set(spec.plans.map((p) => p.key));
929
+ for (const [featureKey, feature] of Object.entries(spec.features)) {
930
+ if (!feature.plans)
931
+ continue;
932
+ feature.plans.forEach((planKey, idx) => {
933
+ if (planKeys.has(planKey))
934
+ return;
935
+ ctx.addIssue({
936
+ code: "custom",
937
+ path: ["features", featureKey, "plans", idx],
938
+ message: `Feature "${featureKey}" references unknown plan "${planKey}"`
939
+ });
940
+ });
941
+ const seen = /* @__PURE__ */ new Set();
942
+ feature.plans.forEach((planKey, idx) => {
943
+ if (seen.has(planKey)) {
944
+ ctx.addIssue({
945
+ code: "custom",
946
+ path: ["features", featureKey, "plans", idx],
947
+ message: `Feature "${featureKey}" lists plan "${planKey}" more than once`
948
+ });
949
+ }
950
+ seen.add(planKey);
951
+ });
952
+ }
953
+ }
954
+ function validateRouteMeters(spec, ctx) {
955
+ if (!spec.features)
956
+ return;
957
+ const meterKeys = new Set((spec.metering?.meters ?? []).map((m) => m.key).filter((k) => typeof k === "string" && k.length > 0));
958
+ let anyRouteDeclaresMeters = false;
959
+ for (const [featureKey, feature] of Object.entries(spec.features)) {
960
+ const routes = feature.routes ?? [];
961
+ routes.forEach((route, routeIdx) => {
962
+ if (!route.meters || route.meters.length === 0)
963
+ return;
964
+ anyRouteDeclaresMeters = true;
965
+ route.meters.forEach((meter, meterIdx) => {
966
+ if (meterKeys.has(meter))
967
+ return;
968
+ ctx.addIssue({
969
+ code: "custom",
970
+ path: [
971
+ "features",
972
+ featureKey,
973
+ "routes",
974
+ routeIdx,
975
+ "meters",
976
+ meterIdx
977
+ ],
978
+ message: `Route references unknown meter "${meter}". Declare it in metering.meters[].`
979
+ });
980
+ });
981
+ });
982
+ }
983
+ if (anyRouteDeclaresMeters && meterKeys.size === 0) {
984
+ ctx.addIssue({
985
+ code: "custom",
986
+ path: ["metering", "meters"],
987
+ message: "One or more routes declare `meters` but `metering.meters[]` is empty. Declare meters at the product level first."
988
+ });
989
+ }
990
+ }
991
+ function buildReachableMetersByPlan(spec, runtimeMeters) {
992
+ const out = /* @__PURE__ */ new Map();
993
+ for (const plan of spec.plans) {
994
+ out.set(plan.key, /* @__PURE__ */ new Set());
995
+ }
996
+ if (!spec.features) {
997
+ for (const set of out.values()) {
998
+ for (const m of runtimeMeters)
999
+ set.add(m);
1000
+ }
1001
+ return out;
1002
+ }
1003
+ for (const feature of Object.values(spec.features)) {
1004
+ addFeatureMetersToReachable(feature, runtimeMeters, out);
1005
+ }
1006
+ return out;
1007
+ }
1008
+ function addFeatureMetersToReachable(feature, runtimeMeters, reachableByPlan) {
1009
+ const grantedPlanKeys = feature.plans ?? [];
1010
+ const routes = feature.routes ?? [];
1011
+ for (const planKey of grantedPlanKeys) {
1012
+ const reachable = reachableByPlan.get(planKey);
1013
+ if (!reachable)
1014
+ continue;
1015
+ for (const route of routes) {
1016
+ if (route.unmetered === true)
1017
+ continue;
1018
+ if (route.meters && route.meters.length > 0) {
1019
+ for (const meter of route.meters)
1020
+ reachable.add(meter);
1021
+ continue;
1022
+ }
1023
+ for (const m of runtimeMeters)
1024
+ reachable.add(m);
1025
+ }
1026
+ }
1027
+ }
1028
+ function extractLimitMeterKey(limit) {
1029
+ if (!limit || typeof limit !== "object")
1030
+ return null;
1031
+ const candidate = limit;
1032
+ if (typeof candidate.dimension === "string")
1033
+ return candidate.dimension;
1034
+ return null;
1035
+ }
1036
+ var SPECIAL_REACHABLE_DIMENSIONS = /* @__PURE__ */ new Set(["credits"]);
1037
+ function validateLimitMeterReachability(spec, ctx) {
1038
+ if (!spec.plans || spec.plans.length === 0)
1039
+ return;
1040
+ const runtimeMeters = new Set((spec.metering?.meters ?? []).map((m) => m.key).filter((k) => typeof k === "string" && k.length > 0));
1041
+ const usageMeters = new Set(Object.keys(spec.usage?.meters ?? {}));
1042
+ const reachableByPlan = buildReachableMetersByPlan(spec, runtimeMeters);
1043
+ spec.plans.forEach((plan, planIdx) => {
1044
+ const limits = plan.limits ?? [];
1045
+ const reachable = reachableByPlan.get(plan.key) ?? /* @__PURE__ */ new Set();
1046
+ limits.forEach((limit, limitIdx) => {
1047
+ const ack = limit && typeof limit === "object" ? limit.acknowledgeUnreachable === true : false;
1048
+ if (ack)
1049
+ return;
1050
+ const meterKey = extractLimitMeterKey(limit);
1051
+ if (!meterKey)
1052
+ return;
1053
+ if (reachable.has(meterKey))
1054
+ return;
1055
+ if (usageMeters.has(meterKey))
1056
+ return;
1057
+ if (SPECIAL_REACHABLE_DIMENSIONS.has(meterKey))
1058
+ return;
1059
+ ctx.addIssue({
1060
+ code: "custom",
1061
+ path: ["plans", planIdx, "limits", limitIdx, "meter"],
1062
+ message: `Plan "${plan.key}" limits meter "${meterKey}" but no route in any granted feature emits this meter, and the meter is not declared under usage.meters either. Either grant a feature whose routes meter "${meterKey}" to this plan, declare the meter under metering.meters[] or usage.meters, or set acknowledgeUnreachable: true if the limit is intentional (e.g. soft budget).`
1063
+ });
1064
+ });
1065
+ });
1066
+ }
1067
+ function planMonthlyPrice(plan) {
1068
+ return plan.recurring_fee_cents;
1069
+ }
1070
+ function planPriceKey(plan, monthly) {
1071
+ void plan;
1072
+ return `USD:month:${monthly}`;
1073
+ }
1074
+
1075
+ // ../contracts/dist/plans/spec/product.js
1076
+ var productIdentitySchema = z13.object({
1077
+ subdomain: z13.string().min(1).max(63).regex(/^[a-z0-9]([a-z0-9-]*[a-z0-9])?$/, "Subdomain must be lowercase alphanumeric with optional hyphens")
1078
+ });
1079
+ var meterEnforcementTypeSchema = z13.enum([
1080
+ "exact_pre_request",
1081
+ "estimated_then_settled",
1082
+ "postpaid",
1083
+ "strict_concurrency"
1084
+ ]);
1085
+ var meterDefinitionSchema = z13.object({
1086
+ key: z13.string().min(1).max(64).regex(/^[a-z0-9_]+$/, "Meter key must be lowercase alphanumeric with underscores"),
1087
+ display: z13.string().min(1).max(100),
1088
+ // v0.42.0 — `type: "built-in" | "custom"` removed. The runtime never
1089
+ // read it (gateway estimators key on meter NAME, Stripe meter
1090
+ // creation keys on meter KEY); it was schema documentation. Wizard
1091
+ // pre-fills sensible meters per template (api_calls → `requests`;
1092
+ // ai_usage → `dollars`); Custom template lets builders define keys
1093
+ // freely. Old specs with `type: ...` parse cleanly because Zod
1094
+ // strips unknown fields by default.
1095
+ unit: z13.string().max(20).optional(),
1096
+ /**
1097
+ * Runtime enforcement semantics for this meter. This is compiled into
1098
+ * signed gateway artifacts so the edge chooses reservation, settlement,
1099
+ * postpaid, or strict permit behavior from declarative config rather than from
1100
+ * mutable gateway-side defaults.
1101
+ */
1102
+ enforcementType: meterEnforcementTypeSchema.default("estimated_then_settled"),
1103
+ // ---------------------------------------------------------------------
1104
+ // 0.29.0 — meter aggregation contract (FAR-394 / Phase 1a).
1105
+ //
1106
+ // Mirrors Lago's BillableMetric so a meter defined here can be
1107
+ // pushed to Lago without a translation layer. `aggregation` and
1108
+ // `window` carry safe defaults so existing CompiledPlan rows in
1109
+ // core's DB (which carry historical specs) continue to parse.
1110
+ // The property fields are optional at the schema level; core's
1111
+ // validate-pass (Phase 1b) enforces the conditional requirements
1112
+ // (`valueProperty` required for SUM/MAX, `uniqueProperty`
1113
+ // required for UNIQUE_COUNT) where it has the cross-field context.
1114
+ // ---------------------------------------------------------------------
1115
+ /**
1116
+ * How to aggregate measured values into a usage figure for the
1117
+ * current `window`. Defaults to `COUNT` (one event = one unit) so
1118
+ * existing meters that didn't declare aggregation continue to work.
1119
+ */
1120
+ aggregation: z13.enum(["SUM", "COUNT", "MAX", "UNIQUE_COUNT", "LATEST"]).default("COUNT"),
1121
+ /**
1122
+ * Aggregation window. `billing_period` (the default) makes the
1123
+ * meter accumulate across the subscription's billing period.
1124
+ * Sub-period windows (`minute`/`hour`/`day`/`month`) are for
1125
+ * rate-limit-shaped meters where the period boundary is a fixed
1126
+ * wall-clock interval, not the subscription anniversary.
1127
+ */
1128
+ window: z13.enum(["minute", "hour", "day", "month", "billing_period"]).default("billing_period"),
1129
+ /**
1130
+ * Property on the event payload to read for `SUM` and `MAX`
1131
+ * aggregations. Optional at the schema level; core's `validate.ts`
1132
+ * (Phase 1b) enforces "required when aggregation is SUM or MAX".
1133
+ */
1134
+ valueProperty: z13.string().optional(),
1135
+ /**
1136
+ * Property on the event payload to dedupe on for `UNIQUE_COUNT`
1137
+ * aggregation. Optional at the schema level; core's validate-pass
1138
+ * enforces "required when aggregation is UNIQUE_COUNT".
1139
+ */
1140
+ uniqueProperty: z13.string().optional(),
1141
+ /**
1142
+ * Optional grouping dimensions. When set, the aggregation is per
1143
+ * unique combination of these properties' values, not a single
1144
+ * scalar. Used for per-region or per-model breakdowns.
1145
+ */
1146
+ groupBy: z13.array(z13.string()).optional(),
1147
+ /**
1148
+ * Lago-side event code for ingress correlation. Matches Lago's
1149
+ * BillableMetric `code` so events sent to Lago land on the right
1150
+ * meter without a per-meter translation table.
1151
+ */
1152
+ eventCode: z13.string().optional()
1153
+ });
1154
+ var usageMeasureSchema = z13.string().min(1).max(64).regex(/^[a-z0-9_]+$/, "Usage measure must be lowercase alphanumeric with underscores");
1155
+ var usageRatingPricePolicySchema = z13.enum([
1156
+ "pass_through",
1157
+ "markup",
1158
+ "fixed_margin",
1159
+ "customer_rate"
1160
+ ]);
1161
+ var fixedRatingSchema = z13.object({
1162
+ source: z13.literal("fixed"),
1163
+ rates: z13.record(z13.string().min(1), z13.record(usageMeasureSchema, z13.number().int().nonnegative()))
1164
+ });
1165
+ var providerCatalogRatingSchema = z13.object({
1166
+ source: z13.literal("provider_catalog"),
1167
+ catalog: z13.string().min(1).max(100),
1168
+ pricePolicy: usageRatingPricePolicySchema.default("pass_through"),
1169
+ markupPercent: z13.number().nonnegative().max(1e4).optional(),
1170
+ marginMicros: z13.number().int().nonnegative().optional()
1171
+ });
1172
+ var upstreamReportedRatingSchema = z13.object({
1173
+ source: z13.literal("upstream_reported"),
1174
+ amountField: z13.string().min(1).max(500),
1175
+ currencyField: z13.string().min(1).max(500).optional()
1176
+ });
1177
+ var externalRateApiRatingSchema = z13.object({
1178
+ source: z13.literal("external_rate_api"),
1179
+ resolver: z13.string().min(1).max(100),
1180
+ configRef: z13.string().min(1).max(200).optional()
1181
+ });
1182
+ var customRatingSchema = z13.object({
1183
+ source: z13.literal("custom"),
1184
+ resolver: z13.string().min(1).max(100),
1185
+ configRef: z13.string().min(1).max(200).optional()
1186
+ });
1187
+ var usageRatingSchema = z13.discriminatedUnion("source", [
1188
+ fixedRatingSchema,
1189
+ providerCatalogRatingSchema,
1190
+ upstreamReportedRatingSchema,
1191
+ externalRateApiRatingSchema,
1192
+ customRatingSchema
1193
+ ]);
1194
+ var usageMeterSchema = z13.object({
1195
+ selector: z13.string().min(1).max(100).optional(),
1196
+ measures: z13.array(usageMeasureSchema).min(1).max(20),
1197
+ rating: usageRatingSchema.optional()
1198
+ });
1199
+ var usageBlockSchema = z13.object({
1200
+ meters: z13.record(z13.string().min(1).max(64).regex(/^[a-z0-9_]+$/), usageMeterSchema)
1201
+ });
1202
+ var featureRouteSchema = z13.object({
1203
+ method: z13.enum(["GET", "POST", "PUT", "PATCH", "DELETE", "HEAD", "OPTIONS", "*"]).default("*"),
1204
+ // Path is the route under the product's baseUrl. OpenAPI parameter
1205
+ // syntax is supported and translated by the compiler:
1206
+ // /users/:id → /users/*
1207
+ // /users/{id} → /users/*
1208
+ // Path-globs `*` (one segment) and `**` (any subpath) are passed
1209
+ // through. The compiler rejects ambiguous compound segments like
1210
+ // `/foo/:a-:b` — parameter names must occupy whole segments.
1211
+ path: z13.string().min(1).max(500).regex(/^\/[a-zA-Z0-9_/:.{}*-]*$/, "path must start with / and contain only [a-zA-Z0-9_/:.{}*-]"),
1212
+ // Optional per-route meter binding (Option B, v0.41.0). When set,
1213
+ // the gateway only emits usage events for these meters on requests
1214
+ // matching this route — `requests` and token meters get scoped
1215
+ // independently per route. Resolution rules:
1216
+ // - omitted → route inherits "all configured product meters"
1217
+ // (back-compat with manifests written before v0.41)
1218
+ // - non-empty → only these meters increment
1219
+ // - [] → REJECTED at parse (`ROUTE_METERS_EMPTY_ARRAY`).
1220
+ // Use `unmetered: true` for the explicit opt-out.
1221
+ // - null → treated as omitted (PATCH-clear UX)
1222
+ //
1223
+ // Each entry must resolve to a key in `metering.meters[].key`;
1224
+ // otherwise the cross-validation refinement
1225
+ // `validateRouteMeters` rejects with `UNKNOWN_METER_IN_ROUTE`.
1226
+ meters: z13.array(z13.string().min(1).max(64)).min(1, "Use `unmetered: true` instead of an empty array (typo guard against silently-unmetered routes)").max(20).nullable().optional(),
1227
+ // Explicit unmetered route. Mutually exclusive with `meters` (the
1228
+ // `superRefine` below catches any builder that sets both).
1229
+ // Compiles to `entitlement.fr[i].meters: []` on the wire side so
1230
+ // the gateway short-circuits both DO consume and event publish.
1231
+ unmetered: z13.boolean().optional()
1232
+ }).superRefine((route, ctx) => {
1233
+ if (route.unmetered === true && route.meters && route.meters.length > 0) {
1234
+ ctx.addIssue({
1235
+ code: "custom",
1236
+ message: "`unmetered: true` is mutually exclusive with `meters` \u2014 drop one",
1237
+ path: ["unmetered"]
1238
+ });
1239
+ }
1240
+ });
1241
+ var featureCatalogEntrySchema = z13.object({
1242
+ // Optional human-friendly summary; surfaced in dashboards / settings UI.
1243
+ description: z13.string().max(500).optional(),
1244
+ routes: z13.array(featureRouteSchema).min(1).max(50),
1245
+ // Plans that grant this feature. Feature-first canonical mapping —
1246
+ // builders declare "which plans get this feature" on the feature
1247
+ // itself rather than enumerating features per plan. Required and
1248
+ // non-empty: a feature with no plans grants nothing and is a likely
1249
+ // typo. Cross-reference validation (every key resolves to an
1250
+ // existing plan) lives in `validateFeatureReferences` below.
1251
+ plans: z13.array(z13.string().min(1)).min(1).max(20)
1252
+ });
1253
+ var featureCatalogSchema = z13.record(z13.string().min(1).max(100).regex(/^[a-z0-9_.:-]+$/), featureCatalogEntrySchema);
1254
+ var productSpecSchema = z13.object({
1255
+ product: z13.object({
1256
+ name: z13.string().min(1).max(100),
1257
+ displayName: z13.string().max(200).optional(),
1258
+ description: z13.string().max(2e3).optional(),
1259
+ baseUrl: z13.string().url("baseUrl must be a valid URL"),
1260
+ sandboxBaseUrl: z13.string().url("sandboxBaseUrl must be a valid URL").optional(),
1261
+ visibility: z13.enum(["public", "private"]).default("public"),
1262
+ // Branding
1263
+ logoUrl: z13.string().url().optional(),
1264
+ primaryColor: z13.string().regex(/^#[0-9a-fA-F]{6}$/).optional(),
1265
+ // Environment
1266
+ envBranchPrefix: z13.string().max(50).nullable().optional()
1267
+ }),
1268
+ gateway: z13.object({
1269
+ authHeader: z13.string().min(1).max(100).default("x-api-key"),
1270
+ upstreamAuth: z13.object({
1271
+ type: z13.enum(["none", "static_bearer"]),
1272
+ token: z13.string().optional()
1273
+ }).default({ type: "none" })
1274
+ }),
1275
+ metering: z13.object({
1276
+ meters: z13.array(meterDefinitionSchema).max(10).default([]),
1277
+ billOn4xx: z13.boolean().default(false)
1278
+ }).default({ meters: [], billOn4xx: false }),
1279
+ usage: usageBlockSchema.optional(),
1280
+ usagePricing: z13.never({
1281
+ error: "usagePricing is not supported. Define usage.meters.<key>.rating instead."
1282
+ }).optional(),
1283
+ features: featureCatalogSchema.optional(),
1284
+ resources: countedResourcesSchema,
1285
+ /**
1286
+ * Track B4 — Declarative frontend surface. Product code can declare
1287
+ * template-owned nav/pages composed from known portal components. The
1288
+ * template renders these for non-reserved paths; contractual dashboard
1289
+ * sections remain template-owned.
1290
+ */
1291
+ frontend: frontendManifestSchema.optional(),
1292
+ migrations: migrationDeclsSchema.optional(),
1293
+ // v0.53.0 — `billing.strategy` removed. Plans now declare their own
1294
+ // billing shape via the unified 5-knob spec (`meters`,
1295
+ // `recurring_fee_cents`, `recurring_credit_grant_cents`,
1296
+ // `one_time_credit_grant_cents`, `trial_days`,
1297
+ // `max_monthly_spend_cents`). The product-level `billing` block
1298
+ // retains the transition-policy fields (`gracePeriodDays`,
1299
+ // `subscriberChangePolicy`); the strategy enum is gone.
1300
+ billing: z13.object({
1301
+ gracePeriodDays: z13.number().int().nonnegative().default(3),
1302
+ // When true (default), a plan limit INCREASE re-projects onto active
1303
+ // subscribers immediately; a DECREASE always defers to period end.
1304
+ // Read by migrateActiveSubscribersForRuntimeOnlyChange.
1305
+ applyLimitUpgradesInstantly: z13.boolean().optional(),
1306
+ subscriberChangePolicy: subscriberChangePolicySchema.default({
1307
+ default: "preserve_current_period",
1308
+ proration: "none",
1309
+ when: {
1310
+ price_increase: "preserve_current_period",
1311
+ price_decrease: "switch_immediately",
1312
+ feature_added: "switch_immediately",
1313
+ feature_removed: "preserve_current_period",
1314
+ limit_increased: "switch_immediately",
1315
+ limit_reduced: "preserve_current_period",
1316
+ credit_increased: "switch_immediately",
1317
+ credit_reduced: "preserve_current_period",
1318
+ rating_changed: "preserve_current_period"
1319
+ },
1320
+ allowImmediatePriceIncrease: false,
1321
+ allowImmediateEntitlementReduction: false
1322
+ })
1323
+ }).default({
1324
+ gracePeriodDays: 3,
1325
+ subscriberChangePolicy: {
1326
+ default: "preserve_current_period",
1327
+ proration: "none",
1328
+ when: {
1329
+ price_increase: "preserve_current_period",
1330
+ price_decrease: "switch_immediately",
1331
+ feature_added: "switch_immediately",
1332
+ feature_removed: "preserve_current_period",
1333
+ limit_increased: "switch_immediately",
1334
+ limit_reduced: "preserve_current_period",
1335
+ credit_increased: "switch_immediately",
1336
+ credit_reduced: "preserve_current_period",
1337
+ rating_changed: "preserve_current_period"
1338
+ },
1339
+ allowImmediatePriceIncrease: false,
1340
+ allowImmediateEntitlementReduction: false
1341
+ }
1342
+ }),
1343
+ plans: z13.array(planSpecSchema).max(4).default([]),
1344
+ /**
1345
+ * Add-on catalog (v0.56+). Composable economic + entitlement
1346
+ * overlays that subscribers can pile on top of their base plan.
1347
+ * Map keyed by the AddOn key.
1348
+ *
1349
+ * Compositor (core PR 2a-2) merges (base_plan, [active addons])
1350
+ * → effective entitlement at compile time. Capability limits
1351
+ * combine via MAX (additive); meters merge by dimension;
1352
+ * grants append.
1353
+ *
1354
+ * Example:
1355
+ * add_ons:
1356
+ * extra_tokens_50k:
1357
+ * name: Extra 50k tokens
1358
+ * recurring_fee_cents: 2000
1359
+ * grants:
1360
+ * - kind: recurring
1361
+ * amount_cents: 2000
1362
+ * enterprise_sso:
1363
+ * name: Enterprise SSO
1364
+ * featureGates:
1365
+ * sso: true
1366
+ */
1367
+ add_ons: addOnsBlockSchema,
1368
+ /**
1369
+ * Phase 3b — Lifecycle policy. Currently carries breaking-change
1370
+ * governance: the deprecation window and successor-route
1371
+ * requirements that the publish gate enforces when YAML diff
1372
+ * removes a route or otherwise breaks compatibility.
1373
+ *
1374
+ * Absent or empty → no calendar enforcement (today's behavior).
1375
+ * When set, core's `publish-validators` walks the diff and
1376
+ * refuses changes that violate the policy (e.g. a route removal
1377
+ * before the deprecation window has elapsed, or without a
1378
+ * declared successor).
1379
+ *
1380
+ * Example:
1381
+ * lifecycle:
1382
+ * breaking_changes:
1383
+ * require_deprecation_window_days: 90
1384
+ * require_successor_route: true
1385
+ */
1386
+ lifecycle: z13.object({
1387
+ breaking_changes: z13.object({
1388
+ /** Minimum days a route must have been marked for removal
1389
+ * (in main-branch YAML) before the publish gate will let
1390
+ * it actually be removed. Set to 0 to disable. */
1391
+ require_deprecation_window_days: z13.number().int().nonnegative().default(0),
1392
+ /** When true, a route removal must declare a successor
1393
+ * route via the lifecycle metadata (mechanics in core
1394
+ * 3b-2) before the publish gate accepts it. */
1395
+ require_successor_route: z13.boolean().default(false)
1396
+ }).default({
1397
+ require_deprecation_window_days: 0,
1398
+ require_successor_route: false
1399
+ })
1400
+ }).default({
1401
+ breaking_changes: {
1402
+ require_deprecation_window_days: 0,
1403
+ require_successor_route: false
1404
+ }
1405
+ }),
1406
+ /**
1407
+ * Phase B0 — Platform-as-Backend mode. Optional top-level webhook
1408
+ * subscription block. Compiles into `WebhookEndpoint` rows via the
1409
+ * `emit-webhooks` pass (idempotent upsert, soft-delete on absence).
1410
+ *
1411
+ * Once a product has compiled with a `webhooks` block, API mutations
1412
+ * on those endpoints fail with `409 MANAGED_BY_CODE` — see
1413
+ * `core/src/routes/management-webhooks.ts`. Tie-breaker: product.config.ts
1414
+ * wins over API state if an endpoint id appears in both.
1415
+ */
1416
+ webhooks: webhooksBlockSchema.optional(),
1417
+ /**
1418
+ * Phase B0 — Per-environment overrides. Deep-merges onto the base
1419
+ * spec at compile time, scoped by environment id (resolved from the
1420
+ * pushing branch via `branch-environment-resolver.ts`).
1421
+ *
1422
+ * Out-of-scope: overriding `customerAuthStrategy` is not allowed —
1423
+ * that field stays provisioner-controlled.
1424
+ */
1425
+ environments: environmentsBlockSchema.optional(),
1426
+ /**
1427
+ * Ephemeral-environment behaviour. Production never reads from
1428
+ * here; the fields are only meaningful for env-branch compiles
1429
+ * (env/* branches, validation bot, agent-CI flows). See
1430
+ * `feature/env-flash-model` plan + `docs/yaml-edits.md`.
1431
+ *
1432
+ * `defaultPlan` (OPTIONAL): when set, the bootstrap-key endpoint
1433
+ * (`POST /portal/products/.../test/bootstrap-key`) and the
1434
+ * internal persona-issue endpoint (`POST /internal/personas/
1435
+ * issue`) fall back to this planKey if the caller omits one.
1436
+ * Lets agents in env-CI bootstrap a persona with zero plan-pick
1437
+ * decision. If unset, callers MUST pass `planKey` explicitly
1438
+ * (preserves today's behaviour). Compiler validation pins the
1439
+ * value to a real `plans[].key`.
1440
+ */
1441
+ ephemeral: z13.object({
1442
+ defaultPlan: z13.string().min(1).optional()
1443
+ }).optional()
1444
+ }).superRefine((spec, ctx) => {
1445
+ rejectUsagePricing(spec, ctx);
1446
+ validateFreePlans(spec.plans, ctx);
1447
+ validatePaidPlanPrices(spec.plans, ctx);
1448
+ validateMeterReferences(spec, ctx);
1449
+ validateFeatureReferences(spec, ctx);
1450
+ validateRouteMeters(spec, ctx);
1451
+ validateLimitMeterReachability(spec, ctx);
1452
+ });
1453
+ var productPhaseSchema = z13.object({
1454
+ product: productSpecSchema.shape.product
1455
+ });
1456
+ var gatewayPhaseSchema = z13.object({
1457
+ gateway: productSpecSchema.shape.gateway
1458
+ });
1459
+ var meteringPhaseSchema = z13.object({
1460
+ metering: productSpecSchema.shape.metering
1461
+ });
1462
+ var plansPhaseSchema = z13.object({
1463
+ plans: productSpecSchema.shape.plans
1464
+ });
1465
+
1466
+ // ../contracts/dist/plans/spec/policy-types.js
1467
+ import { z as z14 } from "zod";
1468
+ var rateLimitWindowSchema = z14.string().min(2).max(20).regex(/^\d+(ms|s|m|h)$/, "rate_limit window must look like `60s`, `5m`, `1h`");
1469
+ var rateLimitConfigSchema = z14.object({
1470
+ strategy: z14.enum(["token_bucket", "sliding_window", "fixed_window"]).default("token_bucket"),
1471
+ /**
1472
+ * Which request dimensions identify the bucket. v0.3.0 supports a
1473
+ * fixed set; extending requires a coordinated gateway/policy-engine
1474
+ * change. The `subscription` dimension is the steady-state default;
1475
+ * `ip` is for unauthenticated probes; `credential` is finer-grained
1476
+ * than subscription (per-key throttling).
1477
+ */
1478
+ dimensions: z14.array(z14.enum(["subscription", "credential", "ip", "route"])).min(1).max(4).default(["subscription"]),
1479
+ limits: z14.array(z14.object({
1480
+ window: rateLimitWindowSchema,
1481
+ max: z14.number().int().positive().max(1e7)
1482
+ })).min(1).max(10),
1483
+ /**
1484
+ * Bounded fail-open behaviour for DO outages. See architecture RFC
1485
+ * "Fail-open guardrails" section. When the policy executor observes
1486
+ * `max_consecutive_failures` DO-call failures within
1487
+ * `max_window_seconds`, it transitions to `degraded_mode` until
1488
+ * `recovery_threshold` consecutive successes restore normal
1489
+ * evaluation.
1490
+ */
1491
+ fail_open: z14.object({
1492
+ max_consecutive_failures: z14.number().int().positive().default(100),
1493
+ max_window_seconds: z14.number().int().positive().default(60),
1494
+ recovery_threshold: z14.number().int().positive().default(50),
1495
+ degraded_mode: z14.enum([
1496
+ "safe_mode_block",
1497
+ "safe_mode_throttle",
1498
+ "runtime_killswitch_trigger"
1499
+ ]).default("safe_mode_throttle")
1500
+ }).default({
1501
+ max_consecutive_failures: 100,
1502
+ max_window_seconds: 60,
1503
+ recovery_threshold: 50,
1504
+ degraded_mode: "safe_mode_throttle"
1505
+ })
1506
+ });
1507
+ var authConfigSchema = z14.object({
1508
+ header_name: z14.string().min(1).max(100).default("x-api-key"),
1509
+ /**
1510
+ * How the gateway constructs the upstream Authorization header:
1511
+ * - `none` → no upstream auth header added
1512
+ * - `static_bearer` → forward a configured static token
1513
+ * - `subscriber_jwt` → mint a per-subscriber JWT (out of scope v0.3.0)
1514
+ */
1515
+ upstream_token_source: z14.discriminatedUnion("type", [
1516
+ z14.object({ type: z14.literal("none") }),
1517
+ z14.object({
1518
+ type: z14.literal("static_bearer"),
1519
+ token_secret_ref: z14.string().min(1).max(200).describe("Reference into the secret store (e.g. CF Secret name); not the raw token")
1520
+ })
1521
+ ]).default({ type: "none" }),
1522
+ /**
1523
+ * When `true`, treat the inbound credential's scopes (if any) as
1524
+ * additional gating beyond the entitlement check. v0.3.0 ships with
1525
+ * `strict` as the default.
1526
+ */
1527
+ scope_mode: z14.enum(["strict", "advisory", "off"]).default("strict")
1528
+ });
1529
+ var concurrencyConfigSchema = z14.object({
1530
+ max_in_flight: z14.number().int().positive().max(1e4),
1531
+ /**
1532
+ * Which dimensions key the lease bucket. Matches the existing
1533
+ * ConcurrencyLease DO `idFromName` pattern (subscription | capability
1534
+ * tuple).
1535
+ */
1536
+ dimensions: z14.array(z14.enum(["subscription", "credential", "capability"])).min(1).max(3).default(["subscription"]),
1537
+ /**
1538
+ * Optional capability scope. When set, the lease bucket is keyed
1539
+ * partly by this capability name — separate buckets per capability.
1540
+ */
1541
+ capability: z14.string().min(1).max(120).optional(),
1542
+ /**
1543
+ * Lease TTL — releases automatically after this many seconds even if
1544
+ * the request never returns (defensive default 30s, mirrors existing
1545
+ * ConcurrencyLease behaviour).
1546
+ */
1547
+ lease_ttl_seconds: z14.number().int().positive().max(600).default(30),
1548
+ fail_open: z14.object({
1549
+ max_consecutive_failures: z14.number().int().positive().default(50),
1550
+ max_window_seconds: z14.number().int().positive().default(60),
1551
+ recovery_threshold: z14.number().int().positive().default(20),
1552
+ degraded_mode: z14.enum([
1553
+ "safe_mode_block",
1554
+ "safe_mode_throttle",
1555
+ "runtime_killswitch_trigger"
1556
+ ]).default("safe_mode_throttle")
1557
+ }).default({
1558
+ max_consecutive_failures: 50,
1559
+ max_window_seconds: 60,
1560
+ recovery_threshold: 20,
1561
+ degraded_mode: "safe_mode_throttle"
1562
+ })
1563
+ });
1564
+ var retryConfigSchema = z14.object({
1565
+ max_attempts: z14.number().int().min(1).max(5).default(2),
1566
+ /**
1567
+ * HTTP status codes that trigger a retry. 5xx is the default; opt
1568
+ * into 429 retries only when the upstream understands `Retry-After`.
1569
+ */
1570
+ retry_on_status: z14.array(z14.number().int().min(400).max(599)).min(1).max(20).default([502, 503, 504]),
1571
+ /**
1572
+ * Backoff curve. Total wall-clock attempt time is bounded so the
1573
+ * gateway worker cannot block past `total_budget_ms`.
1574
+ */
1575
+ backoff: z14.object({
1576
+ initial_ms: z14.number().int().positive().max(5e3).default(100),
1577
+ multiplier: z14.number().positive().max(10).default(2),
1578
+ jitter: z14.enum(["none", "full", "equal"]).default("equal"),
1579
+ total_budget_ms: z14.number().int().positive().max(3e4).default(5e3)
1580
+ }).default({
1581
+ initial_ms: 100,
1582
+ multiplier: 2,
1583
+ jitter: "equal",
1584
+ total_budget_ms: 5e3
1585
+ })
1586
+ });
1587
+ var transformConfigSchema = z14.object({
1588
+ /**
1589
+ * When the transform applies. `request` runs before upstream forward;
1590
+ * `response` runs after. Most transforms are one or the other; both
1591
+ * is rare.
1592
+ */
1593
+ applies_to: z14.enum(["request", "response", "both"]).default("request"),
1594
+ /**
1595
+ * List of key rewrites. Source path uses dot notation (`a.b.c`);
1596
+ * `target` may include the same syntax to move keys around. Drops
1597
+ * are expressed as `target: null`.
1598
+ */
1599
+ rewrites: z14.array(z14.object({
1600
+ source: z14.string().min(1).max(200),
1601
+ target: z14.string().min(1).max(200).nullable()
1602
+ })).min(1).max(20)
1603
+ });
1604
+ var policyBodySchema = z14.discriminatedUnion("type", [
1605
+ z14.object({ type: z14.literal("rate_limit"), config: rateLimitConfigSchema }),
1606
+ z14.object({ type: z14.literal("auth"), config: authConfigSchema }),
1607
+ z14.object({ type: z14.literal("concurrency"), config: concurrencyConfigSchema }),
1608
+ z14.object({ type: z14.literal("retry"), config: retryConfigSchema }),
1609
+ z14.object({ type: z14.literal("transform"), config: transformConfigSchema })
1610
+ ]);
1611
+
1612
+ // ../contracts/dist/plans/spec/policies-layer.js
1613
+ import { z as z15 } from "zod";
1614
+ var cacheProfileSchema = z15.enum(["long", "short", "blocking"]).default("long");
1615
+ var policyCompatibilitySchema = z15.object({
1616
+ route_types: z15.array(z15.enum(["http"])).max(5).optional(),
1617
+ meters: z15.array(z15.string().min(1).max(64)).max(20).optional(),
1618
+ auth_modes: z15.array(z15.enum(["api_key", "oauth2", "anonymous"])).max(5).optional()
1619
+ });
1620
+ var policyFileSchema = z15.intersection(z15.object({
1621
+ /**
1622
+ * Policy name. Referenced by routes via `policies: [<name>]`. Must
1623
+ * be unique across the product; the compiler enforces this in the
1624
+ * cross-file validation pass.
1625
+ */
1626
+ name: z15.string().min(1).max(64).regex(/^[a-z0-9_-]+$/, "Policy name must be lowercase alphanumeric with hyphens/underscores"),
1627
+ description: z15.string().max(500).optional(),
1628
+ compatible_with: policyCompatibilitySchema.default({}),
1629
+ /**
1630
+ * Mutation class — runtime vs contractual. Policies are operational
1631
+ * by nature so the default is `runtime`. Marking a policy as
1632
+ * `contractual` signals that changes to it require human approval
1633
+ * (invariant #16).
1634
+ */
1635
+ mutation_class: z15.enum(["runtime", "contractual"]).default("runtime"),
1636
+ cacheProfile: cacheProfileSchema
1637
+ }), policyBodySchema);
1638
+
1639
+ // ../contracts/dist/plans/spec/routes-layer.js
1640
+ import { z as z17 } from "zod";
1641
+
1642
+ // ../contracts/dist/framework/actions/index.js
1643
+ import { z as z16 } from "zod";
1644
+ var actionKindSchema = z16.enum(["query", "mutation"]);
1645
+ var actionAuditPolicySchema = z16.enum(["none", "metadata", "full"]);
1646
+ var actionSubjectBindingSchema = z16.object({
1647
+ type: z16.string().min(1).max(64).regex(/^[a-zA-Z0-9_.:-]+$/),
1648
+ from: z16.enum(["header", "path_param"]),
1649
+ name: z16.string().min(1).max(120)
1650
+ });
1651
+ var actionResourceEffectSchema = z16.object({
1652
+ resource: z16.string().min(1).max(100).regex(/^[a-z0-9_.:-]+$/),
1653
+ effect: z16.enum(["create", "delete"])
1654
+ });
1655
+ var actionSpecSchema = z16.object({
1656
+ id: z16.string().min(1).max(160).regex(/^[a-z0-9_.:-]+$/),
1657
+ title: z16.string().min(1).max(160).optional(),
1658
+ kind: actionKindSchema,
1659
+ actorType: z16.string().min(1).max(64).regex(/^[a-zA-Z0-9_.:-]+$/).optional(),
1660
+ subject: actionSubjectBindingSchema.optional(),
1661
+ inputSchemaRef: z16.string().min(1).max(240).optional(),
1662
+ audit: actionAuditPolicySchema.default("metadata"),
1663
+ resource: actionResourceEffectSchema.optional()
1664
+ });
1665
+
1666
+ // ../contracts/dist/plans/spec/routes-layer.js
1667
+ var routeMatchSchema = z17.object({
1668
+ method: z17.enum(["GET", "POST", "PUT", "PATCH", "DELETE", "HEAD", "OPTIONS", "*"]).default("*"),
1669
+ path: z17.string().min(1).max(500).regex(/^\/[a-zA-Z0-9_/:.{}*-]*$/, "path must start with / and contain only [a-zA-Z0-9_/:.{}*-]")
1670
+ });
1671
+ var routeDefinitionSchema = z17.object({
1672
+ match: routeMatchSchema,
1673
+ /**
1674
+ * Per-route meter binding. Mirrors the legacy `featureRouteSchema`
1675
+ * semantics:
1676
+ * - omitted → route inherits "all configured product meters"
1677
+ * - non-empty → only these meters increment
1678
+ * - [] → REJECTED at parse (typo guard)
1679
+ * - null → treated as omitted (PATCH-clear UX)
1680
+ */
1681
+ meters: z17.array(z17.string().min(1).max(64)).min(1, "Use `unmetered: true` instead of an empty array (typo guard against silently-unmetered routes)").max(20).nullable().optional(),
1682
+ unmetered: z17.boolean().optional(),
1683
+ /** Optional explicit action id. When absent, the compiler derives an
1684
+ * implicit action from feature + method + path. */
1685
+ action: z17.string().min(1).max(160).regex(/^[a-z0-9_.:-]+$/).optional()
1686
+ }).superRefine((route, ctx) => {
1687
+ if (route.unmetered === true && route.meters && route.meters.length > 0) {
1688
+ ctx.addIssue({
1689
+ code: "custom",
1690
+ message: "`unmetered: true` is mutually exclusive with `meters` \u2014 drop one",
1691
+ path: ["unmetered"]
1692
+ });
1693
+ }
1694
+ });
1695
+ var routeUpstreamSchema = z17.object({
1696
+ override_origin: z17.string().url("override_origin must be a valid URL").nullable().default(null)
1697
+ });
1698
+ var routeRuntimeSchema = z17.object({
1699
+ rollout_key: z17.string().min(1).max(120).regex(/^[a-z0-9_-]+$/, "rollout_key must be lowercase alphanumeric with hyphens/underscores").optional(),
1700
+ /**
1701
+ * Optional runtime flags this feature depends on. The runtime
1702
+ * evaluator AND's the feature's enablement across all referenced
1703
+ * flags. If any flag is disabled, the route returns the configured
1704
+ * fallback (404 by default — see /runtime failure matrix).
1705
+ */
1706
+ required_flags: z17.array(z17.string().min(1).max(120).regex(/^[a-z0-9_-]+$/)).max(10).optional()
1707
+ });
1708
+ var routesFileSchema = z17.object({
1709
+ /**
1710
+ * Feature key — the entitlement unit. Surfaced in dashboards,
1711
+ * subscriptions, and the gateway's matched-route trace.
1712
+ */
1713
+ feature: z17.string().min(1).max(100).regex(/^[a-z0-9_.:-]+$/, "feature key must be lowercase alphanumeric with [_.:-]"),
1714
+ description: z17.string().max(500).optional(),
1715
+ /**
1716
+ * Route additions are contractual by default — they expose new API
1717
+ * surface to subscribers. Internal/non-customer-visible routes can
1718
+ * mark themselves `runtime` to allow autonomous agent flips
1719
+ * (invariant #16; see RFC approval matrix).
1720
+ */
1721
+ mutation_class: z17.enum(["runtime", "contractual"]).default("contractual"),
1722
+ cacheProfile: cacheProfileSchema,
1723
+ routes: z17.array(routeDefinitionSchema).min(1).max(50),
1724
+ upstream: routeUpstreamSchema.default({ override_origin: null }),
1725
+ /**
1726
+ * Ordered list of policy names to apply. Executed sequentially by
1727
+ * the gateway policy engine; first-deny wins. Referenced policies
1728
+ * MUST declare compatible `compatible_with` envelopes for this
1729
+ * feature's route/meter shape — the compiler enforces.
1730
+ */
1731
+ policies: z17.array(z17.string().min(1).max(64).regex(/^[a-z0-9_-]+$/)).max(20).default([]),
1732
+ runtime: routeRuntimeSchema.default({}),
1733
+ /**
1734
+ * Capability groups this feature joins. A plan that includes any
1735
+ * referenced capability grants this feature. Capabilities are
1736
+ * resolved at compile time into the plan's expanded feature set.
1737
+ */
1738
+ capabilities: z17.array(z17.string().min(1).max(120).regex(/^[a-z0-9_-]+$/)).max(20).default([]),
1739
+ /**
1740
+ * Plans that grant this feature directly (in addition to capability
1741
+ * membership). Mirrors the legacy `featureCatalogEntrySchema.plans`
1742
+ * field for the common case where the feature isn't part of any
1743
+ * shared capability group.
1744
+ *
1745
+ * v0.3.0 keeps direct-plan binding for migration ergonomics; v0.4
1746
+ * may deprecate this in favour of capability-only composition.
1747
+ */
1748
+ plans: z17.array(z17.string().min(1).max(64)).max(20).default([]),
1749
+ /** Explicit actions declared by this feature. Routes reference them by
1750
+ * `route.action`; routes without a binding receive implicit actions. */
1751
+ actions: z17.array(actionSpecSchema).max(100).optional()
1752
+ });
1753
+
1754
+ // ../contracts/dist/plans/spec/runtime-layer.js
1755
+ import { z as z18 } from "zod";
1756
+ var keyNameSchema = z18.string().min(1).max(120).regex(/^[a-z0-9_-]+$/, "runtime key must be lowercase alphanumeric with hyphens/underscores");
1757
+ var dependsOnSchema = z18.object({
1758
+ runtime_flags: z18.array(keyNameSchema).max(10).optional(),
1759
+ capabilities: z18.array(z18.string().min(1).max(120).regex(/^[a-z0-9_-]+$/)).max(10).optional()
1760
+ }).default({});
1761
+ var audienceSchema = z18.object({
1762
+ plans: z18.array(z18.string().min(1).max(64)).max(20).optional(),
1763
+ environments: z18.array(z18.string().min(1).max(64)).max(10).optional(),
1764
+ capabilities: z18.array(z18.string().min(1).max(120).regex(/^[a-z0-9_-]+$/)).max(10).optional()
1765
+ }).default({});
1766
+ var rolloutDefaultsSchema = z18.object({
1767
+ missing_behavior: z18.enum(["treat_as_zero_percent", "treat_as_full_rollout", "fail_closed"]).default("treat_as_zero_percent"),
1768
+ stale_behavior: z18.enum(["use_last_known", "fail_closed_on_signature"]).default("use_last_known")
1769
+ });
1770
+ var rolloutEntrySchema = z18.object({
1771
+ description: z18.string().max(500).optional(),
1772
+ audience: audienceSchema,
1773
+ /**
1774
+ * Rollout percentage (0-100). 0 = no subscribers in treatment;
1775
+ * 100 = full rollout. Cohort stability invariant #20: increasing
1776
+ * the percent admits more subscribers (existing stay); decreasing
1777
+ * drops subscribers whose deterministic bucket is above the new
1778
+ * boundary (logged as an audit event).
1779
+ */
1780
+ percent: z18.number().int().min(0).max(100),
1781
+ /**
1782
+ * Seed for the deterministic SHA-256 hash that computes bucket
1783
+ * assignment. Rotating the seed is a destructive rebucketing
1784
+ * operation; the workflow runner refuses without approval metadata
1785
+ * (invariant #16).
1786
+ */
1787
+ assignment_seed: z18.string().min(1).max(120).default("default"),
1788
+ depends_on: dependsOnSchema,
1789
+ defaults: rolloutDefaultsSchema.default({
1790
+ missing_behavior: "treat_as_zero_percent",
1791
+ stale_behavior: "use_last_known"
1792
+ })
1793
+ });
1794
+ var runtimeRolloutFileSchema = z18.object({
1795
+ cacheProfile: cacheProfileSchema.default("blocking"),
1796
+ rollouts: z18.record(keyNameSchema, rolloutEntrySchema).default({})
1797
+ });
1798
+ var flagDefaultsSchema = z18.object({
1799
+ missing_behavior: z18.enum(["disabled", "enabled", "fail_closed"]).default("disabled"),
1800
+ stale_behavior: z18.enum(["use_last_known", "fail_closed_on_signature"]).default("use_last_known")
1801
+ });
1802
+ var flagEntrySchema = z18.object({
1803
+ description: z18.string().max(500).optional(),
1804
+ enabled: z18.boolean(),
1805
+ audience: audienceSchema,
1806
+ depends_on: dependsOnSchema,
1807
+ defaults: flagDefaultsSchema.default({
1808
+ missing_behavior: "disabled",
1809
+ stale_behavior: "use_last_known"
1810
+ }),
1811
+ /**
1812
+ * When set, this flag acts as a killswitch — its `enabled: true`
1813
+ * disables traffic for the referenced subjects. Useful for
1814
+ * incident response (`policy_premium-rate-limit_emergency_off`).
1815
+ */
1816
+ killswitch_target: z18.object({
1817
+ kind: z18.enum(["policy", "route", "rollout"]),
1818
+ name: z18.string().min(1).max(120).regex(/^[a-z0-9_-]+$/)
1819
+ }).optional()
1820
+ });
1821
+ var runtimeFlagsFileSchema = z18.object({
1822
+ cacheProfile: cacheProfileSchema.default("blocking"),
1823
+ flags: z18.record(keyNameSchema, flagEntrySchema).default({})
1824
+ });
1825
+ var migrationDefaultsSchema = z18.object({
1826
+ missing_behavior: z18.enum(["disabled", "pending_review"]).default("disabled"),
1827
+ stale_behavior: z18.enum(["use_last_known", "fail_closed_on_signature"]).default("use_last_known")
1828
+ });
1829
+ var migrationTriggerSchema = z18.object({
1830
+ description: z18.string().max(500).optional(),
1831
+ /**
1832
+ * The (fromPlanKey, toPlanKey) migration this trigger configures.
1833
+ * Compiler validates both keys resolve.
1834
+ */
1835
+ from_plan_key: z18.string().min(1).max(64),
1836
+ to_plan_key: z18.string().min(1).max(64),
1837
+ policy: z18.enum([
1838
+ "GRANDFATHER",
1839
+ "MIGRATE_AT_RENEWAL",
1840
+ "MIGRATE_IMMEDIATELY",
1841
+ "MIGRATE_BY_DATE",
1842
+ "OPT_IN"
1843
+ ]).default("MIGRATE_AT_RENEWAL"),
1844
+ proration_policy: z18.enum(["NONE", "PRORATE", "CREDIT"]).default("NONE"),
1845
+ /**
1846
+ * Wall-clock cutover for `MIGRATE_BY_DATE` policy. Ignored for other
1847
+ * policies. Must be in the future at compile time.
1848
+ */
1849
+ cutover_at: z18.string().datetime().optional(),
1850
+ /**
1851
+ * Freeze window per invariant #8 — while this migration is RUNNING,
1852
+ * contractual mutations to the product are rejected at the webhook
1853
+ * layer with 423 LOCKED. Set to `false` to allow concurrent
1854
+ * contract edits (only for migrations that don't touch entitlement
1855
+ * shape — e.g. pure price corrections).
1856
+ */
1857
+ freeze_contract_mutations: z18.boolean().default(true),
1858
+ depends_on: dependsOnSchema,
1859
+ defaults: migrationDefaultsSchema.default({
1860
+ missing_behavior: "disabled",
1861
+ stale_behavior: "use_last_known"
1862
+ })
1863
+ });
1864
+ var runtimeMigrationsFileSchema = z18.object({
1865
+ cacheProfile: cacheProfileSchema.default("blocking"),
1866
+ migrations: z18.record(keyNameSchema, migrationTriggerSchema).default({})
1867
+ });
1868
+
1869
+ // ../contracts/dist/plans/spec/capabilities-layer.js
1870
+ import { z as z19 } from "zod";
1871
+ var capabilityFileSchema = z19.object({
1872
+ capability: z19.string().min(1).max(120).regex(/^[a-z0-9_-]+$/, "capability name must be lowercase alphanumeric with hyphens/underscores"),
1873
+ description: z19.string().max(500).optional(),
1874
+ /**
1875
+ * Capability composition is contractual by default — including a new
1876
+ * feature changes the customer's effective entitlement. Mark
1877
+ * `runtime` only for capabilities that compose runtime-only knobs
1878
+ * (e.g. an internal "monitoring" capability that gates dashboard
1879
+ * pages without affecting billable behaviour).
1880
+ */
1881
+ mutation_class: z19.enum(["runtime", "contractual"]).default("contractual"),
1882
+ includes_features: z19.array(z19.string().min(1).max(100).regex(/^[a-z0-9_.:-]+$/)).max(20).default([]),
1883
+ includes_policies: z19.array(z19.string().min(1).max(64).regex(/^[a-z0-9_-]+$/)).max(20).default([]),
1884
+ includes_capabilities: z19.array(z19.string().min(1).max(120).regex(/^[a-z0-9_-]+$/)).max(20).default([])
1885
+ });
1886
+
1887
+ // ../contracts/dist/plans/spec/product-v2.js
1888
+ import { z as z20 } from "zod";
1889
+ var PRODUCT_V2_SCHEMA_VERSION = 1;
1890
+ var billingKnobsShape2 = {
1891
+ meters: z20.array(meterSchema).default([]),
1892
+ recurring_fee_cents: z20.number().int().nonnegative().default(0),
1893
+ /** Billing cadence (`month` default / `year`). Drives the Stripe price
1894
+ * `recurring.interval` and the entitlement billing-period window. */
1895
+ billing_interval: billingIntervalSchema.default("month"),
1896
+ trial_days: z20.number().int().nonnegative().default(0),
1897
+ max_monthly_spend_cents: z20.number().int().nonnegative().optional(),
1898
+ min_monthly_spend_cents: z20.number().int().nonnegative().optional(),
1899
+ // Unified grants array — the SINGLE credit surface. Recurring +
1900
+ // one-time credit are canonical entries here (the legacy scalar knobs
1901
+ // were removed).
1902
+ grants: z20.array(grantSchema).max(40).default([])
1903
+ };
1904
+ var planSpecV2Schema = z20.object({
1905
+ key: z20.string().min(1).max(64).regex(/^[a-z0-9_-]+$/, "Plan key must be lowercase alphanumeric with hyphens/underscores"),
1906
+ name: z20.string().min(1).max(100),
1907
+ description: z20.string().max(500).optional(),
1908
+ details: z20.array(z20.string().max(200)).max(10).optional(),
1909
+ ...billingKnobsShape2,
1910
+ free: z20.boolean().default(false),
1911
+ /**
1912
+ * Capability composition references. The compiler's
1913
+ * `resolve-capabilities` pass walks the graph and expands this into
1914
+ * concrete (features, policies) sets stored on the CompiledPlan.
1915
+ *
1916
+ * Replaces the legacy `featureGates` block (which is now expressed
1917
+ * via capability composition) and the per-plan inverse feature
1918
+ * mapping (`featureCatalogEntry.plans[]`).
1919
+ *
1920
+ * A plan may reference zero capabilities — useful for free / starter
1921
+ * plans that grant access only via direct route bindings.
1922
+ */
1923
+ capabilities: z20.array(z20.string().min(1).max(120).regex(/^[a-z0-9_-]+$/)).max(20).default([]),
1924
+ limits: z20.array(planLimitRuleSchema).max(20).default([]),
1925
+ /**
1926
+ * Control-plane capability caps (count limits + boolean toggles).
1927
+ * Same semantics as the legacy `capability_limits` field.
1928
+ */
1929
+ capability_limits: z20.record(z20.string().min(1).max(120), z20.union([z20.number().int().nonnegative(), z20.boolean()])).default({}),
1930
+ overageBehavior: z20.enum(["block", "allow_and_bill"]).default("block"),
1931
+ selfServeEnabled: z20.boolean().default(true),
1932
+ /**
1933
+ * Marks a plan as the pinned-cohort head (status LEGACY_STABLE). Only
1934
+ * existing subscribers stay on legacy plans; new subscribers cannot
1935
+ * join. Removing a legacy plan while subscribers are pinned is a
1936
+ * compile error.
1937
+ */
1938
+ legacy: z20.boolean().optional().default(false),
1939
+ archive: z20.object({
1940
+ at: z20.string().datetime().optional(),
1941
+ transitionTo: z20.string().optional(),
1942
+ strategy: z20.enum(["auto", "explicit", "block"]).default("auto")
1943
+ }).optional()
1944
+ }).superRefine((plan, ctx) => {
1945
+ refineSingleCanonicalGrant(plan.grants, ctx);
1946
+ });
1947
+ var DEFAULT_SUBSCRIBER_CHANGE_POLICY = {
1948
+ default: "preserve_current_period",
1949
+ proration: "none",
1950
+ when: {
1951
+ price_increase: "preserve_current_period",
1952
+ price_decrease: "switch_immediately",
1953
+ feature_added: "switch_immediately",
1954
+ feature_removed: "preserve_current_period",
1955
+ limit_increased: "switch_immediately",
1956
+ limit_reduced: "preserve_current_period",
1957
+ credit_increased: "switch_immediately",
1958
+ credit_reduced: "preserve_current_period",
1959
+ rating_changed: "preserve_current_period"
1960
+ },
1961
+ allowImmediatePriceIncrease: false,
1962
+ allowImmediateEntitlementReduction: false
1963
+ };
1964
+ var productSpecV2Schema = z20.object({
1965
+ /**
1966
+ * Schema-version stamp. Forward migrations target a higher value;
1967
+ * readers refuse unsupported versions per invariant #2.
1968
+ */
1969
+ artifactSchemaVersion: z20.literal(PRODUCT_V2_SCHEMA_VERSION).default(PRODUCT_V2_SCHEMA_VERSION),
1970
+ product: z20.object({
1971
+ name: z20.string().min(1).max(100),
1972
+ displayName: z20.string().max(200).optional(),
1973
+ description: z20.string().max(2e3).optional(),
1974
+ baseUrl: z20.string().url("baseUrl must be a valid URL"),
1975
+ sandboxBaseUrl: z20.string().url("sandboxBaseUrl must be a valid URL").optional(),
1976
+ visibility: z20.enum(["public", "private"]).default("public"),
1977
+ logoUrl: z20.string().url().optional(),
1978
+ primaryColor: z20.string().regex(/^#[0-9a-fA-F]{6}$/).optional(),
1979
+ envBranchPrefix: z20.string().max(50).nullable().optional()
1980
+ }),
1981
+ /**
1982
+ * Meter catalog. Referenced by /routes/*.yaml via `meters: [...]`.
1983
+ * Same shape as the legacy meterDefinitionSchema (re-exported from
1984
+ * product.ts to keep one source of truth during the transition).
1985
+ *
1986
+ * Lifted to a top-level array so the compiler can hash + invalidate
1987
+ * meter changes independently from plan changes (incremental
1988
+ * compilation per invariant #1 + Merkle DAG).
1989
+ */
1990
+ meters: z20.array(meterDefinitionSchema).max(10).default([]),
1991
+ /**
1992
+ * Bill on 4xx responses (independent of meter setup). Stays on the
1993
+ * product spec because it's a contractual choice that affects
1994
+ * billing predictability.
1995
+ */
1996
+ billOn4xx: z20.boolean().default(false),
1997
+ frontend: frontendManifestSchema.optional(),
1998
+ migrations: migrationDeclsSchema.optional(),
1999
+ resources: countedResourcesSchema,
2000
+ billing: z20.object({
2001
+ gracePeriodDays: z20.number().int().nonnegative().default(3),
2002
+ // When true (default), a plan limit INCREASE re-projects onto active
2003
+ // subscribers immediately; a DECREASE always defers to period end.
2004
+ // Read by migrateActiveSubscribersForRuntimeOnlyChange.
2005
+ applyLimitUpgradesInstantly: z20.boolean().optional(),
2006
+ subscriberChangePolicy: subscriberChangePolicySchema.default(DEFAULT_SUBSCRIBER_CHANGE_POLICY)
2007
+ }).default({
2008
+ gracePeriodDays: 3,
2009
+ subscriberChangePolicy: DEFAULT_SUBSCRIBER_CHANGE_POLICY
2010
+ }),
2011
+ plans: z20.array(planSpecV2Schema).max(4).default([]),
2012
+ add_ons: addOnsBlockSchema,
2013
+ lifecycle: z20.object({
2014
+ breaking_changes: z20.object({
2015
+ require_deprecation_window_days: z20.number().int().nonnegative().default(0),
2016
+ require_successor_route: z20.boolean().default(false)
2017
+ }).default({
2018
+ require_deprecation_window_days: 0,
2019
+ require_successor_route: false
2020
+ })
2021
+ }).default({
2022
+ breaking_changes: {
2023
+ require_deprecation_window_days: 0,
2024
+ require_successor_route: false
2025
+ }
2026
+ }),
2027
+ webhooks: webhooksBlockSchema.optional(),
2028
+ environments: environmentsBlockSchema.optional(),
2029
+ ephemeral: z20.object({
2030
+ defaultPlan: z20.string().min(1).optional()
2031
+ }).optional()
2032
+ });
2033
+
2034
+ // ../contracts/dist/plans/spec/manifest-ir.js
2035
+ import { createHash } from "node:crypto";
2036
+ import { z as z21 } from "zod";
2037
+ var MANIFEST_IR_VERSION = 1;
2038
+ var manifestIrSchema = z21.object({
2039
+ irVersion: z21.literal(MANIFEST_IR_VERSION),
2040
+ /** Version of @farthershore/product that emitted this envelope. */
2041
+ sdkVersion: z21.string().min(1).max(64),
2042
+ /** Legacy unified ProductSpec — the live `CompileProductOptions.sourceSpec`. */
2043
+ product: productSpecSchema,
2044
+ /** One entry per feature, sorted by `feature` (mirrors /routes/<feature>.yaml). */
2045
+ routes: z21.array(routesFileSchema).max(200).default([]),
2046
+ /** Sorted by `name` (mirrors /policies/<name>.yaml). */
2047
+ policies: z21.array(policyFileSchema).max(200).default([]),
2048
+ /** Sorted by `capability` (mirrors /capabilities/<name>.yaml). */
2049
+ capabilities: z21.array(capabilityFileSchema).max(200).default([]),
2050
+ /** Reserved. Always null at irVersion 1 — runtime stays YAML/dashboard. */
2051
+ runtime: z21.object({
2052
+ rollout: z21.null().default(null),
2053
+ flags: z21.null().default(null),
2054
+ migrations: z21.null().default(null)
2055
+ }).default({ rollout: null, flags: null, migrations: null })
2056
+ });
2057
+ function canonicalManifestJson(value) {
2058
+ return stableJson(JSON.parse(JSON.stringify(value)));
2059
+ }
2060
+ function hashManifestIr(ir) {
2061
+ return createHash("sha256").update(canonicalManifestJson(ir)).digest("hex");
2062
+ }
2063
+ function stableJson(value) {
2064
+ if (Array.isArray(value))
2065
+ return `[${value.map(stableJson).join(",")}]`;
2066
+ if (value && typeof value === "object") {
2067
+ return `{${Object.entries(value).sort(([a], [b]) => a < b ? -1 : a > b ? 1 : 0).map(([key, val]) => `${JSON.stringify(key)}:${stableJson(val)}`).join(",")}}`;
2068
+ }
2069
+ return JSON.stringify(value);
2070
+ }
2071
+
2072
+ // ../contracts/dist/plans/presets.js
2073
+ var FREE = {
2074
+ kind: "free",
2075
+ label: "Free",
2076
+ description: "No recurring fee and no metered usage. Pure freemium tier.",
2077
+ pricing: {
2078
+ meters: [],
2079
+ recurring_fee_cents: 0,
2080
+ billing_interval: "month",
2081
+ grants: [],
2082
+ trial_days: 0
2083
+ }
2084
+ };
2085
+ var STARTER = {
2086
+ kind: "starter",
2087
+ label: "Starter \u2014 $20/mo + $20 included",
2088
+ description: "$20/month subscription with $20 of usage credit each period. Stripe-style minimum-spend; overage billed at $0.001/request by default.",
2089
+ pricing: {
2090
+ meters: [
2091
+ { dimension: "requests", kind: "linear", price_per_unit_micros: 1e3 }
2092
+ ],
2093
+ recurring_fee_cents: 2e3,
2094
+ billing_interval: "month",
2095
+ grants: [{ kind: "recurring", amount_cents: 2e3 }],
2096
+ trial_days: 0
2097
+ }
2098
+ };
2099
+ var PRO = {
2100
+ kind: "pro",
2101
+ label: "Pro \u2014 $100/mo + $200 included",
2102
+ description: "$100/month subscription with $200 of usage credit each period and a 14-day trial. Overage billed at $0.0005/request by default.",
2103
+ pricing: {
2104
+ meters: [
2105
+ { dimension: "requests", kind: "linear", price_per_unit_micros: 500 }
2106
+ ],
2107
+ recurring_fee_cents: 1e4,
2108
+ billing_interval: "month",
2109
+ grants: [{ kind: "recurring", amount_cents: 2e4 }],
2110
+ trial_days: 14
2111
+ }
2112
+ };
2113
+ var PREPAID = {
2114
+ kind: "prepaid",
2115
+ label: "Prepaid \u2014 $50 sign-up credit, then PAYG",
2116
+ description: "$50 one-time credit at signup. Once depleted the subscriber pays-as-they-go at $0.001/request by default.",
2117
+ pricing: {
2118
+ meters: [
2119
+ { dimension: "requests", kind: "linear", price_per_unit_micros: 1e3 }
2120
+ ],
2121
+ recurring_fee_cents: 0,
2122
+ billing_interval: "month",
2123
+ grants: [{ kind: "one_time", amount_cents: 5e3 }],
2124
+ trial_days: 0
2125
+ }
2126
+ };
2127
+ var METERED = {
2128
+ kind: "metered",
2129
+ label: "Metered (pay-as-you-go)",
2130
+ description: "No subscription fee. Pure usage billing at $0.001/request by default.",
2131
+ pricing: {
2132
+ meters: [
2133
+ { dimension: "requests", kind: "linear", price_per_unit_micros: 1e3 }
2134
+ ],
2135
+ recurring_fee_cents: 0,
2136
+ billing_interval: "month",
2137
+ grants: [],
2138
+ trial_days: 0
2139
+ }
2140
+ };
2141
+ var PLAN_PRESETS = Object.freeze({
2142
+ free: FREE,
2143
+ starter: STARTER,
2144
+ pro: PRO,
2145
+ prepaid: PREPAID,
2146
+ metered: METERED
2147
+ });
2148
+
2149
+ // ../contracts/dist/plans/subscription-pricing-override.js
2150
+ import { z as z22 } from "zod";
2151
+ var subscriptionPricingOverrideSchema = z22.object({
2152
+ /** Override the plan's recurring fee for this subscriber. */
2153
+ recurring_fee_cents: z22.number().int().nonnegative().optional(),
2154
+ /**
2155
+ * Override the plan's credit grants. `grants[]` is the single credit
2156
+ * surface — when present, it fully replaces the plan's grants for this
2157
+ * subscriber (recurring + one-time credit are canonical entries here;
2158
+ * the legacy recurring/one-time scalar knobs were removed).
2159
+ */
2160
+ grants: z22.array(grantSchema).max(40).optional(),
2161
+ /** Override the minimum-spend floor. */
2162
+ min_monthly_spend_cents: z22.number().int().nonnegative().optional(),
2163
+ /** Override the maximum-spend ceiling. */
2164
+ max_monthly_spend_cents: z22.number().int().nonnegative().optional(),
2165
+ /** Replace the entire meter list for this subscriber. When set, the
2166
+ * full plan meter array is replaced (not merged) — call it explicit
2167
+ * rather than implicit so a deal can also REMOVE billable meters,
2168
+ * not just adjust rates. */
2169
+ meters: z22.array(meterSchema).optional(),
2170
+ /** Free-text notes about the deal. Surfaced in admin UI for audit. */
2171
+ notes: z22.string().max(2e3).optional()
2172
+ });
2173
+
2174
+ // src/validate.ts
2175
+ function validateManifestIr(candidate) {
2176
+ const parsed = manifestIrSchema.safeParse(candidate);
2177
+ if (!parsed.success) {
2178
+ return {
2179
+ ok: false,
2180
+ issues: parsed.error.issues.map((issue) => ({
2181
+ code: issue.code.toUpperCase(),
2182
+ path: issue.path.map(String).join("."),
2183
+ message: issue.message
2184
+ }))
2185
+ };
2186
+ }
2187
+ const ir = JSON.parse(
2188
+ JSON.stringify(candidate)
2189
+ );
2190
+ return { ok: true, ir, irHash: hashIr(ir) };
2191
+ }
2192
+ function hashIr(ir) {
2193
+ return hashManifestIr(ir);
2194
+ }
2195
+
2196
+ // src/version.ts
2197
+ var SDK_VERSION = true ? "0.0.0" : "0.0.0-dev";
2198
+
2199
+ // src/business.ts
2200
+ var BUSINESS_BRAND = Symbol.for("farthershore.product.business");
2201
+ function isCapabilityGrant(value) {
2202
+ return typeof value === "object" && value !== null && value.kind === "capability_grant";
2203
+ }
2204
+ function keyOf(ref) {
2205
+ return typeof ref === "string" ? ref : ref.key;
2206
+ }
2207
+ function parseRouteMatch(match) {
2208
+ const trimmed = match.trim();
2209
+ const space = trimmed.indexOf(" ");
2210
+ if (space === -1) {
2211
+ if (!trimmed.startsWith("/")) {
2212
+ throw new ManifestBuilderError(
2213
+ `route "${match}": expected "METHOD /path" or "/path"`
2214
+ );
2215
+ }
2216
+ return { method: "*", path: trimmed };
2217
+ }
2218
+ const method = trimmed.slice(0, space).toUpperCase();
2219
+ const path = trimmed.slice(space + 1).trim();
2220
+ const methods = [
2221
+ "GET",
2222
+ "POST",
2223
+ "PUT",
2224
+ "PATCH",
2225
+ "DELETE",
2226
+ "HEAD",
2227
+ "OPTIONS",
2228
+ "*"
2229
+ ];
2230
+ if (!methods.includes(method)) {
2231
+ throw new ManifestBuilderError(
2232
+ `route "${match}": unknown HTTP method "${method}"`
2233
+ );
2234
+ }
2235
+ if (!path.startsWith("/")) {
2236
+ throw new ManifestBuilderError(
2237
+ `route "${match}": path must start with "/"`
2238
+ );
2239
+ }
2240
+ return { method, path };
2241
+ }
2242
+ var Business = class {
2243
+ [BUSINESS_BRAND] = true;
2244
+ name;
2245
+ options;
2246
+ meters = /* @__PURE__ */ new Map();
2247
+ resources = /* @__PURE__ */ new Map();
2248
+ plans = /* @__PURE__ */ new Map();
2249
+ features = /* @__PURE__ */ new Map();
2250
+ policies = /* @__PURE__ */ new Map();
2251
+ capabilities = /* @__PURE__ */ new Map();
2252
+ migrations = [];
2253
+ frontendManifest;
2254
+ productPatch = {};
2255
+ /** Sugar for binding API routes to features. */
2256
+ api;
2257
+ frontend;
2258
+ lifecycle;
2259
+ /** Escape hatches — raw platform-schema JSON, validated at toIR(). */
2260
+ raw;
2261
+ constructor(name, options) {
2262
+ if (!name || typeof name !== "string") {
2263
+ throw new ManifestBuilderError("fs.business(name, \u2026): name is required");
2264
+ }
2265
+ if (!options?.baseUrl) {
2266
+ throw new ManifestBuilderError(
2267
+ `fs.business("${name}", \u2026): options.baseUrl is required (the upstream origin the gateway proxies to)`
2268
+ );
2269
+ }
2270
+ this.name = name;
2271
+ this.options = options;
2272
+ this.api = {
2273
+ route: (match, options2) => {
2274
+ const featureKey = keyOf(options2.feature);
2275
+ const file = this.features.get(featureKey);
2276
+ if (!file) {
2277
+ throw new ManifestBuilderError(
2278
+ `api.route("${match}"): feature "${featureKey}" is not declared \u2014 call business.feature("${featureKey}", \u2026) first`
2279
+ );
2280
+ }
2281
+ file.routes.push(this.buildRoute(match, options2));
2282
+ return this;
2283
+ }
2284
+ };
2285
+ this.frontend = {
2286
+ nav: (items) => {
2287
+ const manifest = this.ensureFrontendManifest();
2288
+ manifest.nav = items.map((item) => ({
2289
+ label: item.label,
2290
+ path: item.path,
2291
+ ...item.capability !== void 0 ? { capability: keyOf(item.capability) } : {}
2292
+ }));
2293
+ return this;
2294
+ },
2295
+ page: (path, options2) => {
2296
+ const manifest = this.ensureFrontendManifest();
2297
+ if (manifest.pages?.some((page) => page.path === path)) {
2298
+ throw new ManifestBuilderError(
2299
+ `duplicate frontend page "${path}" \u2014 each frontend page path must be declared once`
2300
+ );
2301
+ }
2302
+ manifest.pages ??= [];
2303
+ manifest.pages.push({
2304
+ path,
2305
+ title: options2.title,
2306
+ requiresAuth: options2.requiresAuth,
2307
+ ...options2.capability !== void 0 ? { capability: keyOf(options2.capability) } : {},
2308
+ ...options2.components?.length ? {
2309
+ components: options2.components.map((component) => ({
2310
+ component: component.component,
2311
+ ...component.props !== void 0 ? { props: component.props } : {},
2312
+ ...component.capability !== void 0 ? { capability: keyOf(component.capability) } : {},
2313
+ ...component.gateMode !== void 0 ? { gateMode: component.gateMode } : {}
2314
+ }))
2315
+ } : {}
2316
+ });
2317
+ return this;
2318
+ },
2319
+ manifest: (manifest) => {
2320
+ this.frontendManifest = manifest;
2321
+ return this;
2322
+ }
2323
+ };
2324
+ this.lifecycle = {
2325
+ migration: (id, options2) => {
2326
+ this.assertUniqueMigrationId(id);
2327
+ this.migrations.push({
2328
+ id,
2329
+ from: this.normalizeMigrationPlanRef(options2.from),
2330
+ to: this.normalizeMigrationTargetRef(options2.to),
2331
+ newCustomers: options2.newCustomers ?? "immediate",
2332
+ existingCustomers: options2.existingCustomers,
2333
+ ...options2.pins?.length ? {
2334
+ pins: options2.pins.map((pin) => ({
2335
+ subscriber: pin.subscriber,
2336
+ pinTo: {
2337
+ plan: keyOf(pin.pinTo.plan),
2338
+ version: pin.pinTo.version
2339
+ },
2340
+ ...pin.until !== void 0 ? { until: pin.until } : {},
2341
+ ...pin.notes !== void 0 ? { notes: pin.notes } : {}
2342
+ }))
2343
+ } : {}
2344
+ });
2345
+ return this;
2346
+ },
2347
+ migrations: (migrations) => {
2348
+ this.assertUniqueMigrationIds(migrations);
2349
+ this.migrations = migrations;
2350
+ return this;
2351
+ }
2352
+ };
2353
+ this.raw = {
2354
+ productPatch: (patch) => {
2355
+ this.productPatch = deepMerge(this.productPatch, patch);
2356
+ return this;
2357
+ },
2358
+ plan: (spec) => {
2359
+ this.assertNewKey(this.plans, spec.key, "plan");
2360
+ this.plans.set(spec.key, spec);
2361
+ return this;
2362
+ },
2363
+ routesFile: (file) => {
2364
+ this.assertNewKey(this.features, file.feature, "feature");
2365
+ this.features.set(file.feature, file);
2366
+ return this;
2367
+ },
2368
+ policyFile: (file) => {
2369
+ this.assertNewKey(this.policies, file.name, "policy");
2370
+ this.policies.set(file.name, file);
2371
+ return this;
2372
+ },
2373
+ capabilityFile: (file) => {
2374
+ this.assertNewKey(this.capabilities, file.capability, "capability");
2375
+ this.capabilities.set(file.capability, file);
2376
+ return this;
2377
+ },
2378
+ frontend: (manifest) => {
2379
+ this.frontendManifest = manifest;
2380
+ return this;
2381
+ }
2382
+ };
2383
+ }
2384
+ meter(key, options) {
2385
+ this.assertNewKey(this.meters, key, "meter");
2386
+ this.meters.set(key, { key, ...options });
2387
+ return { kind: "meter", key };
2388
+ }
2389
+ resource(name, options = {}) {
2390
+ this.assertNewKey(this.resources, name, "resource");
2391
+ this.resources.set(name, { name, ...options });
2392
+ return { kind: "resource", key: name };
2393
+ }
2394
+ capability(key, options = {}) {
2395
+ this.assertNewKey(this.capabilities, key, "capability");
2396
+ const file = {
2397
+ capability: key,
2398
+ ...options.description !== void 0 || options.title !== void 0 ? { description: options.description ?? options.title } : {},
2399
+ ...options.mutationClass !== void 0 ? { mutation_class: options.mutationClass } : {},
2400
+ ...options.includesFeatures?.length ? { includes_features: options.includesFeatures.map(keyOf) } : {},
2401
+ ...options.includesPolicies?.length ? { includes_policies: options.includesPolicies.map(keyOf) } : {},
2402
+ ...options.includesCapabilities?.length ? { includes_capabilities: options.includesCapabilities.map(keyOf) } : {}
2403
+ };
2404
+ this.capabilities.set(key, file);
2405
+ return {
2406
+ kind: "capability",
2407
+ key,
2408
+ enable: (enableOptions) => ({
2409
+ kind: "capability_grant",
2410
+ capability: key,
2411
+ ...enableOptions?.limits ? { limits: enableOptions.limits } : {}
2412
+ })
2413
+ };
2414
+ }
2415
+ feature(key, options = {}) {
2416
+ this.assertNewKey(this.features, key, "feature");
2417
+ const file = {
2418
+ feature: key,
2419
+ routes: [],
2420
+ ...options.description !== void 0 ? { description: options.description } : {},
2421
+ ...options.mutationClass !== void 0 ? { mutation_class: options.mutationClass } : {},
2422
+ ...options.cacheProfile !== void 0 ? { cacheProfile: options.cacheProfile } : {},
2423
+ ...options.upstreamOrigin !== void 0 ? { upstream: { override_origin: options.upstreamOrigin } } : {},
2424
+ ...options.policies?.length ? { policies: options.policies.map(keyOf) } : {},
2425
+ ...options.capabilities?.length ? { capabilities: options.capabilities.map(keyOf) } : {},
2426
+ ...options.plans?.length ? { plans: options.plans.map(keyOf) } : {},
2427
+ ...options.actions?.length ? {
2428
+ actions: options.actions.map(({ id, ...action }) => ({
2429
+ id,
2430
+ ...action
2431
+ }))
2432
+ } : {},
2433
+ ...options.rolloutKey !== void 0 || options.requiredFlags?.length ? {
2434
+ runtime: {
2435
+ ...options.rolloutKey !== void 0 ? { rollout_key: options.rolloutKey } : {},
2436
+ ...options.requiredFlags?.length ? { required_flags: options.requiredFlags } : {}
2437
+ }
2438
+ } : {}
2439
+ };
2440
+ for (const route of options.routes ?? []) {
2441
+ file.routes.push(this.buildRoute(route.match, route));
2442
+ }
2443
+ this.features.set(key, file);
2444
+ const ref = {
2445
+ kind: "feature",
2446
+ key,
2447
+ action: (id, actionOptions) => {
2448
+ file.actions ??= [];
2449
+ if (file.actions.some((action) => action.id === id)) {
2450
+ throw new ManifestBuilderError(
2451
+ `duplicate action "${id}" \u2014 each action id must be declared once`
2452
+ );
2453
+ }
2454
+ file.actions.push({ id, ...actionOptions });
2455
+ return { kind: "action", key: id };
2456
+ },
2457
+ route: (match, routeOptions) => {
2458
+ file.routes.push(this.buildRoute(match, routeOptions ?? {}));
2459
+ return ref;
2460
+ }
2461
+ };
2462
+ return ref;
2463
+ }
2464
+ policy(name, options) {
2465
+ this.assertNewKey(this.policies, name, "policy");
2466
+ this.policies.set(name, {
2467
+ name,
2468
+ type: options.type,
2469
+ config: options.config,
2470
+ ...options.description !== void 0 ? { description: options.description } : {},
2471
+ ...options.mutationClass !== void 0 ? { mutation_class: options.mutationClass } : {},
2472
+ ...options.cacheProfile !== void 0 ? { cacheProfile: options.cacheProfile } : {},
2473
+ ...options.compatibleWith ? {
2474
+ compatible_with: {
2475
+ ...options.compatibleWith.routeTypes ? { route_types: options.compatibleWith.routeTypes } : {},
2476
+ ...options.compatibleWith.meters ? { meters: options.compatibleWith.meters.map(keyOf) } : {},
2477
+ ...options.compatibleWith.authModes ? { auth_modes: options.compatibleWith.authModes } : {}
2478
+ }
2479
+ } : {}
2480
+ });
2481
+ return { kind: "policy", key: name };
2482
+ }
2483
+ plan(key, options) {
2484
+ this.assertNewKey(this.plans, key, "plan");
2485
+ const capabilityKeys = (options.capabilities ?? []).map(keyOf);
2486
+ const capabilityLimits = {
2487
+ ...options.capabilityLimits ?? {}
2488
+ };
2489
+ const creditGrants = [];
2490
+ for (const grant of options.grants ?? []) {
2491
+ if (isCapabilityGrant(grant)) {
2492
+ if (!capabilityKeys.includes(grant.capability)) {
2493
+ capabilityKeys.push(grant.capability);
2494
+ }
2495
+ Object.assign(capabilityLimits, grant.limits ?? {});
2496
+ } else {
2497
+ creditGrants.push(grant);
2498
+ }
2499
+ }
2500
+ const spec = {
2501
+ key,
2502
+ name: options.name,
2503
+ ...options.description !== void 0 ? { description: options.description } : {},
2504
+ ...options.details ? { details: options.details } : {},
2505
+ ...options.price ? {
2506
+ recurring_fee_cents: options.price.recurring_fee_cents,
2507
+ billing_interval: options.price.billing_interval,
2508
+ ...options.price.free ? { free: true } : {}
2509
+ } : {},
2510
+ ...options.meters ? { meters: options.meters } : {},
2511
+ ...creditGrants.length ? { grants: creditGrants } : {},
2512
+ ...options.trialDays !== void 0 ? { trial_days: options.trialDays } : {},
2513
+ ...options.maxMonthlySpendCents !== void 0 ? { max_monthly_spend_cents: options.maxMonthlySpendCents } : {},
2514
+ ...options.minMonthlySpendCents !== void 0 ? { min_monthly_spend_cents: options.minMonthlySpendCents } : {},
2515
+ ...options.limits ? { limits: options.limits } : {},
2516
+ ...options.featureGates ? { featureGates: options.featureGates } : {},
2517
+ ...Object.keys(capabilityLimits).length ? { capability_limits: capabilityLimits } : {},
2518
+ ...options.overageBehavior !== void 0 ? { overageBehavior: options.overageBehavior } : {},
2519
+ ...options.selfServeEnabled !== void 0 ? { selfServeEnabled: options.selfServeEnabled } : {},
2520
+ ...options.legacy !== void 0 ? { legacy: options.legacy } : {},
2521
+ ...options.archive ? { archive: options.archive } : {},
2522
+ ...options.raw ?? {}
2523
+ };
2524
+ const rawCaps = Array.isArray(
2525
+ spec.capabilities
2526
+ ) ? spec.capabilities.map(
2527
+ String
2528
+ ) : [];
2529
+ const mergedCaps = [.../* @__PURE__ */ new Set([...capabilityKeys, ...rawCaps])];
2530
+ if (mergedCaps.length) {
2531
+ spec.capabilities = mergedCaps;
2532
+ }
2533
+ this.plans.set(key, spec);
2534
+ return { kind: "plan", key };
2535
+ }
2536
+ /** Assemble + validate the Manifest IR. Throws ManifestValidationError
2537
+ * with structured issues when the declared state is invalid. */
2538
+ toIR() {
2539
+ const candidate = {
2540
+ irVersion: 1,
2541
+ sdkVersion: SDK_VERSION,
2542
+ product: this.buildProductSpec(),
2543
+ routes: sortBy([...this.features.values()], (file) => file.feature),
2544
+ policies: sortBy([...this.policies.values()], (file) => file.name),
2545
+ capabilities: sortBy(
2546
+ [...this.capabilities.values()],
2547
+ (file) => file.capability
2548
+ ),
2549
+ runtime: { rollout: null, flags: null, migrations: null }
2550
+ };
2551
+ const result = validateManifestIr(candidate);
2552
+ if (!result.ok) throw new ManifestValidationError(result.issues);
2553
+ return { ir: result.ir, irHash: result.irHash };
2554
+ }
2555
+ buildProductSpec() {
2556
+ const options = this.options;
2557
+ const base = {
2558
+ product: {
2559
+ name: this.name,
2560
+ baseUrl: options.baseUrl,
2561
+ ...options.displayName !== void 0 ? { displayName: options.displayName } : {},
2562
+ ...options.description !== void 0 ? { description: options.description } : {},
2563
+ ...options.sandboxBaseUrl !== void 0 ? { sandboxBaseUrl: options.sandboxBaseUrl } : {},
2564
+ ...options.visibility !== void 0 ? { visibility: options.visibility } : {},
2565
+ ...options.logoUrl !== void 0 ? { logoUrl: options.logoUrl } : {},
2566
+ ...options.primaryColor !== void 0 ? { primaryColor: options.primaryColor } : {},
2567
+ ...options.envBranchPrefix !== void 0 ? { envBranchPrefix: options.envBranchPrefix } : {}
2568
+ },
2569
+ gateway: {
2570
+ ...options.authHeader !== void 0 ? { authHeader: options.authHeader } : {},
2571
+ ...options.upstreamAuth !== void 0 ? { upstreamAuth: options.upstreamAuth } : {}
2572
+ },
2573
+ metering: {
2574
+ meters: sortBy([...this.meters.values()], (meter) => meter.key),
2575
+ ...options.billOn4xx !== void 0 ? { billOn4xx: options.billOn4xx } : {}
2576
+ },
2577
+ ...options.billing !== void 0 ? { billing: options.billing } : {},
2578
+ ...this.frontendManifest !== void 0 ? { frontend: this.frontendManifest } : {},
2579
+ ...this.migrations.length ? { migrations: this.migrations } : {},
2580
+ ...this.resources.size ? {
2581
+ resources: sortBy(
2582
+ [...this.resources.values()],
2583
+ (resource) => resource.name
2584
+ )
2585
+ } : {},
2586
+ plans: sortBy([...this.plans.values()], (plan) => plan.key)
2587
+ };
2588
+ return deepMerge(
2589
+ base,
2590
+ this.productPatch
2591
+ );
2592
+ }
2593
+ buildRoute(match, options) {
2594
+ const parsed = parseRouteMatch(match);
2595
+ return {
2596
+ match: parsed,
2597
+ ...options.meters?.length ? { meters: options.meters.map(keyOf) } : {},
2598
+ ...options.unmetered !== void 0 ? { unmetered: options.unmetered } : {},
2599
+ ...options.action !== void 0 ? { action: keyOf(options.action) } : {}
2600
+ };
2601
+ }
2602
+ ensureFrontendManifest() {
2603
+ this.frontendManifest ??= { version: 1, nav: [], pages: [] };
2604
+ return this.frontendManifest;
2605
+ }
2606
+ normalizeMigrationPlanRef(ref) {
2607
+ return {
2608
+ plan: keyOf(ref.plan),
2609
+ ...ref.version !== void 0 ? { version: ref.version } : {}
2610
+ };
2611
+ }
2612
+ normalizeMigrationTargetRef(ref) {
2613
+ return {
2614
+ plan: keyOf(ref.plan),
2615
+ version: ref.version ?? "head"
2616
+ };
2617
+ }
2618
+ assertUniqueMigrationId(id) {
2619
+ if (this.migrations.some((migration) => migration.id === id)) {
2620
+ throw new ManifestBuilderError(
2621
+ `duplicate migration "${id}" \u2014 each migration id must be declared once`
2622
+ );
2623
+ }
2624
+ }
2625
+ assertUniqueMigrationIds(migrations) {
2626
+ const seen = /* @__PURE__ */ new Set();
2627
+ for (const migration of migrations) {
2628
+ if (seen.has(migration.id)) {
2629
+ throw new ManifestBuilderError(
2630
+ `duplicate migration "${migration.id}" \u2014 each migration id must be declared once`
2631
+ );
2632
+ }
2633
+ seen.add(migration.id);
2634
+ }
2635
+ }
2636
+ assertNewKey(map, key, label) {
2637
+ if (map.has(key)) {
2638
+ throw new ManifestBuilderError(
2639
+ `duplicate ${label} "${key}" \u2014 each ${label} key must be declared once`
2640
+ );
2641
+ }
2642
+ }
2643
+ };
2644
+ function isBusiness(value) {
2645
+ return typeof value === "object" && value !== null && value[BUSINESS_BRAND] === true;
2646
+ }
2647
+ function sortBy(items, key) {
2648
+ return [...items].sort((a, b) => {
2649
+ const ka = key(a);
2650
+ const kb = key(b);
2651
+ return ka < kb ? -1 : ka > kb ? 1 : 0;
2652
+ });
2653
+ }
2654
+ function isPlainObject(value) {
2655
+ return typeof value === "object" && value !== null && !Array.isArray(value);
2656
+ }
2657
+ function deepMerge(base, patch) {
2658
+ const out = { ...base };
2659
+ for (const [key, value] of Object.entries(patch)) {
2660
+ const existing = out[key];
2661
+ if (isPlainObject(existing) && isPlainObject(value)) {
2662
+ out[key] = deepMerge(existing, value);
2663
+ } else {
2664
+ out[key] = value;
2665
+ }
2666
+ }
2667
+ return out;
2668
+ }
2669
+
2670
+ // src/bin.ts
2671
+ function parseArgs(argv) {
2672
+ const args = {
2673
+ entry: "product.config.ts",
2674
+ out: "manifest-ir.json",
2675
+ diagnosticsOut: "manifest-diagnostics.json"
2676
+ };
2677
+ for (let i = 0; i < argv.length; i++) {
2678
+ const flag = argv[i];
2679
+ const value = argv[i + 1];
2680
+ if (flag === "--entry" && value) {
2681
+ args.entry = value;
2682
+ i++;
2683
+ } else if (flag === "--out" && value) {
2684
+ args.out = value;
2685
+ i++;
2686
+ } else if (flag === "--diagnostics-out" && value) {
2687
+ args.diagnosticsOut = value;
2688
+ i++;
2689
+ } else if (flag === "--help" || flag === "-h") {
2690
+ process.stdout.write(
2691
+ "Usage: farthershore-manifest-build [--entry product.config.ts] [--out manifest-ir.json] [--diagnostics-out manifest-diagnostics.json]\n"
2692
+ );
2693
+ process.exit(0);
2694
+ }
2695
+ }
2696
+ return args;
2697
+ }
2698
+ function validationIssues(error) {
2699
+ if (error instanceof ManifestValidationError) return error.issues;
2700
+ if (typeof error === "object" && error !== null && error.name === "ManifestValidationError" && Array.isArray(error.issues)) {
2701
+ return error.issues;
2702
+ }
2703
+ return null;
2704
+ }
2705
+ function errorMessage(error) {
2706
+ return error instanceof Error ? error.message : String(error);
2707
+ }
2708
+ async function main() {
2709
+ const args = parseArgs(process.argv.slice(2));
2710
+ const entryPath = resolve(process.cwd(), args.entry);
2711
+ let mod;
2712
+ try {
2713
+ mod = await tsImport(
2714
+ pathToFileURL(entryPath).href,
2715
+ import.meta.url
2716
+ );
2717
+ } catch (error) {
2718
+ process.stderr.write(
2719
+ `farthershore-manifest-build: failed to load ${args.entry}: ${error instanceof Error ? error.message : String(error)}
2720
+ `
2721
+ );
2722
+ process.exit(1);
2723
+ }
2724
+ const direct = mod.default;
2725
+ const interop = direct?.default;
2726
+ const candidate = isBusiness(direct) ? direct : interop;
2727
+ if (!isBusiness(candidate)) {
2728
+ process.stderr.write(
2729
+ `farthershore-manifest-build: ${args.entry} must \`export default\` the Business returned by fs.business(\u2026)
2730
+ `
2731
+ );
2732
+ process.exit(1);
2733
+ }
2734
+ try {
2735
+ const { ir, irHash } = candidate.toIR();
2736
+ await writeFile(
2737
+ resolve(process.cwd(), args.out),
2738
+ `${JSON.stringify({ ir, irHash }, null, 2)}
2739
+ `,
2740
+ "utf8"
2741
+ );
2742
+ process.stdout.write(
2743
+ `manifest ok \u2014 ${ir.product.plans?.length ?? 0} plan(s), ${ir.routes.length} feature(s), irHash ${irHash.slice(0, 12)}\u2026 (sdk ${SDK_VERSION})
2744
+ `
2745
+ );
2746
+ } catch (error) {
2747
+ const issues = validationIssues(error);
2748
+ if (issues) {
2749
+ await writeFile(
2750
+ resolve(process.cwd(), args.diagnosticsOut),
2751
+ `${JSON.stringify({ version: 1, source: "sdk", issues }, null, 2)}
2752
+ `,
2753
+ "utf8"
2754
+ );
2755
+ process.stderr.write(`${errorMessage(error)}
2756
+ `);
2757
+ process.exit(2);
2758
+ }
2759
+ process.stderr.write(
2760
+ `farthershore-manifest-build: ${errorMessage(error)}
2761
+ `
2762
+ );
2763
+ process.exit(1);
2764
+ }
2765
+ }
2766
+ await main();