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