@amboras-dev/capability-kit 0.0.0-stage → 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.js ADDED
@@ -0,0 +1,1036 @@
1
+ import { z } from "zod/v4";
2
+ //#region src/canonical-json.ts
3
+ function serialize(value, seen) {
4
+ if (value !== null && typeof value === "object" && typeof value.toJSON === "function") value = value.toJSON();
5
+ if (value === null) return "null";
6
+ switch (typeof value) {
7
+ case "string": return JSON.stringify(value);
8
+ case "number": return Number.isFinite(value) ? JSON.stringify(value) : "null";
9
+ case "boolean": return value ? "true" : "false";
10
+ case "bigint": throw new TypeError("canonicalJson: BigInt is not JSON");
11
+ case "object": break;
12
+ default: return;
13
+ }
14
+ const obj = value;
15
+ if (seen.has(obj)) throw new TypeError("canonicalJson: circular structure");
16
+ seen.add(obj);
17
+ let out;
18
+ if (Array.isArray(obj)) out = `[${obj.map((item) => serialize(item, seen) ?? "null").join(",")}]`;
19
+ else {
20
+ const record = obj;
21
+ const parts = [];
22
+ for (const key of Object.keys(record).sort()) {
23
+ const item = serialize(record[key], seen);
24
+ if (item !== void 0) parts.push(`${JSON.stringify(key)}:${item}`);
25
+ }
26
+ out = `{${parts.join(",")}}`;
27
+ }
28
+ seen.delete(obj);
29
+ return out;
30
+ }
31
+ /** Sorted keys, no whitespace. Throws on BigInt and circular values; `undefined` at the top serializes as `null`. */
32
+ function canonicalJson(v) {
33
+ return serialize(v, /* @__PURE__ */ new Set()) ?? "null";
34
+ }
35
+ //#endregion
36
+ //#region src/errors.ts
37
+ const STATUS = {
38
+ invalid_input: 400,
39
+ forbidden: 403,
40
+ not_found: 404,
41
+ conflict: 409,
42
+ confirmation_required: 409,
43
+ rate_limited: 429,
44
+ idempotency_conflict: 409,
45
+ unavailable: 503,
46
+ internal: 500
47
+ };
48
+ function httpStatusFor(code) {
49
+ return STATUS[code];
50
+ }
51
+ /** Retrying the same request can succeed later only for these. */
52
+ const RETRYABLE_BY_DEFAULT = /* @__PURE__ */ new Set(["rate_limited", "unavailable"]);
53
+ var CapabilityError = class extends Error {
54
+ /** `retryable` defaults to true for `rate_limited` and `unavailable`, else false. */
55
+ constructor(init) {
56
+ super(init.message);
57
+ this.name = "CapabilityError";
58
+ this.body = {
59
+ ...init,
60
+ retryable: init.retryable ?? RETRYABLE_BY_DEFAULT.has(init.code)
61
+ };
62
+ this.status = httpStatusFor(init.code);
63
+ }
64
+ };
65
+ /** A definition that breaks a kit rule. Thrown at boot, never per request. */
66
+ var CapabilityDefinitionError = class extends Error {
67
+ constructor(message) {
68
+ super(message);
69
+ this.name = "CapabilityDefinitionError";
70
+ }
71
+ };
72
+ /** A manifest that fails its shape or hash check. */
73
+ var CapabilityManifestError = class extends Error {
74
+ constructor(message) {
75
+ super(message);
76
+ this.name = "CapabilityManifestError";
77
+ }
78
+ };
79
+ const dotted = (path) => path.map((p) => String(p)).join(".");
80
+ const hasOwn = (obj, key) => Object.prototype.hasOwnProperty.call(obj, key);
81
+ function issueRule(issue) {
82
+ const params = issue.params;
83
+ if (issue.code !== "custom" || typeof params !== "object" || params === null) return void 0;
84
+ const rule = params.rule;
85
+ return typeof rule === "string" ? rule : void 0;
86
+ }
87
+ function plain(issue) {
88
+ const field = dotted(issue.path);
89
+ const rule = issueRule(issue);
90
+ return {
91
+ field,
92
+ message: issue.message,
93
+ headline: field ? `${field}: ${issue.message}` : issue.message,
94
+ ...rule ? { rule } : {}
95
+ };
96
+ }
97
+ /** One entry per renamed key (steering message first), then the issue itself for any other unknown keys. */
98
+ /**
99
+ * A plain union reports only "Invalid input" at its own path. Report the branch
100
+ * that came closest instead (fewest issues, then fewest literal mismatches; the
101
+ * first on a tie), with full paths.
102
+ */
103
+ function unionEntries(issue, renamed) {
104
+ const branches = issue.errors.filter((b) => b.length > 0);
105
+ if (branches.length === 0) return void 0;
106
+ const rank = (branch) => branch.length * 1e3 + branch.filter((i) => i.code === "invalid_value").length;
107
+ return branches.reduce((best, branch) => rank(branch) < rank(best) ? branch : best).flatMap((sub) => entriesFor({
108
+ ...sub,
109
+ path: [...issue.path, ...sub.path]
110
+ }, renamed));
111
+ }
112
+ function entriesFor(issue, renamed) {
113
+ if (issue.code === "invalid_union") return unionEntries(issue, renamed) ?? [plain(issue)];
114
+ if (issue.code !== "unrecognized_keys" || !Array.isArray(issue.keys)) return [plain(issue)];
115
+ const keys = issue.keys.map(String);
116
+ const known = keys.filter((key) => hasOwn(renamed, key));
117
+ if (known.length === 0) return [plain(issue)];
118
+ const out = known.map((key) => {
119
+ const message = `"${key}" was removed: use ${renamed[key]}.`;
120
+ return {
121
+ field: dotted([...issue.path, key]),
122
+ message,
123
+ headline: message,
124
+ rule: "field_renamed"
125
+ };
126
+ });
127
+ const rest = keys.filter((key) => !hasOwn(renamed, key));
128
+ if (rest.length > 0) {
129
+ const message = `Unrecognized key${rest.length > 1 ? "s" : ""}: ${rest.map((k) => `"${k}"`).join(", ")}`;
130
+ out.push(plain({
131
+ ...issue,
132
+ keys: rest,
133
+ message
134
+ }));
135
+ }
136
+ return out;
137
+ }
138
+ /**
139
+ * `invalid_input`: `field` is the dotted path of the first issue, `issues` lists
140
+ * all of them. Renamed-field issues (`options.renamed`) come first and make the headline. A custom issue's `params.rule` becomes `body.rule`; see
141
+ * `options.renamed` for steering removed field names.
142
+ */
143
+ function fromZodError(err, options = {}) {
144
+ const renamed = options.renamed ?? {};
145
+ const all = err.issues.flatMap((issue) => entriesFor(issue, renamed));
146
+ const entries = [...all.filter((e) => e.rule === "field_renamed"), ...all.filter((e) => e.rule !== "field_renamed")];
147
+ const issues = entries.map(({ field, message }) => ({
148
+ field,
149
+ message
150
+ }));
151
+ const first = entries[0];
152
+ if (!first) return new CapabilityError({
153
+ code: "invalid_input",
154
+ message: "Invalid input",
155
+ issues: []
156
+ });
157
+ return new CapabilityError({
158
+ code: "invalid_input",
159
+ message: first.headline,
160
+ ...first.field ? { field: first.field } : {},
161
+ ...first.rule ? { rule: first.rule } : {},
162
+ issues
163
+ });
164
+ }
165
+ //#endregion
166
+ //#region src/ids.ts
167
+ const AREAS = [
168
+ "promotions",
169
+ "catalog",
170
+ "inventory",
171
+ "setup",
172
+ "content",
173
+ "orders",
174
+ "payments",
175
+ "domains",
176
+ "publish"
177
+ ];
178
+ /** Higher rank includes every lower one: an owner can do what a manager can. */
179
+ const ROLE_RANK = Object.freeze({
180
+ owner: 3,
181
+ manager: 2,
182
+ staff: 1
183
+ });
184
+ const ID_PATTERN = new RegExp(`^(${AREAS.join("|")})(\\.[a-z][a-z_]*){1,2}$`);
185
+ /** True for a well-formed id in a known area. */
186
+ function isCapabilityId(value) {
187
+ return typeof value === "string" && ID_PATTERN.test(value);
188
+ }
189
+ /** The area an id belongs to (`promotions.create` → `promotions`). */
190
+ function areaOf(id) {
191
+ return id.slice(0, id.indexOf("."));
192
+ }
193
+ /** True for `owner`, `manager` or `staff`. */
194
+ function isRole(value) {
195
+ return value === "owner" || value === "manager" || value === "staff";
196
+ }
197
+ //#endregion
198
+ //#region src/money.ts
199
+ const ZERO_DECIMAL = /* @__PURE__ */ new Set([
200
+ "JPY",
201
+ "KRW",
202
+ "VND",
203
+ "CLP",
204
+ "PYG",
205
+ "XAF",
206
+ "XOF",
207
+ "BIF",
208
+ "DJF",
209
+ "GNF",
210
+ "ISK",
211
+ "KMF",
212
+ "MGA",
213
+ "RWF",
214
+ "UGX",
215
+ "VUV",
216
+ "XPF"
217
+ ]);
218
+ const THREE_DECIMAL = /* @__PURE__ */ new Set([
219
+ "KWD",
220
+ "BHD",
221
+ "OMR",
222
+ "TND",
223
+ "JOD",
224
+ "IQD",
225
+ "LYD"
226
+ ]);
227
+ /** The one unit sentence every money field carries, for the model and for people. */
228
+ const MONEY_UNIT_SENTENCE = "Amount in normal currency units as the merchant says it (99 means 99.00; 1.5 means 1.50). Never cents or minor units.";
229
+ /** JSON Schema keyword marking a money field. */
230
+ const MONEY_META_KEY = "x-amboras-money";
231
+ /**
232
+ * A bad amount or currency code. It is an `invalid_input` CapabilityError, so an
233
+ * adapter that maps CapabilityError answers 400 with the rule (and the field,
234
+ * when the caller passed one) instead of a 500.
235
+ */
236
+ var MoneyError = class extends CapabilityError {
237
+ constructor(message, rule, field) {
238
+ super({
239
+ code: "invalid_input",
240
+ message: field ? `${field}: ${message}` : message,
241
+ rule,
242
+ ...field ? { field } : {}
243
+ });
244
+ this.name = "MoneyError";
245
+ }
246
+ };
247
+ const isBound = (n) => typeof n === "number" && Number.isFinite(n) && n >= 0;
248
+ const BRANDS = /* @__PURE__ */ new WeakMap();
249
+ const alwaysValid = () => true;
250
+ /** The money brand a schema carries, if it was built by money() (through any chain). */
251
+ function moneyBrandOf(schema) {
252
+ const checks = schema?._zod?.def?.checks;
253
+ if (!checks) return void 0;
254
+ for (const check of checks) {
255
+ const brand = BRANDS.get(check);
256
+ if (brand) return brand;
257
+ }
258
+ }
259
+ /**
260
+ * A major-unit amount: finite, ≥ 0 (or > `exclusiveMinimum`), at most `max`,
261
+ * described with MONEY_UNIT_SENTENCE and marked `x-amboras-money` in JSON Schema.
262
+ *
263
+ * Chained checks (`.positive()`, `.max()`, `.int()`) and `.optional()`,
264
+ * `.nullable()`, `.nullish()` are safe: the kit's JSON Schema keeps the sentence
265
+ * and the marker. `.describe()` replaces the sentence, which `defineCapability`
266
+ * rejects: pass `describe` here instead.
267
+ */
268
+ function money(opts) {
269
+ let marker;
270
+ if ("currencyField" in opts) {
271
+ if (typeof opts.currencyField !== "string" || opts.currencyField.trim() === "") throw new CapabilityDefinitionError("money(): currencyField must be a non-empty field name");
272
+ marker = { currencyField: opts.currencyField };
273
+ } else if (opts.currency === "store") marker = { currency: "store" };
274
+ else throw new CapabilityDefinitionError("money(): pass { currencyField } or { currency: 'store' }");
275
+ const { exclusiveMinimum, max } = opts;
276
+ if (exclusiveMinimum !== void 0 && !isBound(exclusiveMinimum)) throw new CapabilityDefinitionError("money(): exclusiveMinimum must be a finite number ≥ 0");
277
+ if (max !== void 0 && (!isBound(max) || max <= (exclusiveMinimum ?? -1))) throw new CapabilityDefinitionError("money(): max must be a finite number ≥ 0 and above exclusiveMinimum");
278
+ const description = opts.describe ? `${opts.describe.trim()} ${MONEY_UNIT_SENTENCE}` : MONEY_UNIT_SENTENCE;
279
+ let schema = exclusiveMinimum === void 0 ? z.number().min(0) : z.number().gt(exclusiveMinimum);
280
+ if (max !== void 0) schema = schema.max(max);
281
+ schema = schema.refine(alwaysValid);
282
+ const { checks } = schema._zod.def;
283
+ BRANDS.set(checks[checks.length - 1], {
284
+ description,
285
+ marker
286
+ });
287
+ return schema.meta({
288
+ description,
289
+ [MONEY_META_KEY]: marker
290
+ });
291
+ }
292
+ function normalizeCode(currency) {
293
+ if (typeof currency !== "string" || !/^[A-Za-z]{3}$/.test(currency)) throw new MoneyError(`Invalid currency code: ${JSON.stringify(currency)}`, "currency_invalid");
294
+ return currency.toUpperCase();
295
+ }
296
+ /** ISO 4217 minor-unit digits: 0 for JPY/KRW/VND…, 3 for KWD/BHD/OMR/TND/JOD/IQD/LYD, else 2 (also for unknown codes). */
297
+ function fractionDigits(currency) {
298
+ const code = normalizeCode(currency);
299
+ if (ZERO_DECIMAL.has(code)) return 0;
300
+ if (THREE_DECIMAL.has(code)) return 3;
301
+ return 2;
302
+ }
303
+ /**
304
+ * Amboras storage rule: ×1000 for the seven three-decimal codes, ×100 for every
305
+ * other code INCLUDING zero-decimal ones (¥5 is stored as 500). By design; see
306
+ * Medusa `divisorFor`.
307
+ */
308
+ function storageScale(currency) {
309
+ return THREE_DECIMAL.has(normalizeCode(currency)) ? 1e3 : 100;
310
+ }
311
+ const MAX_UNITS = 2 ** 46;
312
+ /** Decimal places in the shortest text form of `n` (what JSON carries): 19.99 → 2, 1e-7 → 7, 1.5e+21 → 0. */
313
+ function decimalPlaces(n) {
314
+ const [mantissa, exponent = "0"] = String(Math.abs(n)).split("e");
315
+ const fraction = mantissa.split(".")[1] ?? "";
316
+ return Math.max(0, fraction.length - Number(exponent));
317
+ }
318
+ /**
319
+ * Major → stored minor integer. Throws MoneyError (`invalid_input`) when the
320
+ * amount has more fraction digits than the currency allows (19.999 USD, 5.5 JPY),
321
+ * is not finite, or does not fit a safe integer. Pass `field` to name the input
322
+ * field in the error.
323
+ *
324
+ * An amount is accepted when its decimal text has at most the allowed digits
325
+ * (exact at any magnitude), or when it is within float noise of such an amount
326
+ * (0.1 + 0.2 → 0.30). Noise is bounded absolutely, so a real extra digit is never
327
+ * rounded away, however large the amount.
328
+ */
329
+ function toStorageAmount(major, currency, field) {
330
+ const digits = fractionDigits(currency);
331
+ const scale = storageScale(currency);
332
+ const code = currency.toUpperCase();
333
+ if (typeof major !== "number" || !Number.isFinite(major)) throw new MoneyError(`Amount must be a finite number, got ${String(major)}`, "money_invalid", field);
334
+ const inDigits = major * 10 ** digits;
335
+ const whole = Math.round(inDigits);
336
+ const noise = Math.abs(inDigits - whole);
337
+ if (!(decimalPlaces(major) <= digits) && noise > Math.min(8 * Number.EPSILON * Math.max(1, Math.abs(inDigits)), 1e-6)) throw new MoneyError(`${major} has more than ${digits} decimal places, the most ${code} allows`, "money_precision", field);
338
+ const stored = whole * (scale / 10 ** digits);
339
+ if (Math.abs(whole) > MAX_UNITS || !Number.isSafeInteger(stored)) throw new MoneyError(`${major} ${code} is too large to store`, "money_invalid", field);
340
+ return stored === 0 ? 0 : stored;
341
+ }
342
+ /** Stored minor amount → major units. */
343
+ function fromStorageAmount(minor, currency) {
344
+ const scale = storageScale(currency);
345
+ if (typeof minor !== "number" || !Number.isFinite(minor)) throw new MoneyError(`Stored amount must be a finite number, got ${String(minor)}`, "money_invalid");
346
+ return minor / scale;
347
+ }
348
+ /** Output schema for a MoneyView (what `moneyView` returns), for capability outputs. */
349
+ const moneyViewSchema = z.object({
350
+ amount: z.number().describe("Amount in normal currency units (major units)."),
351
+ currency: z.string().length(3).describe("ISO 4217 code, upper case."),
352
+ display: z.string().describe("Formatted for people, e.g. \"$19.99\".")
353
+ });
354
+ /**
355
+ * A stored amount as the merchant reads it. Fraction digits are pinned (not left
356
+ * to the runtime's ICU data): ISO digits, widened only to show a stored value
357
+ * that is not a whole minor unit (¥5.5 stays visible).
358
+ */
359
+ function moneyView(minor, currency) {
360
+ const code = normalizeCode(currency);
361
+ const amount = fromStorageAmount(minor, code);
362
+ const digits = fractionDigits(code);
363
+ const scaleDigits = storageScale(code) === 1e3 ? 3 : 2;
364
+ return {
365
+ amount,
366
+ currency: code,
367
+ display: new Intl.NumberFormat("en", {
368
+ style: "currency",
369
+ currency: code,
370
+ minimumFractionDigits: digits,
371
+ maximumFractionDigits: Math.max(digits, scaleDigits)
372
+ }).format(amount)
373
+ };
374
+ }
375
+ //#endregion
376
+ //#region src/json-schema.ts
377
+ /**
378
+ * The exact conversion every manifest uses. Throws for types JSON Schema cannot
379
+ * express. Every money() field gets its marker back, and its unit sentence when
380
+ * a chained check dropped the description (zod 3.25's zod/v4, zod 4.0–4.2). A
381
+ * description that REPLACED the sentence is left alone for defineCapability to reject.
382
+ */
383
+ function toJsonSchema(schema, io) {
384
+ return z.toJSONSchema(schema, {
385
+ target: "draft-2020-12",
386
+ io,
387
+ override: (ctx) => {
388
+ const brand = moneyBrandOf(ctx.zodSchema);
389
+ if (!brand) return;
390
+ const node = ctx.jsonSchema;
391
+ node[MONEY_META_KEY] = { ...brand.marker };
392
+ if (typeof node.description !== "string") node.description = brand.description;
393
+ }
394
+ });
395
+ }
396
+ function isRecord$1(v) {
397
+ return typeof v === "object" && v !== null && !Array.isArray(v);
398
+ }
399
+ /** Object schemas reachable from `node` without entering a property: itself, its combinator branches and $ref targets. */
400
+ function objectBranches(node, root, seen) {
401
+ if (seen.has(node)) return [];
402
+ seen.add(node);
403
+ const out = [node];
404
+ for (const key of [
405
+ "anyOf",
406
+ "oneOf",
407
+ "allOf"
408
+ ]) {
409
+ const branches = node[key];
410
+ if (Array.isArray(branches)) {
411
+ for (const branch of branches) if (isRecord$1(branch)) out.push(...objectBranches(branch, root, seen));
412
+ }
413
+ }
414
+ const ref = node.$ref;
415
+ if (typeof ref === "string" && ref.startsWith("#/$defs/")) {
416
+ const defs = root.$defs;
417
+ const target = isRecord$1(defs) ? defs[ref.slice(8)] : void 0;
418
+ if (isRecord$1(target)) out.push(...objectBranches(target, root, seen));
419
+ }
420
+ return out;
421
+ }
422
+ /**
423
+ * The property schemas a dotted path (`discount.amount_off`) names, across union
424
+ * branches. Empty when no branch has it.
425
+ */
426
+ function resolveField(root, path) {
427
+ let level = [root];
428
+ let found = [];
429
+ for (const name of path.split(".")) {
430
+ found = [];
431
+ for (const node of level) for (const branch of objectBranches(node, root, /* @__PURE__ */ new Set())) {
432
+ const props = branch.properties;
433
+ if (isRecord$1(props) && Object.prototype.hasOwnProperty.call(props, name) && isRecord$1(props[name])) found.push({
434
+ parent: branch,
435
+ name
436
+ });
437
+ }
438
+ level = found.map(({ parent, name: n }) => parent.properties[n]);
439
+ }
440
+ return found;
441
+ }
442
+ /**
443
+ * A copy of `schema` without the hidden fields (`agent.hideFields`), also
444
+ * dropped from `required`. Adapters build the model-facing input with this.
445
+ */
446
+ function omitFields(schema, fields) {
447
+ const copy = JSON.parse(JSON.stringify(schema));
448
+ for (const field of fields) for (const { parent, name } of resolveField(copy, field)) {
449
+ delete parent.properties[name];
450
+ if (Array.isArray(parent.required)) parent.required = parent.required.filter((r) => r !== name);
451
+ }
452
+ return copy;
453
+ }
454
+ //#endregion
455
+ //#region src/guards.ts
456
+ /**
457
+ * Tenant and actor ids come from the call context, never from input. A name is
458
+ * refused when, lower-cased with separators removed, it ends in one of these
459
+ * (`store_id`, `storeId`, `store-environment-id`, `shop_id`, `env_id` …).
460
+ */
461
+ const FORBIDDEN_SUFFIXES = [
462
+ "storeid",
463
+ "tenantid",
464
+ "profileid",
465
+ "environmentid",
466
+ "envid",
467
+ "shopid",
468
+ "merchantid"
469
+ ];
470
+ /** An object field with one of these names and an `id` inside (`store: { id }`) is the same thing spelt differently. */
471
+ const FORBIDDEN_CONTAINERS = /* @__PURE__ */ new Set([
472
+ "store",
473
+ "tenant",
474
+ "profile",
475
+ "environment",
476
+ "env",
477
+ "shop",
478
+ "merchant"
479
+ ]);
480
+ const normalizeName = (name) => name.toLowerCase().replace(/[^a-z0-9]/g, "");
481
+ const isForbiddenName = (name) => {
482
+ const n = normalizeName(name);
483
+ return FORBIDDEN_SUFFIXES.some((suffix) => n.endsWith(suffix));
484
+ };
485
+ const TOOL_NAME = /^[a-zA-Z0-9_-]{1,64}$/;
486
+ const isPositiveInt = (n) => typeof n === "number" && Number.isInteger(n) && n >= 1;
487
+ const isRecord = (v) => typeof v === "object" && v !== null && !Array.isArray(v);
488
+ /** Keywords whose value is a map of schemas, a schema, or a list of schemas. */
489
+ const SCHEMA_MAPS = [
490
+ "properties",
491
+ "patternProperties",
492
+ "$defs",
493
+ "definitions",
494
+ "dependentSchemas"
495
+ ];
496
+ const SCHEMA_VALUES = [
497
+ "items",
498
+ "additionalProperties",
499
+ "propertyNames",
500
+ "contains",
501
+ "if",
502
+ "then",
503
+ "else",
504
+ "unevaluatedProperties",
505
+ "unevaluatedItems"
506
+ ];
507
+ const SCHEMA_LISTS = [
508
+ "anyOf",
509
+ "oneOf",
510
+ "allOf",
511
+ "prefixItems"
512
+ ];
513
+ /** A schema with none of these accepts any value (`z.any()`, `z.unknown()`). */
514
+ const CONSTRAINING = [
515
+ "type",
516
+ "enum",
517
+ "const",
518
+ "$ref",
519
+ "not",
520
+ ...SCHEMA_LISTS
521
+ ];
522
+ /** Visits every subschema (not `not`: what it forbids is not input). `true` is visited as `{}`. */
523
+ function visitSchemas(schema, visit) {
524
+ if (schema === true) {
525
+ visit({});
526
+ return;
527
+ }
528
+ if (!isRecord(schema)) return;
529
+ visit(schema);
530
+ for (const key of SCHEMA_MAPS) {
531
+ const map = schema[key];
532
+ if (isRecord(map)) for (const name of Object.keys(map)) visitSchemas(map[name], visit);
533
+ }
534
+ for (const key of SCHEMA_VALUES) if (key in schema) visitSchemas(schema[key], visit);
535
+ for (const key of SCHEMA_LISTS) {
536
+ const list = schema[key];
537
+ if (Array.isArray(list)) for (const item of list) visitSchemas(item, visit);
538
+ }
539
+ }
540
+ function finiteKeys(node) {
541
+ const names = node.propertyNames;
542
+ if (!isRecord(names) || !Array.isArray(names.enum)) return void 0;
543
+ return names.enum.filter((v) => typeof v === "string");
544
+ }
545
+ function fail(id, message) {
546
+ throw new CapabilityDefinitionError(`${id}: ${message}`);
547
+ }
548
+ function checkFieldName(id, name, schema) {
549
+ if (isForbiddenName(name)) fail(id, `input must not contain "${name}"; store, tenant, profile and environment ids come from the call context`);
550
+ if (FORBIDDEN_CONTAINERS.has(normalizeName(name)) && isRecord(schema) && isRecord(schema.properties) && "id" in schema.properties) fail(id, `input must not contain "${name}.id"; store, tenant, profile and environment ids come from the call context`);
551
+ }
552
+ function checkMoneyNode(id, input, node) {
553
+ const description = node.description;
554
+ if (typeof description !== "string" || !description.includes("Amount in normal currency units as the merchant says it (99 means 99.00; 1.5 means 1.50). Never cents or minor units.")) fail(id, "a money() field lost its unit sentence; pass money({ ..., describe }) instead of .describe()/.meta()");
555
+ const marker = node[MONEY_META_KEY];
556
+ const field = isRecord(marker) ? marker.currencyField : void 0;
557
+ if (isRecord(marker) && marker.currency === "store" && field === void 0) return;
558
+ if (typeof field !== "string") fail(id, `a money() field has a malformed ${MONEY_META_KEY} marker`);
559
+ const found = resolveField(input, field);
560
+ if (found.length === 0) fail(id, `money() currencyField "${field}" is not a field at the input root`);
561
+ for (const { parent, name } of found) {
562
+ const target = parent.properties[name];
563
+ if (!(target.type === "string" || Array.isArray(target.enum) && target.enum.every((v) => typeof v === "string"))) fail(id, `money() currencyField "${field}" must be a string field (an ISO 4217 code)`);
564
+ }
565
+ }
566
+ /**
567
+ * The input must be a plain object (every tool-calling API needs one), every key
568
+ * it accepts must be declared, and no declared key may carry a tenant or actor
569
+ * id at any depth (records, unions, intersections, arrays and tuples included).
570
+ */
571
+ function checkInputSchema(id, input) {
572
+ if (input.type !== "object") fail(id, "input must be an object schema (z.object / z.strictObject)");
573
+ visitSchemas(input, (node) => {
574
+ if (!CONSTRAINING.some((key) => key in node)) fail(id, "input contains a field that accepts any value (z.any / z.unknown); declare its shape");
575
+ const props = node.properties;
576
+ if (isRecord(props)) for (const name of Object.keys(props)) checkFieldName(id, name, props[name]);
577
+ const keys = finiteKeys(node);
578
+ if (keys) for (const name of keys) checkFieldName(id, name, node.additionalProperties);
579
+ if (node.additionalProperties !== void 0 && node.additionalProperties !== false && !keys || "patternProperties" in node || "propertyNames" in node && !keys) fail(id, "input objects must declare their keys (no z.record with open keys, z.looseObject or .passthrough())");
580
+ if ("x-amboras-money" in node) checkMoneyNode(id, input, node);
581
+ });
582
+ }
583
+ function checkHideFields(id, input, hideFields) {
584
+ for (const field of hideFields) {
585
+ const found = resolveField(input, field);
586
+ if (found.length === 0) fail(id, `hideFields names "${field}", which is not an input field`);
587
+ for (const { parent, name } of found) if (Array.isArray(parent.required) && parent.required.includes(name)) fail(id, `hidden field "${field}" is required, so the model could never send a valid call; make it optional or give it a default`);
588
+ }
589
+ }
590
+ function checkPolicy(id, policy) {
591
+ if (!isRole(policy.minRole)) fail(id, "policy.minRole must be owner, manager or staff");
592
+ if (typeof policy.confirm === "object" && !isPositiveInt(policy.confirm.when.recordsAtLeast)) fail(id, "policy.confirm.when.recordsAtLeast must be an integer ≥ 1");
593
+ if (policy.rateLimit && !isPositiveInt(policy.rateLimit.perMinute)) fail(id, "policy.rateLimit.perMinute must be an integer ≥ 1");
594
+ }
595
+ /** Every rule that does not need zod or the run function. Throws CapabilityDefinitionError. */
596
+ function checkEntry(entry) {
597
+ const { id } = entry;
598
+ if (!isCapabilityId(id)) throw new CapabilityDefinitionError(`Invalid capability id "${id}": use <area>.<verb> or <area>.<entity>.<verb>, lowercase a-z and _`);
599
+ if (!isPositiveInt(entry.version)) fail(id, "version must be an integer ≥ 1");
600
+ if (entry.owner !== "medusa" && entry.owner !== "orchestrator") fail(id, "owner must be medusa or orchestrator");
601
+ if (typeof entry.http.path !== "string" || !entry.http.path.startsWith("/")) fail(id, "http.path must start with /");
602
+ checkInputSchema(id, entry.input);
603
+ checkHideFields(id, entry.input, entry.agent.hideFields ?? []);
604
+ checkPolicy(id, entry.policy);
605
+ if (typeof entry.agent.description !== "string" || entry.agent.description.trim() === "") fail(id, "agent.description is required");
606
+ if (entry.agent.toolName !== void 0 && !TOOL_NAME.test(entry.agent.toolName)) fail(id, `agent.toolName must match ${TOOL_NAME}`);
607
+ }
608
+ //#endregion
609
+ //#region src/define.ts
610
+ function convert(schema, io, id) {
611
+ try {
612
+ return toJsonSchema(schema, io);
613
+ } catch (err) {
614
+ throw new CapabilityDefinitionError(`${id}: ${io} schema cannot be expressed as JSON Schema (${String(err)})`);
615
+ }
616
+ }
617
+ function freezeCopy(value) {
618
+ return Object.freeze({ ...value });
619
+ }
620
+ /**
621
+ * Runs every definition rule and returns the input and output JSON Schemas.
622
+ * emitManifest calls it again, so a definition that skipped defineCapability
623
+ * still cannot reach a manifest.
624
+ */
625
+ function checkDefinition(def) {
626
+ const id = def.id;
627
+ if (!isCapabilityId(id)) throw new CapabilityDefinitionError(`Invalid capability id "${id}": use <area>.<verb> or <area>.<entity>.<verb>, lowercase a-z and _`);
628
+ if (def.http.runsOn === "custom" && typeof def.run !== "function") throw new CapabilityDefinitionError(`${id}: runsOn 'custom' needs a run function`);
629
+ const input = convert(def.input, "input", id);
630
+ const output = convert(def.output, "output", id);
631
+ checkEntry({
632
+ ...def,
633
+ input
634
+ });
635
+ return {
636
+ input,
637
+ output
638
+ };
639
+ }
640
+ /**
641
+ * Checks a definition and returns a frozen copy. Throws CapabilityDefinitionError
642
+ * on: a bad id or unknown area; a version that is not a positive integer; an
643
+ * input that is not an object, accepts undeclared keys or any value, or names a
644
+ * store / tenant / profile / environment id at any depth; runsOn 'custom'
645
+ * without run; hideFields naming a missing (or required) field; a schema JSON
646
+ * Schema cannot express; a money() field without its unit sentence or with a
647
+ * currencyField that is not a string field at the input root; an invalid
648
+ * minRole, confirm threshold, rate limit or toolName.
649
+ */
650
+ function defineCapability(def) {
651
+ checkDefinition(def);
652
+ return Object.freeze({
653
+ ...def,
654
+ http: freezeCopy(def.http),
655
+ policy: freezeCopy(def.policy),
656
+ agent: freezeCopy(def.agent)
657
+ });
658
+ }
659
+ //#endregion
660
+ //#region src/sha256.ts
661
+ const K = new Uint32Array([
662
+ 1116352408,
663
+ 1899447441,
664
+ 3049323471,
665
+ 3921009573,
666
+ 961987163,
667
+ 1508970993,
668
+ 2453635748,
669
+ 2870763221,
670
+ 3624381080,
671
+ 310598401,
672
+ 607225278,
673
+ 1426881987,
674
+ 1925078388,
675
+ 2162078206,
676
+ 2614888103,
677
+ 3248222580,
678
+ 3835390401,
679
+ 4022224774,
680
+ 264347078,
681
+ 604807628,
682
+ 770255983,
683
+ 1249150122,
684
+ 1555081692,
685
+ 1996064986,
686
+ 2554220882,
687
+ 2821834349,
688
+ 2952996808,
689
+ 3210313671,
690
+ 3336571891,
691
+ 3584528711,
692
+ 113926993,
693
+ 338241895,
694
+ 666307205,
695
+ 773529912,
696
+ 1294757372,
697
+ 1396182291,
698
+ 1695183700,
699
+ 1986661051,
700
+ 2177026350,
701
+ 2456956037,
702
+ 2730485921,
703
+ 2820302411,
704
+ 3259730800,
705
+ 3345764771,
706
+ 3516065817,
707
+ 3600352804,
708
+ 4094571909,
709
+ 275423344,
710
+ 430227734,
711
+ 506948616,
712
+ 659060556,
713
+ 883997877,
714
+ 958139571,
715
+ 1322822218,
716
+ 1537002063,
717
+ 1747873779,
718
+ 1955562222,
719
+ 2024104815,
720
+ 2227730452,
721
+ 2361852424,
722
+ 2428436474,
723
+ 2756734187,
724
+ 3204031479,
725
+ 3329325298
726
+ ]);
727
+ /** UTF-8 bytes of `text`; a lone surrogate becomes U+FFFD, as `TextEncoder` and Node do. */
728
+ function utf8(text) {
729
+ const out = [];
730
+ for (let i = 0; i < text.length; i++) {
731
+ let c = text.charCodeAt(i);
732
+ if (c >= 55296 && c <= 56319 && i + 1 < text.length) {
733
+ const next = text.charCodeAt(i + 1);
734
+ if (next >= 56320 && next <= 57343) {
735
+ c = 65536 + (c - 55296 << 10) + (next - 56320);
736
+ i++;
737
+ }
738
+ }
739
+ if (c >= 55296 && c <= 57343) c = 65533;
740
+ if (c < 128) out.push(c);
741
+ else if (c < 2048) out.push(192 | c >> 6, 128 | c & 63);
742
+ else if (c < 65536) out.push(224 | c >> 12, 128 | c >> 6 & 63, 128 | c & 63);
743
+ else out.push(240 | c >> 18, 128 | c >> 12 & 63, 128 | c >> 6 & 63, 128 | c & 63);
744
+ }
745
+ return out;
746
+ }
747
+ const rotr = (x, n) => x >>> n | x << 32 - n;
748
+ function sha256Hex(text) {
749
+ const bytes = utf8(text);
750
+ const bitLength = bytes.length * 8;
751
+ bytes.push(128);
752
+ while (bytes.length % 64 !== 56) bytes.push(0);
753
+ const high = Math.floor(bitLength / 4294967296);
754
+ bytes.push(high >>> 24 & 255, high >>> 16 & 255, high >>> 8 & 255, high & 255);
755
+ bytes.push(bitLength >>> 24 & 255, bitLength >>> 16 & 255, bitLength >>> 8 & 255, bitLength & 255);
756
+ const h = new Uint32Array([
757
+ 1779033703,
758
+ 3144134277,
759
+ 1013904242,
760
+ 2773480762,
761
+ 1359893119,
762
+ 2600822924,
763
+ 528734635,
764
+ 1541459225
765
+ ]);
766
+ const w = /* @__PURE__ */ new Uint32Array(64);
767
+ for (let offset = 0; offset < bytes.length; offset += 64) {
768
+ for (let t = 0; t < 16; t++) {
769
+ const j = offset + t * 4;
770
+ w[t] = bytes[j] << 24 | bytes[j + 1] << 16 | bytes[j + 2] << 8 | bytes[j + 3];
771
+ }
772
+ for (let t = 16; t < 64; t++) {
773
+ const s0 = rotr(w[t - 15], 7) ^ rotr(w[t - 15], 18) ^ w[t - 15] >>> 3;
774
+ const s1 = rotr(w[t - 2], 17) ^ rotr(w[t - 2], 19) ^ w[t - 2] >>> 10;
775
+ w[t] = w[t - 16] + s0 + w[t - 7] + s1;
776
+ }
777
+ let [a, b, c, d, e, f, g, hh] = h;
778
+ for (let t = 0; t < 64; t++) {
779
+ const t1 = hh + (rotr(e, 6) ^ rotr(e, 11) ^ rotr(e, 25)) + (e & f ^ ~e & g) + K[t] + w[t] >>> 0;
780
+ const t2 = (rotr(a, 2) ^ rotr(a, 13) ^ rotr(a, 22)) + (a & b ^ a & c ^ b & c) >>> 0;
781
+ hh = g;
782
+ g = f;
783
+ f = e;
784
+ e = d + t1 >>> 0;
785
+ d = c;
786
+ c = b;
787
+ b = a;
788
+ a = t1 + t2 >>> 0;
789
+ }
790
+ h[0] += a;
791
+ h[1] += b;
792
+ h[2] += c;
793
+ h[3] += d;
794
+ h[4] += e;
795
+ h[5] += f;
796
+ h[6] += g;
797
+ h[7] += hh;
798
+ }
799
+ let hex = "";
800
+ for (const word of h) hex += word.toString(16).padStart(8, "0");
801
+ return hex;
802
+ }
803
+ //#endregion
804
+ //#region src/idempotency.ts
805
+ /**
806
+ * 8–100 of `A-Z a-z 0-9 . _ : -`. The underscore is there because the
807
+ * orchestrator's key is `cap.{runId}.{toolCallId}` and model tool call ids
808
+ * contain one (`toolu_…`, `call_…`).
809
+ */
810
+ const IDEMPOTENCY_KEY_PATTERN = /^[A-Za-z0-9_.:-]{8,100}$/;
811
+ /** A non-read capability that is not naturally idempotent needs an `Idempotency-Key`. */
812
+ function requiresIdempotencyKey(policy) {
813
+ return policy.effect !== "read" && !policy.idempotent;
814
+ }
815
+ /** sha256 hex of canonicalJson({ id, input }): same key + different fingerprint = a different request. */
816
+ function requestFingerprint(id, input) {
817
+ return sha256Hex(canonicalJson({
818
+ id,
819
+ input
820
+ }));
821
+ }
822
+ //#endregion
823
+ //#region src/manifest.ts
824
+ const hashOf = (capabilities) => sha256Hex(canonicalJson(capabilities));
825
+ /** Plain JSON with keys sorted at every level, so the committed file is stable text. */
826
+ const sortedJson = (value) => JSON.parse(canonicalJson(value));
827
+ /**
828
+ * Deterministic: entries sorted by id, schemas converted with
829
+ * `{ target: 'draft-2020-12', io }`, no timestamps. Every definition is checked
830
+ * again (the defineCapability rules). Throws on a duplicate id.
831
+ *
832
+ * The JSON Schema text depends on the zod version that emits it (zod 3.25 writes
833
+ * a discriminated union as anyOf, 4.3 as oneOf), so the hash is only comparable
834
+ * between emits on the SAME zod version. Consumers verify a received manifest
835
+ * with parseManifest; they never re-emit it to compare.
836
+ */
837
+ function emitManifest(defs, source) {
838
+ const seen = /* @__PURE__ */ new Set();
839
+ for (const def of defs) {
840
+ if (seen.has(def.id)) throw new CapabilityDefinitionError(`Duplicate capability id ${def.id}`);
841
+ seen.add(def.id);
842
+ }
843
+ const capabilities = [...defs].sort((a, b) => a.id < b.id ? -1 : 1).map((def) => {
844
+ const { input, output } = checkDefinition(def);
845
+ return sortedJson({
846
+ id: def.id,
847
+ version: def.version,
848
+ owner: def.owner,
849
+ http: def.http,
850
+ input,
851
+ output,
852
+ policy: def.policy,
853
+ agent: def.agent
854
+ });
855
+ });
856
+ return {
857
+ schemaVersion: 1,
858
+ source,
859
+ hash: hashOf(capabilities),
860
+ capabilities
861
+ };
862
+ }
863
+ const positiveInt = z.number().int().min(1);
864
+ const jsonSchemaObject = z.record(z.string(), z.unknown());
865
+ const entrySchema = z.looseObject({
866
+ id: z.string().refine(isCapabilityId, "not a valid capability id"),
867
+ version: positiveInt,
868
+ owner: z.enum(["medusa", "orchestrator"]),
869
+ http: z.looseObject({
870
+ method: z.enum([
871
+ "GET",
872
+ "POST",
873
+ "DELETE"
874
+ ]),
875
+ path: z.string(),
876
+ runsOn: z.enum(["custom", "core"])
877
+ }),
878
+ input: jsonSchemaObject,
879
+ output: jsonSchemaObject,
880
+ policy: z.looseObject({
881
+ minRole: z.enum([
882
+ "owner",
883
+ "manager",
884
+ "staff"
885
+ ]),
886
+ planFeature: z.string().optional(),
887
+ effect: z.enum([
888
+ "read",
889
+ "write",
890
+ "destructive"
891
+ ]),
892
+ idempotent: z.boolean(),
893
+ money: z.boolean().optional(),
894
+ confirm: z.union([z.enum(["never", "always"]), z.looseObject({ when: z.looseObject({ recordsAtLeast: positiveInt }) })]),
895
+ rateLimit: z.looseObject({ perMinute: positiveInt }).optional()
896
+ }),
897
+ agent: z.looseObject({
898
+ description: z.string(),
899
+ rules: z.array(z.string()).optional(),
900
+ examples: z.array(z.looseObject({
901
+ input: z.unknown(),
902
+ note: z.string()
903
+ })).optional(),
904
+ hideFields: z.array(z.string()).optional(),
905
+ exposure: z.enum([
906
+ "task-tool",
907
+ "long-tail",
908
+ "hidden"
909
+ ]),
910
+ toolName: z.string().optional()
911
+ })
912
+ });
913
+ const manifestSchema = z.object({
914
+ schemaVersion: z.literal(1),
915
+ source: z.enum(["medusa", "orchestrator"]),
916
+ hash: z.string().regex(/^[0-9a-f]{64}$/),
917
+ capabilities: z.array(entrySchema)
918
+ });
919
+ /**
920
+ * Validates the shape, re-runs the definition rules on every entry (tenant ids,
921
+ * open inputs, money sentence …) and recomputes the hash from the JSON as
922
+ * received, never from schemas. Throws CapabilityManifestError on any failure.
923
+ * The hash proves the file is intact, not who wrote it: trust comes from where
924
+ * the file is read (the committed copy), not from the hash.
925
+ */
926
+ function parseManifest(json) {
927
+ const parsed = manifestSchema.safeParse(json);
928
+ if (!parsed.success) {
929
+ const first = parsed.error.issues[0];
930
+ throw new CapabilityManifestError(`Invalid capability manifest at ${first.path.join(".") || "(root)"}: ${first.message}`);
931
+ }
932
+ const manifest = parsed.data;
933
+ const ids = /* @__PURE__ */ new Set();
934
+ for (const entry of manifest.capabilities) {
935
+ if (ids.has(entry.id)) throw new CapabilityManifestError(`Duplicate capability id ${entry.id} in manifest`);
936
+ ids.add(entry.id);
937
+ try {
938
+ checkEntry(entry);
939
+ } catch (err) {
940
+ throw new CapabilityManifestError(`Invalid capability manifest entry: ${err.message}`);
941
+ }
942
+ }
943
+ const actual = hashOf(json.capabilities);
944
+ if (actual !== manifest.hash) throw new CapabilityManifestError(`Capability manifest hash mismatch: header ${manifest.hash}, content ${actual}`);
945
+ return manifest;
946
+ }
947
+ //#endregion
948
+ //#region src/policy.ts
949
+ function roleCheck(policy, ctx) {
950
+ const { actor } = ctx;
951
+ if (actor.kind === "internal") return null;
952
+ if (actor.role === null) return {
953
+ rule: "role_unknown",
954
+ error: new CapabilityError({
955
+ code: "forbidden",
956
+ rule: "role_unknown",
957
+ message: "Your role in this store could not be read."
958
+ })
959
+ };
960
+ if (ROLE_RANK[actor.role] >= ROLE_RANK[policy.minRole]) return null;
961
+ return {
962
+ rule: "role_insufficient",
963
+ error: new CapabilityError({
964
+ code: "forbidden",
965
+ rule: "role_insufficient",
966
+ message: `This needs the ${policy.minRole} role or higher; you are ${actor.role}.`
967
+ })
968
+ };
969
+ }
970
+ function planCheck(policy, facts) {
971
+ const feature = policy.planFeature;
972
+ if (feature === void 0) return null;
973
+ if (facts.planFeatures === void 0) return {
974
+ rule: "plan_unknown",
975
+ error: new CapabilityError({
976
+ code: "unavailable",
977
+ rule: "plan_unknown",
978
+ message: "The store's plan could not be read."
979
+ })
980
+ };
981
+ if (facts.planFeatures.includes(feature)) return null;
982
+ return {
983
+ rule: "plan_feature_required",
984
+ error: new CapabilityError({
985
+ code: "forbidden",
986
+ rule: "plan_feature_required",
987
+ message: `The store's plan does not include ${feature}.`
988
+ })
989
+ };
990
+ }
991
+ function evaluatePolicy(policy, ctx, facts, enforcement) {
992
+ if (typeof ctx.tenantId !== "string" || ctx.tenantId.trim() === "") return {
993
+ allow: false,
994
+ error: new CapabilityError({
995
+ code: "forbidden",
996
+ rule: "tenant_required",
997
+ message: "No store was resolved for this call."
998
+ })
999
+ };
1000
+ for (const mode of [enforcement.role, enforcement.plan]) if (mode !== "enforce" && mode !== "observe") return {
1001
+ allow: false,
1002
+ error: new CapabilityError({
1003
+ code: "internal",
1004
+ rule: "policy_mode_invalid",
1005
+ message: `Invalid policy enforcement mode ${JSON.stringify(mode)}.`
1006
+ })
1007
+ };
1008
+ const observed = [];
1009
+ const steps = [[roleCheck(policy, ctx), enforcement.role], [planCheck(policy, facts), enforcement.plan]];
1010
+ for (const [check, mode] of steps) {
1011
+ if (check === null) continue;
1012
+ if (mode === "enforce") return {
1013
+ allow: false,
1014
+ error: check.error
1015
+ };
1016
+ observed.push(check.rule);
1017
+ }
1018
+ return {
1019
+ allow: true,
1020
+ observed
1021
+ };
1022
+ }
1023
+ /**
1024
+ * True when the call needs an explicit confirmation before it runs. `records` is
1025
+ * how many records it touches. A threshold policy with no count, or a count that
1026
+ * is not a non-negative integer, requires confirmation (the adapter could not count).
1027
+ */
1028
+ function confirmationRequired(policy, records) {
1029
+ const { confirm } = policy;
1030
+ if (confirm === "never") return false;
1031
+ if (confirm === "always") return true;
1032
+ if (typeof records !== "number" || !Number.isInteger(records) || records < 0) return true;
1033
+ return records >= confirm.when.recordsAtLeast;
1034
+ }
1035
+ //#endregion
1036
+ export { AREAS, CapabilityDefinitionError, CapabilityError, CapabilityManifestError, IDEMPOTENCY_KEY_PATTERN, MONEY_META_KEY, MONEY_UNIT_SENTENCE, MoneyError, ROLE_RANK, areaOf, canonicalJson, confirmationRequired, defineCapability, emitManifest, evaluatePolicy, fractionDigits, fromStorageAmount, fromZodError, httpStatusFor, isCapabilityId, money, moneyView, moneyViewSchema, omitFields, parseManifest, requestFingerprint, requiresIdempotencyKey, storageScale, toStorageAmount };