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