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