@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 +148 -2
- package/dist/index.cjs +1065 -0
- package/dist/index.d.cts +313 -0
- package/dist/index.d.ts +313 -0
- package/dist/index.js +1036 -0
- package/package.json +56 -5
package/README.md
CHANGED
|
@@ -1,3 +1,149 @@
|
|
|
1
|
-
#
|
|
1
|
+
# `@amboras-dev/capability-kit`
|
|
2
2
|
|
|
3
|
-
|
|
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
|
+
```
|