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