@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/README.md CHANGED
@@ -1,3 +1,159 @@
1
- # Temporary Holding Version
1
+ # `@amboras-dev/capability-kit`
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
3
+ The capability contract shared by Medusa (which defines and runs capabilities)
4
+ and the orchestrator (which exposes them as AI tools). One definition per
5
+ merchant action: id, zod input/output, policy and agent text. The kit checks it
6
+ once, converts money at one boundary, answers errors in one shape and emits a
7
+ hashed manifest the other repo re-checks on load. CommonJS + ESM + types; Node >= 20 and
8
+ browsers. One runtime dependency, `@amboras-dev/money` (the storage rule, conversion
9
+ and money view; the kit pins its exact version), and the `zod` peer (`^3.25.76 || ^4.0.0`,
10
+ imported only as `zod/v4`; zod 3.25.0 ships without `dist/` and cannot load `zod/v4`).
11
+
12
+ ```ts
13
+ import { defineCapability, money, toStorageAmount, moneyView, emitManifest, parseManifest } from '@amboras-dev/capability-kit'
14
+ import { z } from 'zod/v4'
15
+
16
+ export const createPromotion = defineCapability({
17
+ id: 'promotions.create',
18
+ version: 1,
19
+ owner: 'medusa',
20
+ http: { method: 'POST', path: '/admin/capabilities/promotions.create', runsOn: 'custom' },
21
+ input: z.object({
22
+ code: z.string(),
23
+ currency_code: z.string().length(3),
24
+ amount_off: money({ currencyField: 'currency_code' }),
25
+ min_subtotal: money({ currencyField: 'currency_code', describe: 'Minimum cart subtotal.' }).optional(),
26
+ }),
27
+ output: z.object({ id: z.string(), summary: z.string() }),
28
+ run: async (ctx, input) => { /* toStorageAmount(input.amount_off, input.currency_code) … */ },
29
+ policy: { minRole: 'manager', effect: 'write', idempotent: false, money: true, confirm: 'never' },
30
+ agent: { description: 'Create a discount code …', exposure: 'task-tool', toolName: 'create_promotion' },
31
+ })
32
+
33
+ toStorageAmount(19.99, 'USD') // 1999
34
+ toStorageAmount(5, 'JPY') // 500 (×100 storage for zero-decimal codes too)
35
+ toStorageAmount(1.5, 'KWD') // 1500 (×1000 for the seven three-decimal codes)
36
+ toStorageAmount(5.5, 'JPY') // throws MoneyError: more than 0 decimal places
37
+ moneyView(1999, 'USD') // { amount: 19.99, currency: 'USD', display: '$19.99' }
38
+
39
+ const manifest = emitManifest([createPromotion], 'medusa') // deterministic, hashed
40
+ parseManifest(JSON.parse(text)) // shape + hash check, throws on mismatch
41
+ ```
42
+
43
+ ## API
44
+
45
+ | Area | Exports |
46
+ |---|---|
47
+ | ids | `AREAS`, `Area`, `CapabilityId`, `Role`, `ROLE_RANK`, `isCapabilityId`, `areaOf` |
48
+ | context | `Actor`, `Surface`, `CapabilityCallContext` (filled by adapters, never from input) |
49
+ | definition | `defineCapability`, `CapabilityDefinition`, `CapabilityPolicy`, `CapabilityAgentSpec`, `CapabilityDefinitionError` |
50
+ | money | `money`, `MoneyOptions`, `moneyViewSchema`, `fractionDigits`, `storageScale`, `toStorageAmount`, `fromStorageAmount`, `moneyView`, `MoneyView`, `MoneyError`, `MONEY_UNIT_SENTENCE`, `MONEY_META_KEY` |
51
+ | errors | `CapabilityError`, `CapabilityErrorBody` (with optional `details`), `CapabilityErrorCode`, `httpStatusFor`, `fromZodError`, `FromZodErrorOptions` |
52
+ | policy | `evaluatePolicy`, `PolicyEnforcement`, `PolicyDecision`, `confirmationRequired` |
53
+ | idempotency | `IDEMPOTENCY_KEY_PATTERN`, `requestFingerprint`, `requiresIdempotencyKey` |
54
+ | manifest | `emitManifest`, `parseManifest`, `canonicalJson`, `CapabilityManifest`, `ManifestEntry`, `AnyCapabilityDefinition`, `JsonSchema`, `omitFields`, `CapabilityManifestError` |
55
+
56
+ `defineCapability` throws at boot on:
57
+ - a bad id or unknown area, or a version that is not a positive integer;
58
+ - an input that is not an object schema, that accepts undeclared keys
59
+ (`z.record` with open keys, `z.looseObject`, `.passthrough()`), or that has a
60
+ field accepting any value (`z.any()`, `z.unknown()`);
61
+ - an input field that carries a store, tenant, profile, environment, shop or
62
+ merchant id at any depth (records, unions, intersections, arrays and tuples
63
+ included; any case or separator: `storeId`, `store-id`, `env_id`, `store: { id }`);
64
+ - `runsOn: 'custom'` without `run`; `hideFields` naming a missing or required field;
65
+ - a schema JSON Schema cannot express (`z.date()`, `z.bigint()`, `z.custom()`);
66
+ - a `money()` field whose unit sentence was replaced, or whose `currencyField`
67
+ is not a string field at the input root;
68
+ - an invalid `minRole`, confirm threshold, rate limit or `toolName`.
69
+
70
+ `emitManifest` runs the same checks again on every definition, and
71
+ `parseManifest` runs the JSON-level ones on every received entry.
72
+
73
+ `evaluatePolicy` checks tenant → role → plan. A missing tenant always denies.
74
+ Members need `ROLE_RANK[role] >= ROLE_RANK[minRole]`; a `null` role denies;
75
+ internal actors pass the role check. A `planFeature` with no plan facts denies
76
+ as retryable `unavailable` (fail closed). In `observe` mode role and plan never
77
+ deny; their rule ids come back in `observed`. An enforcement mode that is not
78
+ exactly `'enforce'` or `'observe'` denies (`internal`, rule `policy_mode_invalid`).
79
+ `confirmationRequired` requires confirmation when a threshold policy gets no
80
+ count, or a count that is not a non-negative integer.
81
+
82
+ ## Decisions
83
+
84
+ - **Major units at every boundary.** `money()` is the only money field type; its
85
+ description is the one unit sentence. Storage is Medusa's: ×1000 for KWD, BHD,
86
+ OMR, TND, JOD, IQD, LYD and ×100 for everything else, zero-decimal codes
87
+ included. The rule has one owner, `@amboras-dev/money`: `storageScale`,
88
+ `fractionDigits`, `toStorageAmount`, `fromStorageAmount` and `moneyView` are thin
89
+ wrappers over its `storageScale`, `storageDigits`, `fromMajor`, `toMajorNumber` and
90
+ `/ai` `moneyView`. Codes are validated by the package's `currencyCode`: an unknown
91
+ code (`ZZZ`) throws `currency_invalid`; retired codes (BGN) still read. A stored
92
+ amount must be a safe integer.
93
+ - **Excess decimals throw, float noise rounds.** `19.99 × 100 = 1998.999…` stores
94
+ 1999 and `0.1 + 0.2` stores 30; `19.999 USD` and `5.5 JPY` throw. The digit
95
+ check reads the number's decimal text, so it holds at any magnitude; amounts
96
+ above 2^46 units of the currency's precision (USD 703 billion) refuse as too large.
97
+ - **`MoneyError` is an `invalid_input` `CapabilityError`** (rules `money_precision`,
98
+ `money_invalid`, `currency_invalid`), mapped from the package's `MoneyError` codes
99
+ (`precision` → `money_precision`; `amount_invalid`, `overflow` → `money_invalid`;
100
+ `currency_invalid` → `currency_invalid`). `toStorageAmount(major, currency, field)`
101
+ names the field, so an adapter answers 400 with it. A bad `money()` option is
102
+ a `CapabilityDefinitionError` (boot).
103
+ - **Display digits are pinned**, not read from the runtime's ICU data (HUF/IDR
104
+ differ between Node images). `moneyView` shows the currency's display digits from
105
+ the package registry, widened to keep a finer stored value: HUF 435000 is
106
+ `HUF 4,350` (was `HUF 4,350.00`), HUF 435050 `HUF 4,350.5`, IQD 1500 `IQD 1.5`
107
+ (was `IQD 1.500`); `amount` is always exact.
108
+ - **Chaining on `money()` is safe; `.describe()` is not.** `money()` adds a
109
+ no-op refinement as a brand. zod copies checks into every clone, so the brand
110
+ survives `.positive()`, `.max()`, `.int()`, `.optional()`, `.nullish()`, even
111
+ on zod 3.25 and 4.0–4.2, where the metadata itself is dropped. The kit's JSON
112
+ Schema emission puts the marker (and the sentence, when it went missing) back
113
+ on every branded node. `.describe()` or `.meta({ description })` REPLACES the
114
+ sentence; `defineCapability` rejects that: pass `money({ ..., describe })`.
115
+ Bounds can also go in the options: `money({ currencyField, exclusiveMinimum: 0, max })`.
116
+ - **`currencyField` is resolved from the input root**, wherever the money field
117
+ sits: `discount.amount_off` with `{ currencyField: 'currency_code' }` reads the
118
+ top-level `currency_code` (a dotted path reaches a nested one).
119
+ `defineCapability` rejects a `currencyField` that does not resolve.
120
+ - **Outputs echo `moneyViewSchema`** (`{ amount, currency, display }`), built with
121
+ `moneyView` from the stored row.
122
+ - **`details`** on an error body holds small flat facts the caller can act on
123
+ (`duplicate_code` → `{ existing_id, existing_status }`); never secrets or PII.
124
+ - **Removed field names steer**: `fromZodError(err, { renamed: { value: 'percent_off
125
+ or amount_off (major units)' } })` turns a strict object's unrecognized `value`
126
+ into `"value" was removed: use percent_off or amount_off (major units).`, rule
127
+ `field_renamed`, field = its dotted path.
128
+ - **Pure SHA-256**, not `node:crypto` or Web Crypto: the package stays
129
+ `platform: neutral` and synchronous, so the Next.js admin can call
130
+ `parseManifest` in the browser. Cross-checked against `node:crypto` in tests.
131
+ - **Manifest hash is over the emitted JSON.** zod lines emit different JSON
132
+ Schema for the same definition (3.25 and 4.0 write a discriminated union as
133
+ `anyOf`, 4.3 as `oneOf`), so a hash is only comparable between emits on the same
134
+ zod version. Consumers verify a received manifest with `parseManifest`, which
135
+ hashes the JSON as received; they never re-emit it to compare. Entries parse as
136
+ loose objects, so a field added by a newer kit passes through and stays covered
137
+ by the hash. The hash proves integrity, not origin: trust comes from reading the
138
+ committed file.
139
+ - **zod 3.25 and 4.x.** The suite runs three times: on zod 4.3.6, on zod 4.0.0
140
+ (`vitest.zod40.config.ts`) and on zod 3.25.76's `zod/v4`
141
+ (`vitest.zod3.config.ts`); types are checked against all three. A manifest
142
+ emitted on 3.25 (`test-fixtures/manifest.zod-3.25.json`) parses on every line,
143
+ and each line's hash is pinned. zod 3.25's
144
+ `zod/v4` keeps its metadata registry per module copy (4.x shares it on
145
+ `globalThis`), so a consumer must load the kit and zod in the same module format.
146
+ Medusa (CommonJS) and the admin bundle (ESM) both do.
147
+ - **Error rules from zod refinements**: `.refine(fn, { params: { rule: 'x' } })`
148
+ becomes `body.rule = 'x'` in `fromZodError`. A renamed-field issue leads the
149
+ headline. A plain union reports its closest branch with full paths instead of
150
+ "Invalid input".
151
+
152
+ ## Testing
153
+
154
+ ```bash
155
+ pnpm test # vitest + 100% coverage on zod 4.3.6, then the same suite on 3.25.76 and 4.0.0
156
+ pnpm typecheck # src (no Node types), tests, and src+tests against zod 3.25 and 4.0 types
157
+ # regenerate the 3.25 manifest fixture after a deliberate change:
158
+ UPDATE_FIXTURES=1 npx vitest run --config vitest.zod3.config.ts src/manifest-cross-zod.test.ts
159
+ ```