From 5817454f6b84e516eab1ed236398f514a217c079 Mon Sep 17 00:00:00 2001 From: Antony Rizzitelli Date: Sat, 31 Oct 2026 23:01:17 +0100 Subject: [PATCH 1/3] feat(skills): add the verify-interface verification skill --- .agents/skills/verify-interface/SKILL.md | 137 ++++++++++++++ .../verify-interface/features/README.md | 18 ++ .../skills/verify-interface/features/calls.md | 33 ++++ .../verify-interface/features/conformance.md | 35 ++++ .../verify-interface/features/errors.md | 30 +++ .../verify-interface/features/field-types.md | 31 +++ .../verify-interface/features/signatures.md | 31 +++ .../skills/verify-interface/features/smoke.md | 30 +++ .../verify-interface/features/statuses.md | 31 +++ .../features/validation-dates.md | 31 +++ .../features/validation-fields.md | 31 +++ .../features/validation-interval.md | 31 +++ .../features/validation-order.md | 30 +++ .../verify-interface/features/vocabularies.md | 31 +++ .../skills/verify-interface/journey-kit.ts | 126 +++++++++++++ .agents/skills/verify-interface/package.json | 4 + .agents/skills/verify-interface/probes.ts | 52 +++++ .../seeded-breaks/calls.patch | 13 ++ .../seeded-breaks/conformance.patch | 13 ++ .../seeded-breaks/errors.patch | 13 ++ .../seeded-breaks/field-types.patch | 13 ++ .../seeded-breaks/signatures.patch | 14 ++ .../seeded-breaks/smoke.patch | 18 ++ .../seeded-breaks/statuses.patch | 13 ++ .../seeded-breaks/validation-dates.patch | 13 ++ .../seeded-breaks/validation-fields.patch | 13 ++ .../seeded-breaks/validation-interval.patch | 13 ++ .../seeded-breaks/validation-order.patch | 15 ++ .../seeded-breaks/vocabularies.patch | 13 ++ .../seeded-doors/contract.patch | 9 + .../seeded-doors/format.patch | 13 ++ .../seeded-doors/publish.patch | 13 ++ .../skills/verify-interface/suite-scripts.ts | 32 ++++ .agents/skills/verify-interface/tsconfig.json | 14 ++ .../verify-interface/validation-cases.ts | 171 +++++++++++++++++ .../skills/verify-interface/verify.config.ts | 177 ++++++++++++++++++ .../verify-interface/vocabulary-cases.ts | 105 +++++++++++ .claude/skills/verify-interface | 1 + AGENTS.md | 2 + 39 files changed, 1413 insertions(+) create mode 100644 .agents/skills/verify-interface/SKILL.md create mode 100644 .agents/skills/verify-interface/features/README.md create mode 100644 .agents/skills/verify-interface/features/calls.md create mode 100644 .agents/skills/verify-interface/features/conformance.md create mode 100644 .agents/skills/verify-interface/features/errors.md create mode 100644 .agents/skills/verify-interface/features/field-types.md create mode 100644 .agents/skills/verify-interface/features/signatures.md create mode 100644 .agents/skills/verify-interface/features/smoke.md create mode 100644 .agents/skills/verify-interface/features/statuses.md create mode 100644 .agents/skills/verify-interface/features/validation-dates.md create mode 100644 .agents/skills/verify-interface/features/validation-fields.md create mode 100644 .agents/skills/verify-interface/features/validation-interval.md create mode 100644 .agents/skills/verify-interface/features/validation-order.md create mode 100644 .agents/skills/verify-interface/features/vocabularies.md create mode 100644 .agents/skills/verify-interface/journey-kit.ts create mode 100644 .agents/skills/verify-interface/package.json create mode 100644 .agents/skills/verify-interface/probes.ts create mode 100644 .agents/skills/verify-interface/seeded-breaks/calls.patch create mode 100644 .agents/skills/verify-interface/seeded-breaks/conformance.patch create mode 100644 .agents/skills/verify-interface/seeded-breaks/errors.patch create mode 100644 .agents/skills/verify-interface/seeded-breaks/field-types.patch create mode 100644 .agents/skills/verify-interface/seeded-breaks/signatures.patch create mode 100644 .agents/skills/verify-interface/seeded-breaks/smoke.patch create mode 100644 .agents/skills/verify-interface/seeded-breaks/statuses.patch create mode 100644 .agents/skills/verify-interface/seeded-breaks/validation-dates.patch create mode 100644 .agents/skills/verify-interface/seeded-breaks/validation-fields.patch create mode 100644 .agents/skills/verify-interface/seeded-breaks/validation-interval.patch create mode 100644 .agents/skills/verify-interface/seeded-breaks/validation-order.patch create mode 100644 .agents/skills/verify-interface/seeded-breaks/vocabularies.patch create mode 100644 .agents/skills/verify-interface/seeded-doors/contract.patch create mode 100644 .agents/skills/verify-interface/seeded-doors/format.patch create mode 100644 .agents/skills/verify-interface/seeded-doors/publish.patch create mode 100644 .agents/skills/verify-interface/suite-scripts.ts create mode 100644 .agents/skills/verify-interface/tsconfig.json create mode 100644 .agents/skills/verify-interface/validation-cases.ts create mode 100644 .agents/skills/verify-interface/verify.config.ts create mode 100644 .agents/skills/verify-interface/vocabulary-cases.ts create mode 120000 .claude/skills/verify-interface diff --git a/.agents/skills/verify-interface/SKILL.md b/.agents/skills/verify-interface/SKILL.md new file mode 100644 index 0000000..70db01a --- /dev/null +++ b/.agents/skills/verify-interface/SKILL.md @@ -0,0 +1,137 @@ +--- +name: verify-interface +description: "Drive this AntelopeJS interface the way a module author does: pack it, load it in `ajs project dev` next to a stub provider wired through ImplementInterface and a consumer, call every declared function through core, and check the minor-bump rule against the published version. Use to prove a change to the interface works before merge, to verify a PR, or to check that a seeded break is caught." +--- + +# Verify interface + +The user of an interface is a module author: one module implements it, others import it and call its functions through core. Every deterministic step is an `antelopejs-verify` command: you choose which journeys to run and judge what they show. Never replace a command with a shell recipe. When a journey you need does not exist, add it to `verify.config.ts`, name it in its feature file, then run it. + +`antelopejs-sync` writes this file, `package.json` and `tsconfig.json` here, and keeps them in sync. Everything else in `.agents/skills/verify-interface/` is the repository's own layer, which sync never touches. + +## Launch + +```bash +pnpm exec antelopejs-verify new --base origin/main --title "" +``` + +`new` prints the run folder; pass it as `` below. `new` runs the setup (`pnpm run build` by default), then packs the interface with `pnpm pack` and extracts it, so journeys load what npm would publish, `files` and `exports` included. Every `services` entry of `verify.config.ts` then starts in its own Docker container on a free loopback port, as for a module. Every journey gets its own `HOME` and scratch folder, `CI=1`, `AJS_NO_UPDATE_CHECK=1`, `NO_COLOR=1` and a free port from the kernel, so two runs never share an instance. Run it from the repository root after installing dependencies. + +## Doctor + +```bash +pnpm exec antelopejs-verify doctor +``` + +Doctor checks that HEAD has not moved since `new`, `ajs` comes from the repository's `@antelopejs/core`, the build is newer than `src/`, what `new` prepared is there, and no process the run recorded is still alive. Do not drive a run that fails doctor. + +## Drive + +```bash +pnpm exec antelopejs-verify plan --base origin/main +pnpm exec antelopejs-verify journey [journey...] +``` + +`plan` maps the diff to features and lists unmapped surfaces. `journey` runs a feature's journeys, or the ones named. The pack ships these journeys for this kind; a feature file names them next to the repository's own: + +- `every-function`: a fixture project loads the packed interface with a stub provider and a consumer module. The consumer walks the loaded interface, and every function the `.d.ts` files declare is called through core: + - an async proxy answers the stub's echo, and the stub's call log shows the same arguments (the second view); + - a registering proxy registers and unregisters, and the stub logs both; + - an event proxy delivers the emitted arguments to a registered handler; + - a plain function runs the `interface.calls` hook of `verify.config.ts` and must return its `expect`; one only providers call is listed in `interface.providerSide` with why. + A declared function with none of these fails the journey. +- `minor-bump`: reads the declared functions of the packed head and of the published version (`npm pack @`). An added, changed or removed function needs a release of at least the next minor. The release is the head version when it is already bumped, otherwise what changelogen makes of the change title (`new --title`) or the commit subjects since the base. + +## Evidence + +Each step writes `/evidence////` with `command.txt`, `stdout.txt`, `stderr.txt`, `exit-code.txt`, `pid.txt` for background processes, `tree.txt`, `http.json` for every request, and `checks.json`. `result.json` holds a journey's result and `feature.json` the merged results of the feature. Read the evidence before you judge a PASS: a check proves only what it asserts. + +## Cleanup + +```bash +pnpm exec antelopejs-verify clean +``` + +`clean` stops what the run started, by recorded PID and container name, and removes everything but `evidence/` and `run.json`. Run it after every run, failed ones included. + +## The repository's own layer + +- `verify.config.ts` default-exports a `VerifyConfig` from `@antelopejs/tooling-configs/verify`: + - `doors`: one entry per door kind (`data`, `format`, `contract`, `outbound`, `exposure`, `publish`), each `null` when it cannot apply or `{ globs, funnels, containments, why }`. `containments` names the `feature/journey` journeys that contain a Data or Format door. Each must fail with that kind's seeded door applied and pass without it: write the journey so one of its checks reads the state the door corrupts. + - `journeys`: the repository's journeys by id, each `{ title, entryPoint, run(context) }`, using the check helpers the same import exports. + - `env`, `setup` (commands replacing the build), `userFacing` (globs replacing the kind's default) and `formal` (formal checks: `name`, `covers`, `command`, `violation`, and optionally `behaviour` and `baseCommand`). +- `services`: image, port, readiness command and a `reset` command run before every journey, as for a module. `new` starts them in Docker on free ports; a journey reads them with `context.service(name)` and `context.native(...)`. +- `interface.provide(api, services)` runs in the stub provider after the generic stubs attach, for example to hand a client pointed at `services` to `internal.SetClient`. +- `interface.calls[":"]` calls a plain exported function from the consumer and states the value it must return. +- `interface.providerSide[":"]` names a function only providers call, with the reason. +- `features/.md`: frontmatter lists `covers:` (source globs), `docs:` (pages a verdict judges) and `journeys:`, then the sections Sub-features, How to get to it (user POV), Driving it with antelopejs-verify, and Gotchas. `features/smoke.md` covers nothing and runs on every verdict; here it runs `every-function`. +- `seeded-breaks/.patch`: one minimal product bug per feature that its journeys must catch. +- `seeded-doors/.patch`: one change per door kind that applies. A Data or Format door's patch must break what its containment journeys check. + +```bash +pnpm exec antelopejs-verify map +``` + +`map` fails on a user-facing file no feature covers, a feature without journeys, docs or seeded break, an unknown journey, an applicable door kind without its seeded door, and a Data or Format door whose `containments` name no journey a feature registers. `containments` (below) then runs those journeys against the seeded doors. + +## Verdict rules + +1. **Run every feature whose `covers:` globs the diff touches, plus `smoke`.** `plan` prints that list as `run`. +2. **An unmapped surface fails the verdict.** A changed user-facing file no feature covers needs a feature entry, its journeys and its seeded break in the same PR. +3. **Every registered journey of a feature in `run` must have run and passed.** Run whole features. A journey that could not reach the product is NOT VERIFIED, and so is the verdict, whatever else passed: never PASS and never PASS+NOTES. +4. **Docs.** Judge every `docs:` page of a touched feature against what the journeys showed. A stale page fails the verdict. +5. **Formal checks.** `pnpm exec antelopejs-verify formal ` runs every formal check of `verify.config.ts` that covers the diff, or whose own `files` the diff changes, and records it under `evidence/formal//`. For each one it decides, without judgement, whether the diff changed what the covered code does. `emit` (the default) compares the structure of the covered files' JavaScript on the base and on HEAD: node kinds, names and literal values, leaving out types, comments, formatting, parentheses and `export` keywords. Each use of an imported binding stands for its origin (the resolved module and the imported name), so an import retargeted to another module or alias counts as changed; a function declaration refers to the top-level variables and classes of its own file by file and name, so moving it next to a different variable of the same name counts as changed. Exports are keyed by the origin they export, so a re-export or an `export { f as run }` retargeted to another binding counts as changed. Each file must keep its top-level statements and its side-effect imports in order; function declarations, which are hoisted, may move between covered files with their imports. So a type-only edit, a reformat or a moved function declaration counts as unchanged, while reordering statements or moving any other statement counts as changed. `emit` judges behaviour, not the public surface: exports are the api-diff's job. `probe` runs the check's `behaviour.command` with a worktree of each commit and compares what it prints. Then: + - a covered check that did not run, or exits non-zero (a tool error, or a check that no longer matches its record), fails the verdict; + - recorded findings fail the verdict only when the covered behaviour changed; when it did not, the verdict reports them as known findings; + - when the covered behaviour changed, or the diff changed the check's own `files`, `formal` also runs the check on the base (`baseCommand` when set). A rule the base broke counts as fixed only when HEAD reports it passing (`passing`); a rule the base broke that HEAD does not report at all fails the verdict. A change that leaves no violated rule passes. + + The decision never reads the check's output, so a model whose rule names stay the same still fails on a changed behaviour. It is conservative: a local rename or an extracted helper counts as changed, and code outside `covers` is invisible to it. `--all` also runs the checks that do not cover the diff, without gating. +6. **One-way doors.** The section comes right after the result line and is mandatory. `pnpm exec antelopejs-verify doors` prints the `One-way-door:` line of the diff. Every file the path rules flag needs a door row or a false positive with its reason. A Data or Format row names a `containments` journey that passed in this run, and that journey is proven by `pnpm exec antelopejs-verify containments`: it fails with the kind's seeded door applied. A containment that passes with its door contains nothing, so a Data or Format door without one that fails with its door is not contained. +7. **Result.** FAIL if any rule fails. NOT VERIFIED when any journey of a required feature is NOT VERIFIED, for example a SaaS module run without its sandbox keys, however many other journeys passed. PASS+NOTES when everything required ran and passed but the judgement lists something no journey reached or something the owner should look at. PASS otherwise. + +Write your judgement to `/evidence/judgement.json`: + +```json +{ + "doors": { + "none": "why the diff opens no door", + "rows": [], + "falsePositives": [{ "kind": "publish", "location": "package.json", "reason": "only devDependencies changed" }] + }, + "docs": [{ "page": "README.md", "matches": true, "note": "" }], + "notVerified": ["What no journey reached, and why"], + "attention": ["What the owner should look at that the journeys cannot judge"], + "verifier": "the fresh thread that judged this run, not the author" +} +``` + +A door row is `{ "kind", "location", "irreversible", "containment", "containmentJourney" }`. Then render and publish: + +```bash +pnpm exec antelopejs-verify verdict --pr +pnpm exec antelopejs-verify publish --pr +``` + +`verdict` writes `evidence/verdict.md` and exits 1 unless the result is PASS or PASS+NOTES. `publish` commits the evidence to the orphan branch `verify-evidence` under `pr-//` from a temporary worktree, posts the verdict as a PR comment, sets commit status `verify` on the head SHA (`success` for PASS and PASS+NOTES, `error` for NOT VERIFIED, `failure` for FAIL) and reads it back. It prints the error GitHub answers when the token cannot write statuses. A new push voids the status. Post a verdict only from a thread that did not write the change. + +## Trust + +The skill is not Trusted yet. It becomes Trusted when it catches every seeded break, flags every seeded door, fails every Data and Format containment with its door, passes a shadow run of 10 consecutive agent PRs, and its last maintain pass is newer than the last change to the repository or its `@antelopejs` dependencies and at most 30 days old. + +## Seeded breaks and doors + +```bash +pnpm exec antelopejs-verify seeded +pnpm exec antelopejs-verify seeded-doors +pnpm exec antelopejs-verify containments +``` + +`seeded` runs each feature's journeys in a scratch worktree of HEAD without the patch, as the control, and with `seeded-breaks/.patch` applied. It prints `CAUGHT` only when a journey that passes in the control fails one of its checks with the patch. Journeys failing in the control and failures without a failing check do not count. `seeded-doors` prints `FLAGGED` when the path rules flag each patch with its own kind. `containments` runs every Data and Format containment journey in a scratch worktree of HEAD without the door, as the control, and with `seeded-doors/.patch` applied. It prints `CONTAINED` only when the journey passes in the control and fails one of its checks with the door. `MISSED` means it passes with its door, `UNPROVEN` that it fails in the control or ends without a failing check, and `NO-DOOR` that the kind has no seeded door; the command exits 1 on anything but `CONTAINED`. + +## Gotchas + +- A function the consumer awaits forever, such as a getter no provider resolved, fails after 15 seconds with a `Timeout` value. +- `minor-bump` needs the npm registry. Without it the journey is not verified, never passed. +- A containment journey of a Data or Format door must check what that door's seeded patch changes, for example the arguments the stub provider logged. `containments` reports one that still passes with its door as `MISSED`. +- Runs live under the system temp folder (`TMPDIR`), and the shared caches under `~/.cache/antelopejs-verify`. +- A repository that outgrows this skill runs `antelopejs-sync --verify custom` and keeps its own `.agents/skills/verify-/`. diff --git a/.agents/skills/verify-interface/features/README.md b/.agents/skills/verify-interface/features/README.md new file mode 100644 index 0000000..ddca188 --- /dev/null +++ b/.agents/skills/verify-interface/features/README.md @@ -0,0 +1,18 @@ +# Feature map + +One file per feature. `covers:` lists the source globs it owns, `docs:` the pages a verdict judges, `journeys:` the journeys it runs, and `seeded-breaks/.patch` the break its journeys must catch. `pnpm exec antelopejs-verify map` checks that every user-facing file is covered. + +| Feature | What the user does | Journeys | +|---|---|---| +| [smoke](smoke.md) | The fixed journey every verdict runs, whatever the diff touches: pack the interface, load it in `ajs project dev` with a stub provider and a consumer, and call everything it declares. | every-function | +| [calls](calls.md) | A backend starts, reads, lists, cancels and re-methods a subscription through the five `InterfaceFunction`s, each with an idempotency key positionally and an optional trailing `provider`. | calls-through-core | +| [signatures](signatures.md) | The compile-time contract of the calls: which argument comes where, which are optional, what they resolve to. | signatures | +| [field-types](field-types.md) | The data a backend reads and writes: `Subscription`, `SubscriptionRequest`, `SubscriptionEvent` and the `JsonObject` that `vendorData` is limited to. | field-types | +| [validation-fields](validation-fields.md) | `ValidateSubscriptionRequest` enforces the portable request fields so every implementation refuses the same shapes. | validate-fields | +| [validation-order](validation-order.md) | The error names the first rule broken, so the order of the rules is part of the contract. | validate-order | +| [validation-interval](validation-interval.md) | A period is `intervalCount` times `interval`; the validator refuses what is not a calendar unit and a positive whole count. | validate-interval | +| [validation-dates](validation-dates.md) | Trials and billing anchors defer the first charge; they are mutually exclusive, must be finite, and with `now` must be strictly in the future. | validate-dates | +| [statuses](statuses.md) | The four normalised statuses and why a subscription ended; only `canceled` is terminal. | status-vocabulary | +| [vocabularies](vocabularies.md) | The other strings consumers branch on and providers store: intervals, cancellation timings, event types, the metadata key marking subscription charges, and the one-year period cap. | wire-names | +| [errors](errors.md) | What a consumer catches when a call did not happen: not found, invalid request, invalid state, first period declined. | error-classes | +| [conformance](conformance.md) | The provider conformance suite ships in the package so any module that lists the interface in `antelopeJs.implements` runs it. | suite-helpers, suite-defaults, suite-files | diff --git a/.agents/skills/verify-interface/features/calls.md b/.agents/skills/verify-interface/features/calls.md new file mode 100644 index 0000000..2adf7fb --- /dev/null +++ b/.agents/skills/verify-interface/features/calls.md @@ -0,0 +1,33 @@ +--- +covers: + - src/functions.ts + - src/index.ts +docs: + - README.md + - docs/2.subscriptions.md + - docs/3.events.md +journeys: + - calls-through-core +--- + +# Calls + +A backend starts, reads, lists, cancels and re-methods a subscription through the five `InterfaceFunction`s, each with an idempotency key positionally and an optional trailing `provider`. + +## Sub-features + +- `calls-through-core`: `CreateSubscription`, `GetSubscription`, `ListSubscriptions`, `CancelSubscription` and `UpdateSubscriptionPaymentMethod` are called through core by a consumer module with the arguments a backend passes (the request, the idempotency key positionally, the cancellation timing, the optional trailing `provider` both given and left out); the stub provider must receive the same arguments in the same order. + +## How to get to it (user POV) + +- `await CreateSubscription(request, `subscribe-${accountId}`)` and `await CancelSubscription(id, `cancel-now-${id}`, "now")` in a module next to a billing provider. + +## Driving it with antelopejs-verify + +```bash +pnpm exec antelopejs-verify journey "$RUN" calls +``` + +## Gotchas + +- The stub provider echoes its arguments, so this feature proves what reaches a provider, not what a provider answers. Provider behaviour belongs to its conformance suite. diff --git a/.agents/skills/verify-interface/features/conformance.md b/.agents/skills/verify-interface/features/conformance.md new file mode 100644 index 0000000..992008f --- /dev/null +++ b/.agents/skills/verify-interface/features/conformance.md @@ -0,0 +1,35 @@ +--- +covers: + - src/tests/** +docs: + - README.md + - docs/4.implementing.md +journeys: + - suite-helpers + - suite-defaults + - suite-files +--- + +# Conformance + +The provider conformance suite ships in the package so any module that lists the interface in `antelopeJs.implements` runs it. + +## Sub-features + +- `suite-helpers`: the shipped `tests/support` module of the packed interface, loaded by a provider author through the package's own `exports`, reads `ANTELOPE_PAYMENT_TEST_METHOD`, `ANTELOPE_SUBSCRIPTIONS_TEST_METHOD_DECLINES_LATER`, `ANTELOPE_SUBSCRIPTIONS_TEST_EVENTS` and `ANTELOPE_PAYMENT_TEST_CURRENCY` as README.md states, with every variable set. +- `suite-defaults`: the same module with no variable set: no test method, events not observable and the currency `EUR`; epoch seconds are refused as a time, milliseconds accepted. +- `suite-files`: `dist/tests` holds the two test files and the support module, and `package.json` exports `./tests/*` and ships `dist`. + +## How to get to it (user POV) + +- A provider author runs `ajs module test` in their module with the `ANTELOPE_PAYMENT_TEST_*` variables from the README table. + +## Driving it with antelopejs-verify + +```bash +pnpm exec antelopejs-verify journey "$RUN" conformance +``` + +## Gotchas + +- The suite itself needs a real provider, so these journeys cover its helpers and packaging, not a provider's verdict. diff --git a/.agents/skills/verify-interface/features/errors.md b/.agents/skills/verify-interface/features/errors.md new file mode 100644 index 0000000..987d284 --- /dev/null +++ b/.agents/skills/verify-interface/features/errors.md @@ -0,0 +1,30 @@ +--- +covers: + - src/errors.ts +docs: + - docs/2.subscriptions.md +journeys: + - error-classes +--- + +# Errors + +What a consumer catches when a call did not happen: not found, invalid request, invalid state, first period declined. + +## Sub-features + +- `error-classes`: the five error classes, built the way docs/2.subscriptions.md says (`new InvalidSubscriptionStateError(message, status?, providerCode?)`, `SubscriptionPaymentError(payment, providerCode?)`): each has its own `name`, is a `SubscriptionError` and an `Error`, siblings are not each other, the provider code is kept verbatim, the state error carries the status, the payment error carries the declined `Payment` and the message `The first period could not be charged: payment is `, and the not-found message is `No such subscription: `. + +## How to get to it (user POV) + +- `catch (error) { if (error instanceof SubscriptionPaymentError) show(error.payment.failure) }`. + +## Driving it with antelopejs-verify + +```bash +pnpm exec antelopejs-verify journey "$RUN" errors +``` + +## Gotchas + +- A failed renewal is a `past_due` status, not an error; only a declined first period rejects. diff --git a/.agents/skills/verify-interface/features/field-types.md b/.agents/skills/verify-interface/features/field-types.md new file mode 100644 index 0000000..70bbeb4 --- /dev/null +++ b/.agents/skills/verify-interface/features/field-types.md @@ -0,0 +1,31 @@ +--- +covers: + - src/types.ts +docs: + - docs/2.subscriptions.md + - docs/3.events.md +journeys: + - field-types +--- + +# Field types + +The data a backend reads and writes: `Subscription`, `SubscriptionRequest`, `SubscriptionEvent` and the `JsonObject` that `vendorData` is limited to. + +## Sub-features + +- `field-types`: a TypeScript consumer compiling against the packed declarations: the fields of `Subscription`, `SubscriptionRequest` and `SubscriptionEvent` that are required and optional as docs/ state, the literal unions of status, interval, end reason, timing and event type, no `vendorData` in a request, and what `JsonObject` accepts (`undefined` values, nested objects) and refuses (`Date`, `Buffer`, functions, read-only arrays, interface-typed objects). + +## How to get to it (user POV) + +- A TypeScript backend reading `subscription.status`, `subscription.endedAt` and `event.payment`. + +## Driving it with antelopejs-verify + +```bash +pnpm exec antelopejs-verify journey "$RUN" field-types +``` + +## Gotchas + +- Types only: the probe cannot see a runtime value. diff --git a/.agents/skills/verify-interface/features/signatures.md b/.agents/skills/verify-interface/features/signatures.md new file mode 100644 index 0000000..7b8c98f --- /dev/null +++ b/.agents/skills/verify-interface/features/signatures.md @@ -0,0 +1,31 @@ +--- +covers: + - src/functions.ts +docs: + - docs/2.subscriptions.md + - docs/4.implementing.md +journeys: + - signatures +--- + +# Signatures + +The compile-time contract of the calls: which argument comes where, which are optional, what they resolve to. + +## Sub-features + +- `signatures`: a TypeScript consumer compiling against the packed declarations: each call takes the arguments docs/2.subscriptions.md shows in order and returns `Promise` (`Promise` for the list), `ValidateSubscriptionRequest(request, now?)` returns `void`, `isTerminalSubscriptionStatus` takes a status, and an event handler takes one `SubscriptionEvent`. + +## How to get to it (user POV) + +- A TypeScript backend module importing the calls and the event handler type. + +## Driving it with antelopejs-verify + +```bash +pnpm exec antelopejs-verify journey "$RUN" signatures +``` + +## Gotchas + +- The probe compiles with `--strict` against `dist/index.d.ts`; it reads types only and needs no provider. diff --git a/.agents/skills/verify-interface/features/smoke.md b/.agents/skills/verify-interface/features/smoke.md new file mode 100644 index 0000000..fb63e47 --- /dev/null +++ b/.agents/skills/verify-interface/features/smoke.md @@ -0,0 +1,30 @@ +--- +covers: +docs: + - README.md + - docs/1.introduction.md +journeys: + - every-function +--- + +# Smoke + +The fixed journey every verdict runs, whatever the diff touches: pack the interface, load it in `ajs project dev` with a stub provider and a consumer, and call everything it declares. + +## Sub-features + +- `every-function`: a fixture project loads the packed interface with a stub provider and a consumer module; every declared call, the event proxy and the two plain helpers (`ValidateSubscriptionRequest` refuses a zero amount with `InvalidSubscriptionRequestError`, `isTerminalSubscriptionStatus` answers true for `canceled` and false for `past_due`) answer through core, and the stub provider logs each call. + +## How to get to it (user POV) + +- A project with a billing module and a module that imports `@antelopejs/interface-subscriptions`. + +## Driving it with antelopejs-verify + +```bash +pnpm exec antelopejs-verify journey "$RUN" smoke +``` + +## Gotchas + +- The smoke feature covers no glob, so it never marks a file as mapped. diff --git a/.agents/skills/verify-interface/features/statuses.md b/.agents/skills/verify-interface/features/statuses.md new file mode 100644 index 0000000..9fdb79f --- /dev/null +++ b/.agents/skills/verify-interface/features/statuses.md @@ -0,0 +1,31 @@ +--- +covers: + - src/types.ts +docs: + - docs/2.subscriptions.md + - README.md +journeys: + - status-vocabulary +--- + +# Statuses + +The four normalised statuses and why a subscription ended; only `canceled` is terminal. This journey contains the Format door of `src/types.ts`. + +## Sub-features + +- `status-vocabulary`: the packed `SUBSCRIPTION_STATUSES` lists trialing, active, past_due and canceled in lifecycle order, `SUBSCRIPTION_END_REASONS` lists requested and payment_failed, both are frozen so a consumer cannot extend them, and `isTerminalSubscriptionStatus` is true for `canceled` only. + +## How to get to it (user POV) + +- `if (isTerminalSubscriptionStatus(subscription.status)) stopBilling()` and `switch (subscription.status)`. + +## Driving it with antelopejs-verify + +```bash +pnpm exec antelopejs-verify journey "$RUN" statuses +``` + +## Gotchas + +- The strings are stored by providers and consumers, so a rename is a data migration. That is why the door exists. diff --git a/.agents/skills/verify-interface/features/validation-dates.md b/.agents/skills/verify-interface/features/validation-dates.md new file mode 100644 index 0000000..76b1e68 --- /dev/null +++ b/.agents/skills/verify-interface/features/validation-dates.md @@ -0,0 +1,31 @@ +--- +covers: + - src/validation.ts +docs: + - docs/4.implementing.md + - docs/2.subscriptions.md +journeys: + - validate-dates +--- + +# Validation dates + +Trials and billing anchors defer the first charge; they are mutually exclusive, must be finite, and with `now` must be strictly in the future. + +## Sub-features + +- `validate-dates`: with a fixed `now`, `ValidateSubscriptionRequest` accepts a trial end or billing anchor one millisecond ahead and refuses one equal to `now`, behind it, or in epoch seconds; a trial together with an anchor is refused before any date; without `now` a past date passes while `NaN` and infinities are refused; it returns `undefined` on success. + +## How to get to it (user POV) + +- `ValidateSubscriptionRequest({ ...request, trialEnd: Date.now() + 14 * DAY }, Date.now())`. + +## Driving it with antelopejs-verify + +```bash +pnpm exec antelopejs-verify journey "$RUN" validation-dates +``` + +## Gotchas + +- `now` is Unix epoch milliseconds, not seconds. diff --git a/.agents/skills/verify-interface/features/validation-fields.md b/.agents/skills/verify-interface/features/validation-fields.md new file mode 100644 index 0000000..08e61dd --- /dev/null +++ b/.agents/skills/verify-interface/features/validation-fields.md @@ -0,0 +1,31 @@ +--- +covers: + - src/validation.ts +docs: + - docs/4.implementing.md + - docs/2.subscriptions.md +journeys: + - validate-fields +--- + +# Validation fields + +`ValidateSubscriptionRequest` enforces the portable request fields so every implementation refuses the same shapes. + +## Sub-features + +- `validate-fields`: the packed `ValidateSubscriptionRequest` accepts the README request and refuses, with `InvalidSubscriptionRequestError` and the message naming the rule, an empty or missing reference, customer or payment method, an amount that is zero, negative, fractional, `NaN`, a string or missing, and a currency that is not three uppercase letters; it checks the format only, so `ZZZ` is accepted. + +## How to get to it (user POV) + +- `ValidateSubscriptionRequest(request, Date.now())` before a request reaches a provider. + +## Driving it with antelopejs-verify + +```bash +pnpm exec antelopejs-verify journey "$RUN" validation-fields +``` + +## Gotchas + +- The error must be an `InvalidSubscriptionRequestError`, a `SubscriptionError` and an `Error`. diff --git a/.agents/skills/verify-interface/features/validation-interval.md b/.agents/skills/verify-interface/features/validation-interval.md new file mode 100644 index 0000000..a37146d --- /dev/null +++ b/.agents/skills/verify-interface/features/validation-interval.md @@ -0,0 +1,31 @@ +--- +covers: + - src/validation.ts +docs: + - docs/4.implementing.md + - docs/2.subscriptions.md +journeys: + - validate-interval +--- + +# Validation interval + +A period is `intervalCount` times `interval`; the validator refuses what is not a calendar unit and a positive whole count. + +## Sub-features + +- `validate-interval`: `ValidateSubscriptionRequest` accepts `day`, `week`, `month` and `year`, an omitted interval count and any positive whole one, and refuses an unknown or differently cased interval and a zero, negative, fractional, `NaN`, text or `null` count. + +## How to get to it (user POV) + +- `{ interval: "month", intervalCount: 3 }` for a quarterly plan. + +## Driving it with antelopejs-verify + +```bash +pnpm exec antelopejs-verify journey "$RUN" validation-interval +``` + +## Gotchas + +- An omitted `intervalCount` means 1 and is accepted; an explicit `null` is not. diff --git a/.agents/skills/verify-interface/features/validation-order.md b/.agents/skills/verify-interface/features/validation-order.md new file mode 100644 index 0000000..500ebb1 --- /dev/null +++ b/.agents/skills/verify-interface/features/validation-order.md @@ -0,0 +1,30 @@ +--- +covers: + - src/validation.ts +docs: + - docs/4.implementing.md +journeys: + - validate-order +--- + +# Validation order + +The error names the first rule broken, so the order of the rules is part of the contract. + +## Sub-features + +- `validate-order`: a request that breaks every rule is repaired one rule at a time; `ValidateSubscriptionRequest` must name reference, customer, payment method, amount, currency, interval, interval count, then the trial and anchor conflict, then the date, as docs/4.implementing.md lists. + +## How to get to it (user POV) + +- A form that shows the first problem `ValidateSubscriptionRequest` reports. + +## Driving it with antelopejs-verify + +```bash +pnpm exec antelopejs-verify journey "$RUN" validation-order +``` + +## Gotchas + +- Each step of the journey repairs exactly one rule, so a swapped pair shows as one wrong message. diff --git a/.agents/skills/verify-interface/features/vocabularies.md b/.agents/skills/verify-interface/features/vocabularies.md new file mode 100644 index 0000000..b52547d --- /dev/null +++ b/.agents/skills/verify-interface/features/vocabularies.md @@ -0,0 +1,31 @@ +--- +covers: + - src/types.ts +docs: + - docs/2.subscriptions.md + - docs/3.events.md +journeys: + - wire-names +--- + +# Vocabularies + +The other strings consumers branch on and providers store: intervals, cancellation timings, event types, the metadata key marking subscription charges, and the one-year period cap. This journey contains the Format door of `src/types.ts`. + +## Sub-features + +- `wire-names`: the packed `SUBSCRIPTION_INTERVALS`, `CANCELLATION_TIMINGS` and `SUBSCRIPTION_EVENT_TYPES` hold the strings docs/ names in the documented order and are frozen, `SUBSCRIPTION_METADATA_KEY` is `antelopeSubscription` and `MAX_PORTABLE_PERIOD_DAYS` is 366. + +## How to get to it (user POV) + +- `if (event?.payment?.metadata?.[SUBSCRIPTION_METADATA_KEY]) return;` in a payment webhook route. + +## Driving it with antelopejs-verify + +```bash +pnpm exec antelopejs-verify journey "$RUN" vocabularies +``` + +## Gotchas + +- The metadata key is how a payment webhook tells a subscription charge from a one-off payment. diff --git a/.agents/skills/verify-interface/journey-kit.ts b/.agents/skills/verify-interface/journey-kit.ts new file mode 100644 index 0000000..a8d32d5 --- /dev/null +++ b/.agents/skills/verify-interface/journey-kit.ts @@ -0,0 +1,126 @@ +import { equal, interrupted } from "@antelopejs/tooling-configs/verify"; +import type { CallRequest, Journey, JourneyContext } from "@antelopejs/tooling-configs/verify"; + +/** Printed by a packed-interface script: a JSON value the journey compares with the docs. */ +export type Printed = string | number | boolean | null | Printed[] | { [key: string]: Printed }; + +/** The request every validation scenario starts from: the README quick start's monthly EUR 19 plan. */ +export const BASE = `{ reference: "acct_1", customer: "cus_1", paymentMethod: "pm_1", amount: { value: 1900, currency: "EUR" }, interval: "month" }`; + +/** A fixed clock, so a date is in the future or the past whatever day the journey runs. */ +export const NOW = 1_800_000_000_000; + +export const PRELUDE = ` +const c = require(process.argv[1]); +const base = ${BASE}; +const NOW = ${NOW}; +const verdict = (request, now) => { + try { + c.ValidateSubscriptionRequest(request, now); + return "accepted"; + } catch (error) { + return [error.name, error.message, error instanceof c.InvalidSubscriptionRequestError, error instanceof c.SubscriptionError, error instanceof Error]; + } +}; +const refused = (request, now) => { + const result = verdict(request, now); + return Array.isArray(result) ? result[1] : result; +}; +`; + +/** Runs a script against the packed `dist/index.js` and checks the JSON it prints against what the docs state. */ +export function packed(title: string, script: string, expected: Printed, label: string): Journey { + return { + title, + entryPoint: "the packed interface required from a consumer", + async run(context: JourneyContext) { + const loaded = await context.command(label, "node", ["-e", `${PRELUDE}\n${script}`, `${context.repo}/dist/index.js`], { + cwd: context.repo, + }); + context.expect(loaded.folder, [ + { name: `${label} ran`, pass: loaded.exitCode === 0, detail: loaded.stderr.slice(-300) }, + equal(`${label} answers as docs/ states`, loaded.stdout.trim(), JSON.stringify(expected)), + ]); + }, + }; +} + +/** The header of a type probe: `Assert>` fails to compile when A and B are not assignable both ways. */ +export const PROBE_HEADER = ` +type Same = [A] extends [B] ? ([B] extends [A] ? true : false) : false; +type Accepts = [Value] extends [Target] ? true : false; +type Not = T extends true ? false : true; +type Assert = T; +`; + +/** Compiles a probe against the packed declarations in a consumer's strict mode. */ +export function declared(title: string, probe: string, label: string): Journey { + return { + title, + entryPoint: "a TypeScript consumer compiling against the packed declarations", + async run(context) { + const file = `${context.work}/probe.ts`; + context.edit("write the type probe", file, `import type * as Api from "${context.repo}/dist/index";\n${PROBE_HEADER}\n${probe}`); + const compiled = await context.command("compile the type probe", "node", [ + `${context.repo}/node_modules/typescript/bin/tsc`, + "--noEmit", + "--strict", + "--skipLibCheck", + "--target", + "es2022", + "--module", + "commonjs", + "--moduleResolution", + "node", + "--typeRoots", + `${context.repo}/node_modules/@types`, + "--types", + "node", + file, + ]); + context.expect(compiled.folder, [{ name: label, pass: compiled.exitCode === 0, detail: compiled.stdout.slice(0, 600) }]); + }, + }; +} + +/** One consumer call through core: the member to call, its arguments, and the proxy path the stub provider must log. */ +export interface Call { + name: string; + args: NonNullable; +} + +export const REQUEST = { reference: "acct_1", customer: "cus_1", paymentMethod: "pm_1", amount: { value: 1900, currency: "EUR" }, interval: "month" }; + +/** Calls each member of the loaded interface through core with the arguments a backend passes, and checks the stub provider received them in order. */ +export function throughCore(title: string, calls: Call[]): Journey { + return { + title, + entryPoint: "a consumer module calling the packed interface in `ajs project dev`", + async run(context) { + const app = await context.dev("ajs project dev", context.fixture()); + for (const { name, args } of calls) { + const result = await context.call(`call ${name}`, app, { path: [name], args }); + context.expect(result.folder, [ + { name: `${name} resolved through core`, pass: result.ok, detail: JSON.stringify(result.value) }, + equal(`${name} reached the provider with its arguments in order`, result.value, { stub: `.:${name}`, args }), + ]); + } + const stopped = await context.stop("SIGINT", app); + context.expect(stopped.folder, [interrupted(stopped)]); + }, + }; +} + +export function suite(title: string, script: string, expected: Printed, label: string, env: { [name: string]: string } = {}): Journey { + return { + title, + entryPoint: "the conformance suite a provider author imports from the packed interface", + async run(context: JourneyContext) { + const loaded = await context.command(label, "node", ["-e", script], { cwd: context.repo, env }); + context.expect(loaded.folder, [ + { name: `${label} ran`, pass: loaded.exitCode === 0, detail: loaded.stderr.slice(-300) }, + equal(`${label} answers as README.md states`, loaded.stdout.trim(), JSON.stringify(expected)), + ]); + }, + }; +} diff --git a/.agents/skills/verify-interface/package.json b/.agents/skills/verify-interface/package.json new file mode 100644 index 0000000..e986b24 --- /dev/null +++ b/.agents/skills/verify-interface/package.json @@ -0,0 +1,4 @@ +{ + "private": true, + "type": "module" +} diff --git a/.agents/skills/verify-interface/probes.ts b/.agents/skills/verify-interface/probes.ts new file mode 100644 index 0000000..0f11923 --- /dev/null +++ b/.agents/skills/verify-interface/probes.ts @@ -0,0 +1,52 @@ +export const SIGNATURES = ` +type Req = Api.SubscriptionRequest; +type Sub = Api.Subscription; +type _Create = Assert, [request: Req, idempotencyKey: string, provider?: string]>>; +type _CreateOut = Assert, Promise>>; +type _Get = Assert, [id: string, provider?: string]>>; +type _GetOut = Assert, Promise>>; +type _List = Assert, [customer: string, provider?: string]>>; +type _ListOut = Assert, Promise>>; +type _Cancel = Assert, [id: string, idempotencyKey: string, timing: Api.CancellationTiming, provider?: string]>>; +type _CancelOut = Assert, Promise>>; +type _Update = Assert, [id: string, paymentMethod: string, idempotencyKey: string, provider?: string]>>; +type _UpdateOut = Assert, Promise>>; +type _Validate = Assert void>>; +type _Terminal = Assert boolean>>; +type Handler = Parameters[1]; +type _Handler = Assert>; +type _HandlerOut = Assert, void | Promise>>; +type _HandlerIn = Assert, [event: Api.SubscriptionEvent]>>; +`; + +export const FIELD_TYPES = ` +type Sub = Api.Subscription; +type Req = Api.SubscriptionRequest; +type Required_ = Pick extends Required> ? true : false; +type _Required = Assert>; +type _OptionalTrial = Assert>>; +type _OptionalEnded = Assert>>; +type _OptionalReason = Assert>>; +type _OptionalPayment = Assert>>; +type _OptionalMetadata = Assert>>; +type _OptionalVendor = Assert>>; +type _Status = Assert>; +type _Interval = Assert>; +type _Reason = Assert, "requested" | "payment_failed">>; +type _Times = Assert>; +type _Timing = Assert>; +type _RequestRequired = Assert>; +type _RequestOptional = Assert, Required_, Required_, Required_, Required_], [false, false, false, false, false]>>; +type _NoVendorInRequest = Assert>; +type _VendorReadonly = Assert]: NonNullable[K] }, NonNullable> extends true ? false : true>>; +type _Event = Assert>; +type _EventPayment = Assert>>; +type _EventSubscription = Assert>; +type _Json = Assert>; +type _NoDate = Assert>>; +type _NoBuffer = Assert>>; +type _NoFunction = Assert void }>>>; +type _NoReadonlyArray = Assert>>; +interface Declared { name: string } +type _NoInterface = Assert>>; +`; diff --git a/.agents/skills/verify-interface/seeded-breaks/calls.patch b/.agents/skills/verify-interface/seeded-breaks/calls.patch new file mode 100644 index 0000000..a25fe39 --- /dev/null +++ b/.agents/skills/verify-interface/seeded-breaks/calls.patch @@ -0,0 +1,13 @@ +diff --git a/src/functions.ts b/src/functions.ts +index 3e3ee49..88c353a 100644 +--- a/src/functions.ts ++++ b/src/functions.ts +@@ -111,7 +111,7 @@ export const CancelSubscription = + * @throws SubscriptionNotFoundError if no subscription has that identifier + * @throws InvalidSubscriptionStateError if the subscription has ended + */ +-export const UpdateSubscriptionPaymentMethod = ++export const UpdateSubscriptionMethod = + InterfaceFunction< + ( + id: string, diff --git a/.agents/skills/verify-interface/seeded-breaks/conformance.patch b/.agents/skills/verify-interface/seeded-breaks/conformance.patch new file mode 100644 index 0000000..883f0e8 --- /dev/null +++ b/.agents/skills/verify-interface/seeded-breaks/conformance.patch @@ -0,0 +1,13 @@ +diff --git a/src/tests/support.ts b/src/tests/support.ts +index b97fbaa..75cd4f1 100644 +--- a/src/tests/support.ts ++++ b/src/tests/support.ts +@@ -73,7 +73,7 @@ export const EVENTS_OBSERVABLE = Boolean( + process.env.ANTELOPE_SUBSCRIPTIONS_TEST_EVENTS, + ); + +-const CURRENCY = process.env.ANTELOPE_PAYMENT_TEST_CURRENCY || "EUR"; ++const CURRENCY = process.env.ANTELOPE_PAYMENT_TEST_CURRENCY || "USD"; + + export const CHARGEABLE = "requires ANTELOPE_PAYMENT_TEST_METHOD"; + export const DECLINES_LATER = diff --git a/.agents/skills/verify-interface/seeded-breaks/errors.patch b/.agents/skills/verify-interface/seeded-breaks/errors.patch new file mode 100644 index 0000000..85c3178 --- /dev/null +++ b/.agents/skills/verify-interface/seeded-breaks/errors.patch @@ -0,0 +1,13 @@ +diff --git a/src/errors.ts b/src/errors.ts +index c168617..ef13628 100644 +--- a/src/errors.ts ++++ b/src/errors.ts +@@ -27,7 +27,7 @@ export class SubscriptionError extends Error { + */ + export class SubscriptionNotFoundError extends SubscriptionError { + constructor(id: string, providerCode?: string) { +- super(`No such subscription: ${id}`, providerCode); ++ super(`Subscription not found: ${id}`, providerCode); + this.name = "SubscriptionNotFoundError"; + } + } diff --git a/.agents/skills/verify-interface/seeded-breaks/field-types.patch b/.agents/skills/verify-interface/seeded-breaks/field-types.patch new file mode 100644 index 0000000..5e7fd62 --- /dev/null +++ b/.agents/skills/verify-interface/seeded-breaks/field-types.patch @@ -0,0 +1,13 @@ +diff --git a/src/types.ts b/src/types.ts +index 60d7ab8..9e3b2ec 100644 +--- a/src/types.ts ++++ b/src/types.ts +@@ -158,7 +158,7 @@ export interface Subscription { + */ + currentPeriodEnd: number; + /** Unix epoch milliseconds. Present when the subscription began with a trial. */ +- trialEnd?: number; ++ trialEnd: number; + /** The subscription ends at `currentPeriodEnd` instead of renewing. */ + cancelAtPeriodEnd: boolean; + /** Unix epoch milliseconds. Present exactly when `status` is `canceled`. */ diff --git a/.agents/skills/verify-interface/seeded-breaks/signatures.patch b/.agents/skills/verify-interface/seeded-breaks/signatures.patch new file mode 100644 index 0000000..9b31ab6 --- /dev/null +++ b/.agents/skills/verify-interface/seeded-breaks/signatures.patch @@ -0,0 +1,14 @@ +diff --git a/src/functions.ts b/src/functions.ts +index 3e3ee49..1947682 100644 +--- a/src/functions.ts ++++ b/src/functions.ts +@@ -88,8 +88,8 @@ export const CancelSubscription = + InterfaceFunction< + ( + id: string, +- idempotencyKey: string, + timing: CancellationTiming, ++ idempotencyKey: string, + provider?: string, + ) => Promise + >(); diff --git a/.agents/skills/verify-interface/seeded-breaks/smoke.patch b/.agents/skills/verify-interface/seeded-breaks/smoke.patch new file mode 100644 index 0000000..1663643 --- /dev/null +++ b/.agents/skills/verify-interface/seeded-breaks/smoke.patch @@ -0,0 +1,18 @@ +diff --git a/src/functions.ts b/src/functions.ts +index 3e3ee49..8628767 100644 +--- a/src/functions.ts ++++ b/src/functions.ts +@@ -54,8 +54,11 @@ export const CreateSubscription = + * @returns The subscription + * @throws SubscriptionNotFoundError if no subscription has that identifier + */ +-export const GetSubscription = +- InterfaceFunction<(id: string, provider?: string) => Promise>(); ++export const GetSubscription = ( ++ id: string, ++ provider?: string, ++): Promise => ++ Promise.reject(new Error(`No provider for ${id} ${provider}`)); + + /** + * Lists a customer's subscriptions, ended ones included. diff --git a/.agents/skills/verify-interface/seeded-breaks/statuses.patch b/.agents/skills/verify-interface/seeded-breaks/statuses.patch new file mode 100644 index 0000000..399bb90 --- /dev/null +++ b/.agents/skills/verify-interface/seeded-breaks/statuses.patch @@ -0,0 +1,13 @@ +diff --git a/src/types.ts b/src/types.ts +index 60d7ab8..79fc22b 100644 +--- a/src/types.ts ++++ b/src/types.ts +@@ -88,7 +88,7 @@ export type SubscriptionStatus = (typeof SUBSCRIPTION_STATUSES)[number]; + export function isTerminalSubscriptionStatus( + status: SubscriptionStatus, + ): boolean { +- return status === "canceled"; ++ return status === "canceled" || status === "past_due"; + } + + /** diff --git a/.agents/skills/verify-interface/seeded-breaks/validation-dates.patch b/.agents/skills/verify-interface/seeded-breaks/validation-dates.patch new file mode 100644 index 0000000..0801467 --- /dev/null +++ b/.agents/skills/verify-interface/seeded-breaks/validation-dates.patch @@ -0,0 +1,13 @@ +diff --git a/src/validation.ts b/src/validation.ts +index 6ac7fb3..3f34eff 100644 +--- a/src/validation.ts ++++ b/src/validation.ts +@@ -10,7 +10,7 @@ function positiveInteger(value: number | undefined): boolean { + function validTime(value: number | undefined, now?: number): boolean { + return ( + value === undefined || +- (Number.isFinite(value) && (now === undefined || value > now)) ++ (Number.isFinite(value) && (now === undefined || value >= now)) + ); + } + diff --git a/.agents/skills/verify-interface/seeded-breaks/validation-fields.patch b/.agents/skills/verify-interface/seeded-breaks/validation-fields.patch new file mode 100644 index 0000000..5ddac63 --- /dev/null +++ b/.agents/skills/verify-interface/seeded-breaks/validation-fields.patch @@ -0,0 +1,13 @@ +diff --git a/src/validation.ts b/src/validation.ts +index 6ac7fb3..1d0ae72 100644 +--- a/src/validation.ts ++++ b/src/validation.ts +@@ -25,7 +25,7 @@ const RULES: Rule[] = [ + ? undefined + : "amount must be a positive integer of minor units", + (request) => +- /^[A-Z]{3}$/.test(request.amount?.currency ?? "") ++ /^[A-Za-z]{3}$/.test(request.amount?.currency ?? "") + ? undefined + : "amount.currency must be an uppercase ISO-4217 code", + (request) => diff --git a/.agents/skills/verify-interface/seeded-breaks/validation-interval.patch b/.agents/skills/verify-interface/seeded-breaks/validation-interval.patch new file mode 100644 index 0000000..4bd1d07 --- /dev/null +++ b/.agents/skills/verify-interface/seeded-breaks/validation-interval.patch @@ -0,0 +1,13 @@ +diff --git a/src/validation.ts b/src/validation.ts +index 6ac7fb3..5c014fa 100644 +--- a/src/validation.ts ++++ b/src/validation.ts +@@ -34,7 +34,7 @@ const RULES: Rule[] = [ + : `interval must be one of ${SUBSCRIPTION_INTERVALS.join(", ")}`, + (request) => + request.intervalCount === undefined || +- positiveInteger(request.intervalCount) ++ request.intervalCount > 0 + ? undefined + : "intervalCount must be a positive integer", + (request) => diff --git a/.agents/skills/verify-interface/seeded-breaks/validation-order.patch b/.agents/skills/verify-interface/seeded-breaks/validation-order.patch new file mode 100644 index 0000000..75873d2 --- /dev/null +++ b/.agents/skills/verify-interface/seeded-breaks/validation-order.patch @@ -0,0 +1,15 @@ +diff --git a/src/validation.ts b/src/validation.ts +index 6ac7fb3..012a3b4 100644 +--- a/src/validation.ts ++++ b/src/validation.ts +@@ -15,9 +15,9 @@ function validTime(value: number | undefined, now?: number): boolean { + } + + const RULES: Rule[] = [ ++ (request) => (request.customer ? undefined : "customer is required"), + (request) => + request.reference ? undefined : "reference must be a non-empty string", +- (request) => (request.customer ? undefined : "customer is required"), + (request) => + request.paymentMethod ? undefined : "paymentMethod is required", + (request) => diff --git a/.agents/skills/verify-interface/seeded-breaks/vocabularies.patch b/.agents/skills/verify-interface/seeded-breaks/vocabularies.patch new file mode 100644 index 0000000..79dc38c --- /dev/null +++ b/.agents/skills/verify-interface/seeded-breaks/vocabularies.patch @@ -0,0 +1,13 @@ +diff --git a/src/types.ts b/src/types.ts +index 60d7ab8..1ed43c2 100644 +--- a/src/types.ts ++++ b/src/types.ts +@@ -50,7 +50,7 @@ export type SubscriptionInterval = (typeof SUBSCRIPTION_INTERVALS)[number]; + * Providers cap the period differently. A request for a longer one may be + * refused with {@link InvalidSubscriptionRequestError}. + */ +-export const MAX_PORTABLE_PERIOD_DAYS = 366; ++export const MAX_PORTABLE_PERIOD_DAYS = 31; + + /** + * Every value {@link SubscriptionStatus} can take, in lifecycle order. diff --git a/.agents/skills/verify-interface/seeded-doors/contract.patch b/.agents/skills/verify-interface/seeded-doors/contract.patch new file mode 100644 index 0000000..f991702 --- /dev/null +++ b/.agents/skills/verify-interface/seeded-doors/contract.patch @@ -0,0 +1,9 @@ +diff --git a/src/index.ts b/src/index.ts +index c8fedcc..373e570 100644 +--- a/src/index.ts ++++ b/src/index.ts +@@ -1,4 +1,3 @@ + export * from "./errors"; + export * from "./functions"; + export * from "./types"; +-export * from "./validation"; diff --git a/.agents/skills/verify-interface/seeded-doors/format.patch b/.agents/skills/verify-interface/seeded-doors/format.patch new file mode 100644 index 0000000..ca9287d --- /dev/null +++ b/.agents/skills/verify-interface/seeded-doors/format.patch @@ -0,0 +1,13 @@ +diff --git a/src/types.ts b/src/types.ts +index 60d7ab8..6ee494c 100644 +--- a/src/types.ts ++++ b/src/types.ts +@@ -58,7 +58,7 @@ export const MAX_PORTABLE_PERIOD_DAYS = 366; + export const SUBSCRIPTION_STATUSES = Object.freeze([ + "trialing", + "active", +- "past_due", ++ "pastdue", + "canceled", + ] as const); + diff --git a/.agents/skills/verify-interface/seeded-doors/publish.patch b/.agents/skills/verify-interface/seeded-doors/publish.patch new file mode 100644 index 0000000..32e4fcd --- /dev/null +++ b/.agents/skills/verify-interface/seeded-doors/publish.patch @@ -0,0 +1,13 @@ +diff --git a/package.json b/package.json +index 19f16e9..1d084cc 100644 +--- a/package.json ++++ b/package.json +@@ -43,7 +43,7 @@ + "./package.json": "./package.json" + }, + "publishConfig": { +- "access": "public", ++ "access": "restricted", + "provenance": true + }, + "scripts": { diff --git a/.agents/skills/verify-interface/suite-scripts.ts b/.agents/skills/verify-interface/suite-scripts.ts new file mode 100644 index 0000000..6fdd3db --- /dev/null +++ b/.agents/skills/verify-interface/suite-scripts.ts @@ -0,0 +1,32 @@ +export const CONFORMANCE_SCRIPT = ` +const own = (name) => require(require.resolve(name, { paths: [process.cwd()] })); +const support = own("@antelopejs/interface-subscriptions/tests/support"); +const seconds = (() => { try { support.assertEpochMillis(Math.floor(Date.now() / 1000), "when"); return "accepted"; } catch (error) { return error.name; } })(); +const millis = (() => { try { support.assertEpochMillis(Date.now(), "when"); return "accepted"; } catch (error) { return error.name; } })(); +const whole = support.inWholeSeconds(60_000) % 1000; +console.log(JSON.stringify({ + method: support.TEST_METHOD ?? null, + declines: support.TEST_METHOD_DECLINES_LATER ?? null, + events: support.EVENTS_OBSERVABLE, + currency: support.amount(5).currency, + value: support.amount(5).value, + absent: support.ABSENT_ID.startsWith("antelope-absent-"), + seconds, + millis, + whole, + day: support.DAY_MS, + trialDays: support.TRIAL_DAYS, + keyDiffers: support.key() !== support.key(), + referenceDiffers: support.reference() !== support.reference(), +})); +`; + +export const CONFORMANCE_FILES = ` +const fs = require("node:fs"); +const path = require("node:path"); +const dist = path.join(process.cwd(), "dist", "tests"); +const files = fs.readdirSync(dist).filter((name) => name.endsWith(".js")).sort(); +const manifest = require(path.join(process.cwd(), "package.json")); +console.log(JSON.stringify({ files, exported: manifest.exports["./tests/*"], shipped: manifest.files })); +`; + diff --git a/.agents/skills/verify-interface/tsconfig.json b/.agents/skills/verify-interface/tsconfig.json new file mode 100644 index 0000000..8d4c934 --- /dev/null +++ b/.agents/skills/verify-interface/tsconfig.json @@ -0,0 +1,14 @@ +{ + "extends": "../../../tsconfig.json", + "compilerOptions": { + "rootDir": "../../..", + "module": "nodenext", + "moduleResolution": "nodenext", + "allowImportingTsExtensions": true, + "composite": false, + "noEmit": true + }, + "include": [ + "**/*.ts" + ] +} diff --git a/.agents/skills/verify-interface/validation-cases.ts b/.agents/skills/verify-interface/validation-cases.ts new file mode 100644 index 0000000..d9e0ed3 --- /dev/null +++ b/.agents/skills/verify-interface/validation-cases.ts @@ -0,0 +1,171 @@ +import type { Printed } from "./journey-kit.ts"; + +export const FIELD_CASES = ` +const cases = { + base: verdict(base), + referenceEmpty: refused({ ...base, reference: "" }), + referenceMissing: refused({ ...base, reference: undefined }), + customerEmpty: refused({ ...base, customer: "" }), + methodEmpty: refused({ ...base, paymentMethod: "" }), + amountZero: refused({ ...base, amount: { value: 0, currency: "EUR" } }), + amountNegative: refused({ ...base, amount: { value: -5, currency: "EUR" } }), + amountFraction: refused({ ...base, amount: { value: 19.5, currency: "EUR" } }), + amountNaN: refused({ ...base, amount: { value: NaN, currency: "EUR" } }), + amountText: refused({ ...base, amount: { value: "1900", currency: "EUR" } }), + amountMissing: refused({ ...base, amount: undefined }), + currencyLower: refused({ ...base, amount: { value: 1900, currency: "eur" } }), + currencyLong: refused({ ...base, amount: { value: 1900, currency: "EURO" } }), + currencyShort: refused({ ...base, amount: { value: 1900, currency: "EU" } }), + currencyEmpty: refused({ ...base, amount: { value: 1900, currency: "" } }), + currencyUnlisted: refused({ ...base, amount: { value: 1, currency: "ZZZ" } }), + smallestAmount: refused({ ...base, amount: { value: 1, currency: "JPY" } }), + errorKind: verdict({ ...base, reference: "" }), +}; +console.log(JSON.stringify(cases)); +`; + +export const FIELD_EXPECTED: Printed = { + base: "accepted", + referenceEmpty: "reference must be a non-empty string", + referenceMissing: "reference must be a non-empty string", + customerEmpty: "customer is required", + methodEmpty: "paymentMethod is required", + amountZero: "amount must be a positive integer of minor units", + amountNegative: "amount must be a positive integer of minor units", + amountFraction: "amount must be a positive integer of minor units", + amountNaN: "amount must be a positive integer of minor units", + amountText: "amount must be a positive integer of minor units", + amountMissing: "amount must be a positive integer of minor units", + currencyLower: "amount.currency must be an uppercase ISO-4217 code", + currencyLong: "amount.currency must be an uppercase ISO-4217 code", + currencyShort: "amount.currency must be an uppercase ISO-4217 code", + currencyEmpty: "amount.currency must be an uppercase ISO-4217 code", + currencyUnlisted: "accepted", + smallestAmount: "accepted", + errorKind: ["InvalidSubscriptionRequestError", "reference must be a non-empty string", true, true, true], +}; + +export const ORDER_CASES = ` +const broken = { reference: "", customer: "", paymentMethod: "", amount: { value: 0, currency: "x" }, interval: "x", intervalCount: 0, trialEnd: 1, billingAnchor: 2 }; +const order = []; +let request = broken; +for (const key of ["reference", "customer", "paymentMethod", "amount", "currency", "interval", "intervalCount", "trialEnd"]) { + order.push(refused(request, NOW)); + if (key === "reference") request = { ...request, reference: "r" }; + if (key === "customer") request = { ...request, customer: "c" }; + if (key === "paymentMethod") request = { ...request, paymentMethod: "m" }; + if (key === "amount") request = { ...request, amount: { value: 1, currency: "x" } }; + if (key === "currency") request = { ...request, amount: { value: 1, currency: "EUR" } }; + if (key === "interval") request = { ...request, interval: "day" }; + if (key === "intervalCount") request = { ...request, intervalCount: 1 }; + if (key === "trialEnd") request = { ...request, billingAnchor: undefined }; +} +order.push(refused(request, NOW)); +console.log(JSON.stringify(order)); +`; + +export const ORDER_EXPECTED: Printed = [ + "reference must be a non-empty string", + "customer is required", + "paymentMethod is required", + "amount must be a positive integer of minor units", + "amount.currency must be an uppercase ISO-4217 code", + "interval must be one of day, week, month, year", + "intervalCount must be a positive integer", + "trialEnd and billingAnchor are mutually exclusive", + "trialEnd must be a finite timestamp in the future", + "trialEnd must be a finite timestamp in the future", +]; + +export const INTERVAL_CASES = ` +const cases = { + day: refused({ ...base, interval: "day" }), + week: refused({ ...base, interval: "week" }), + month: refused({ ...base, interval: "month" }), + year: refused({ ...base, interval: "year" }), + fortnight: refused({ ...base, interval: "fortnight" }), + capitalised: refused({ ...base, interval: "Month" }), + empty: refused({ ...base, interval: "" }), + missing: refused({ ...base, interval: undefined }), + countOmitted: refused({ ...base, intervalCount: undefined }), + countOne: refused({ ...base, intervalCount: 1 }), + countTwelve: refused({ ...base, intervalCount: 12 }), + countZero: refused({ ...base, intervalCount: 0 }), + countNegative: refused({ ...base, intervalCount: -1 }), + countFraction: refused({ ...base, intervalCount: 1.5 }), + countNaN: refused({ ...base, intervalCount: NaN }), + countText: refused({ ...base, intervalCount: "2" }), + countNull: refused({ ...base, intervalCount: null }), + vocabulary: [...c.SUBSCRIPTION_INTERVALS], +}; +console.log(JSON.stringify(cases)); +`; + +export const INTERVAL_EXPECTED: Printed = { + day: "accepted", + week: "accepted", + month: "accepted", + year: "accepted", + fortnight: "interval must be one of day, week, month, year", + capitalised: "interval must be one of day, week, month, year", + empty: "interval must be one of day, week, month, year", + missing: "interval must be one of day, week, month, year", + countOmitted: "accepted", + countOne: "accepted", + countTwelve: "accepted", + countZero: "intervalCount must be a positive integer", + countNegative: "intervalCount must be a positive integer", + countFraction: "intervalCount must be a positive integer", + countNaN: "intervalCount must be a positive integer", + countText: "intervalCount must be a positive integer", + countNull: "intervalCount must be a positive integer", + vocabulary: ["day", "week", "month", "year"], +}; + +export const DATE_CASES = ` +const later = NOW + 86_400_000; +const cases = { + trialFuture: refused({ ...base, trialEnd: later }, NOW), + trialNow: refused({ ...base, trialEnd: NOW }, NOW), + trialOneMsLater: refused({ ...base, trialEnd: NOW + 1 }, NOW), + trialOneMsEarlier: refused({ ...base, trialEnd: NOW - 1 }, NOW), + anchorFuture: refused({ ...base, billingAnchor: later }, NOW), + anchorNow: refused({ ...base, billingAnchor: NOW }, NOW), + anchorPast: refused({ ...base, billingAnchor: NOW - 86_400_000 }, NOW), + trialInSeconds: refused({ ...base, trialEnd: Math.floor(NOW / 1000) + 86_400 }, NOW), + bothFuture: refused({ ...base, trialEnd: later, billingAnchor: later }, NOW), + bothWithoutClock: refused({ ...base, trialEnd: later, billingAnchor: later }), + bothPast: refused({ ...base, trialEnd: 1, billingAnchor: 2 }, NOW), + pastWithoutClock: refused({ ...base, trialEnd: 1 }), + anchorPastWithoutClock: refused({ ...base, billingAnchor: 1 }), + trialNaN: refused({ ...base, trialEnd: NaN }), + trialInfinite: refused({ ...base, trialEnd: Infinity }), + anchorNaN: refused({ ...base, billingAnchor: NaN }, NOW), + anchorInfinite: refused({ ...base, billingAnchor: -Infinity }), + neither: refused(base, NOW), + returned: c.ValidateSubscriptionRequest(base, NOW) === undefined, +}; +console.log(JSON.stringify(cases)); +`; + +export const DATE_EXPECTED: Printed = { + trialFuture: "accepted", + trialNow: "trialEnd must be a finite timestamp in the future", + trialOneMsLater: "accepted", + trialOneMsEarlier: "trialEnd must be a finite timestamp in the future", + anchorFuture: "accepted", + anchorNow: "billingAnchor must be a finite timestamp in the future", + anchorPast: "billingAnchor must be a finite timestamp in the future", + trialInSeconds: "trialEnd must be a finite timestamp in the future", + bothFuture: "trialEnd and billingAnchor are mutually exclusive", + bothWithoutClock: "trialEnd and billingAnchor are mutually exclusive", + bothPast: "trialEnd and billingAnchor are mutually exclusive", + pastWithoutClock: "accepted", + anchorPastWithoutClock: "accepted", + trialNaN: "trialEnd must be a finite timestamp in the future", + trialInfinite: "trialEnd must be a finite timestamp in the future", + anchorNaN: "billingAnchor must be a finite timestamp in the future", + anchorInfinite: "billingAnchor must be a finite timestamp in the future", + neither: "accepted", + returned: true, +}; diff --git a/.agents/skills/verify-interface/verify.config.ts b/.agents/skills/verify-interface/verify.config.ts new file mode 100644 index 0000000..a882cb0 --- /dev/null +++ b/.agents/skills/verify-interface/verify.config.ts @@ -0,0 +1,177 @@ +import type { InterfaceHooks, VerifyConfig } from "@antelopejs/tooling-configs/verify"; + +import type * as SubscriptionsInterface from "../../../src/index.ts"; +import { declared, packed, REQUEST, suite, throughCore } from "./journey-kit.ts"; +import { DATE_CASES, DATE_EXPECTED, FIELD_CASES, FIELD_EXPECTED, INTERVAL_CASES, INTERVAL_EXPECTED, ORDER_CASES, ORDER_EXPECTED } from "./validation-cases.ts"; +import { ERROR_EXPECTED, ERROR_SCRIPT, STATUS_EXPECTED, STATUS_SCRIPT, VOCABULARY_EXPECTED, VOCABULARY_SCRIPT } from "./vocabulary-cases.ts"; +import { FIELD_TYPES, SIGNATURES } from "./probes.ts"; +import { CONFORMANCE_FILES, CONFORMANCE_SCRIPT } from "./suite-scripts.ts"; + +const CALLS: NonNullable["calls"]> = { + ".:ValidateSubscriptionRequest": { + async call(api) { + try { + api.ValidateSubscriptionRequest({ + reference: "acct_1", + customer: "cus_1", + paymentMethod: "pm_1", + amount: { value: 0, currency: "EUR" }, + interval: "month", + }); + return "accepted"; + } catch (error) { + return error instanceof Error ? error.name : "thrown"; + } + }, + expect: "InvalidSubscriptionRequestError", + }, + ".:isTerminalSubscriptionStatus": { + call: (api) => Promise.resolve([api.isTerminalSubscriptionStatus("canceled"), api.isTerminalSubscriptionStatus("past_due")]), + expect: [true, false], + }, +}; + +const config: VerifyConfig = { + doors: { + data: null, + format: { + globs: ["src/types.ts"], + funnels: [], + containments: ["statuses/status-vocabulary", "vocabularies/wire-names"], + why: "the status, end reason, interval, timing and event strings and the antelopeSubscription metadata key are what providers store in their subscription rows and payment metadata, and what consumers branch on", + }, + contract: { + globs: ["src/index.ts", "src/functions.ts", "package.json"], + funnels: [], + containments: [], + why: "the five calls, the event proxy, the validator, the error classes and the vocabularies are what every billing module and provider imports", + }, + outbound: null, + exposure: null, + publish: { + globs: ["package.json", ".release-it.json", "changelogen.config.json", ".github/workflows/release.yml"], + funnels: [], + containments: [], + why: "the package name and release workflow claim @antelopejs/interface-subscriptions on npm", + }, + }, + interface: { + calls: CALLS, + }, + journeys: { + "calls-through-core": throughCore("the five calls reach the provider with their arguments in order", [ + { name: "CreateSubscription", args: [REQUEST, "subscribe-acct_1"] }, + { name: "CreateSubscription", args: [REQUEST, "subscribe-acct_1", "eu-account"] }, + { name: "GetSubscription", args: ["sub_1"] }, + { name: "GetSubscription", args: ["sub_1", "eu-account"] }, + { name: "ListSubscriptions", args: ["cus_1"] }, + { name: "ListSubscriptions", args: ["cus_1", "eu-account"] }, + { name: "CancelSubscription", args: ["sub_1", "cancel-period-end-sub_1", "period_end"] }, + { name: "CancelSubscription", args: ["sub_1", "cancel-now-sub_1", "now", "eu-account"] }, + { name: "UpdateSubscriptionPaymentMethod", args: ["sub_1", "pm_2", "switch-sub_1"] }, + { name: "UpdateSubscriptionPaymentMethod", args: ["sub_1", "pm_2", "switch-sub_1", "eu-account"] }, + ]), + signatures: declared("every call and the validator have the documented signatures", SIGNATURES, "every export has the documented signature"), + "field-types": declared("Subscription, SubscriptionRequest, SubscriptionEvent and JsonObject have the documented shapes", FIELD_TYPES, "every type has the documented shape"), + "validate-fields": packed( + "ValidateSubscriptionRequest refuses an empty reference, customer or method, a non-positive amount and a malformed currency", + FIELD_CASES, + FIELD_EXPECTED, + "field rules", + ), + "validate-order": packed( + "ValidateSubscriptionRequest names the first rule broken, in the order docs/4.implementing.md lists", + ORDER_CASES, + ORDER_EXPECTED, + "rule order", + ), + "validate-interval": packed( + "ValidateSubscriptionRequest accepts day, week, month and year and a positive whole interval count", + INTERVAL_CASES, + INTERVAL_EXPECTED, + "interval rules", + ), + "validate-dates": packed( + "ValidateSubscriptionRequest refuses both a trial and an anchor, and past or non-finite dates", + DATE_CASES, + DATE_EXPECTED, + "date rules", + ), + "status-vocabulary": packed( + "the four statuses and two end reasons are frozen and only canceled is terminal", + STATUS_SCRIPT, + STATUS_EXPECTED, + "status vocabulary", + ), + "wire-names": packed( + "intervals, timings, event types and the metadata key are the strings docs/ names", + VOCABULARY_SCRIPT, + VOCABULARY_EXPECTED, + "wire names", + ), + "error-classes": packed( + "the error classes carry the names, messages, codes, status and declined payment docs/2.subscriptions.md lists", + ERROR_SCRIPT, + ERROR_EXPECTED, + "error classes", + ), + "suite-helpers": suite( + "the shipped conformance helpers read the environment variables README.md lists", + CONFORMANCE_SCRIPT, + { + method: "tok_ok", + declines: "tok_later", + events: true, + currency: "JPY", + value: 5, + absent: true, + seconds: "AssertionError", + millis: "accepted", + whole: 0, + day: 86_400_000, + trialDays: 14, + keyDiffers: true, + referenceDiffers: true, + }, + "suite helpers with every variable set", + { + ANTELOPE_PAYMENT_TEST_METHOD: "tok_ok", + ANTELOPE_SUBSCRIPTIONS_TEST_METHOD_DECLINES_LATER: "tok_later", + ANTELOPE_SUBSCRIPTIONS_TEST_EVENTS: "1", + ANTELOPE_PAYMENT_TEST_CURRENCY: "JPY", + }, + ), + "suite-defaults": suite( + "without the variables the suite defaults to EUR and skips what needs a provider token", + CONFORMANCE_SCRIPT, + { + method: null, + declines: null, + events: false, + currency: "EUR", + value: 5, + absent: true, + seconds: "AssertionError", + millis: "accepted", + whole: 0, + day: 86_400_000, + trialDays: 14, + keyDiffers: true, + referenceDiffers: true, + }, + "suite helpers with no variable set", + ), + "suite-files": suite( + "the package ships dist/tests and exports it as ./tests/*", + CONFORMANCE_FILES, + { + files: ["subscriptions-changes.test.js", "subscriptions.test.js", "support.js"], + exported: { types: "./dist/tests/*.d.ts", default: "./dist/tests/*.js" }, + shipped: ["dist", "docs", "skills"], + }, + "shipped suite files", + ), + }, +}; + +export default config; diff --git a/.agents/skills/verify-interface/vocabulary-cases.ts b/.agents/skills/verify-interface/vocabulary-cases.ts new file mode 100644 index 0000000..5e6e58f --- /dev/null +++ b/.agents/skills/verify-interface/vocabulary-cases.ts @@ -0,0 +1,105 @@ +import type { Printed } from "./journey-kit.ts"; + +export const STATUS_SCRIPT = ` +const mutation = (list) => { try { list.push("invented"); return "pushed"; } catch (error) { return error.name; } }; +console.log(JSON.stringify({ + statuses: [...c.SUBSCRIPTION_STATUSES], + terminal: c.SUBSCRIPTION_STATUSES.map((status) => [status, c.isTerminalSubscriptionStatus(status)]), + reasons: [...c.SUBSCRIPTION_END_REASONS], + frozen: [Object.isFrozen(c.SUBSCRIPTION_STATUSES), Object.isFrozen(c.SUBSCRIPTION_END_REASONS)], + push: [mutation(c.SUBSCRIPTION_STATUSES), mutation(c.SUBSCRIPTION_END_REASONS)], + afterPush: [...c.SUBSCRIPTION_STATUSES].length, + unknown: c.isTerminalSubscriptionStatus("deleted"), +})); +`; + +export const STATUS_EXPECTED: Printed = { + statuses: ["trialing", "active", "past_due", "canceled"], + terminal: [ + ["trialing", false], + ["active", false], + ["past_due", false], + ["canceled", true], + ], + reasons: ["requested", "payment_failed"], + frozen: [true, true], + push: ["TypeError", "TypeError"], + afterPush: 4, + unknown: false, +}; + +export const VOCABULARY_SCRIPT = ` +const mutation = (list) => { try { list.push("invented"); return "pushed"; } catch (error) { return error.name; } }; +console.log(JSON.stringify({ + intervals: [...c.SUBSCRIPTION_INTERVALS], + timings: [...c.CANCELLATION_TIMINGS], + events: [...c.SUBSCRIPTION_EVENT_TYPES], + metadataKey: c.SUBSCRIPTION_METADATA_KEY, + maxPeriodDays: c.MAX_PORTABLE_PERIOD_DAYS, + frozen: [c.SUBSCRIPTION_INTERVALS, c.CANCELLATION_TIMINGS, c.SUBSCRIPTION_EVENT_TYPES].map(Object.isFrozen), + push: [c.SUBSCRIPTION_INTERVALS, c.CANCELLATION_TIMINGS, c.SUBSCRIPTION_EVENT_TYPES].map(mutation), +})); +`; + +export const VOCABULARY_EXPECTED: Printed = { + intervals: ["day", "week", "month", "year"], + timings: ["now", "period_end"], + events: ["subscription.created", "subscription.payment_succeeded", "subscription.payment_failed", "subscription.canceled"], + metadataKey: "antelopeSubscription", + maxPeriodDays: 366, + frozen: [true, true, true], + push: ["TypeError", "TypeError", "TypeError"], +}; + +export const ERROR_SCRIPT = ` +const declined = { id: "pay_1", status: "failed", amount: { value: 1900, currency: "EUR" } }; +const describe = (error) => ({ + name: error.name, + message: error.message, + providerCode: error.providerCode ?? null, + base: error instanceof c.SubscriptionError, + error: error instanceof Error, +}); +const missing = new c.SubscriptionNotFoundError("sub_1", "resource_missing"); +const invalid = new c.InvalidSubscriptionRequestError("amount must be positive", "parameter_invalid"); +const state = new c.InvalidSubscriptionStateError("already ended", "canceled", "state_conflict"); +const stateBare = new c.InvalidSubscriptionStateError("already ended"); +const payment = new c.SubscriptionPaymentError(declined, "card_declined"); +const paymentBare = new c.SubscriptionPaymentError({ ...declined, status: "canceled" }); +const base = new c.SubscriptionError("could not call", "network"); +console.log(JSON.stringify({ + base: describe(base), + missing: describe(missing), + missingBare: new c.SubscriptionNotFoundError("sub_2").providerCode ?? null, + invalid: describe(invalid), + siblings: [missing instanceof c.InvalidSubscriptionRequestError, invalid instanceof c.SubscriptionNotFoundError, state instanceof c.SubscriptionPaymentError], + state: { ...describe(state), status: state.status }, + stateBare: { status: stateBare.status ?? null, providerCode: stateBare.providerCode ?? null }, + payment: { ...describe(payment), carried: payment.payment === declined, status: payment.payment.status }, + paymentBare: { message: paymentBare.message, providerCode: paymentBare.providerCode ?? null }, +})); +`; + +export const described = (name: string, message: string, providerCode: string | null) => ({ + name, + message, + providerCode, + base: true, + error: true, +}); + +export const ERROR_EXPECTED: Printed = { + base: described("SubscriptionError", "could not call", "network"), + missing: described("SubscriptionNotFoundError", "No such subscription: sub_1", "resource_missing"), + missingBare: null, + invalid: described("InvalidSubscriptionRequestError", "amount must be positive", "parameter_invalid"), + siblings: [false, false, false], + state: { ...described("InvalidSubscriptionStateError", "already ended", "state_conflict"), status: "canceled" }, + stateBare: { status: null, providerCode: null }, + payment: { + ...described("SubscriptionPaymentError", "The first period could not be charged: payment pay_1 is failed", "card_declined"), + carried: true, + status: "failed", + }, + paymentBare: { message: "The first period could not be charged: payment pay_1 is canceled", providerCode: null }, +}; diff --git a/.claude/skills/verify-interface b/.claude/skills/verify-interface new file mode 120000 index 0000000..c7df9cd --- /dev/null +++ b/.claude/skills/verify-interface @@ -0,0 +1 @@ +../../.agents/skills/verify-interface \ No newline at end of file diff --git a/AGENTS.md b/AGENTS.md index 992f372..e5e19e7 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -24,6 +24,8 @@ The AntelopeJS interface for recurring billing of a saved payment method, publis Finish every change with `pnpm run checks`. It runs typecheck, lint, format, knip, tests, size, suppressions, and sync, and prints only what failed. The change is done when it prints no failure. +Prove a change works with the verification skill in [`.agents/skills/verify-interface/SKILL.md`](.agents/skills/verify-interface/SKILL.md) before you call it done. + The tools enforce most rules, listed in the table at the end. The sections below hold the rules no tool checks. ### Comments From 7bc6ac11e5dea4373e2d94894079cd1cb93bf6d8 Mon Sep 17 00:00:00 2001 From: Antony Rizzitelli Date: Sat, 31 Oct 2026 23:02:13 +0100 Subject: [PATCH 2/3] fix(skills): cover the error classes and load the conformance helpers in the verify-interface skill --- .../skills/verify-interface/suite-scripts.ts | 1 + .../verify-interface/validation-cases.ts | 1 - .../skills/verify-interface/verify.config.ts | 37 +++++++++++++++++++ .../verify-interface/vocabulary-cases.ts | 4 +- 4 files changed, 40 insertions(+), 3 deletions(-) diff --git a/.agents/skills/verify-interface/suite-scripts.ts b/.agents/skills/verify-interface/suite-scripts.ts index 6fdd3db..30c8857 100644 --- a/.agents/skills/verify-interface/suite-scripts.ts +++ b/.agents/skills/verify-interface/suite-scripts.ts @@ -1,4 +1,5 @@ export const CONFORMANCE_SCRIPT = ` +globalThis.after = () => undefined; const own = (name) => require(require.resolve(name, { paths: [process.cwd()] })); const support = own("@antelopejs/interface-subscriptions/tests/support"); const seconds = (() => { try { support.assertEpochMillis(Math.floor(Date.now() / 1000), "when"); return "accepted"; } catch (error) { return error.name; } })(); diff --git a/.agents/skills/verify-interface/validation-cases.ts b/.agents/skills/verify-interface/validation-cases.ts index d9e0ed3..00a40b0 100644 --- a/.agents/skills/verify-interface/validation-cases.ts +++ b/.agents/skills/verify-interface/validation-cases.ts @@ -74,7 +74,6 @@ export const ORDER_EXPECTED: Printed = [ "intervalCount must be a positive integer", "trialEnd and billingAnchor are mutually exclusive", "trialEnd must be a finite timestamp in the future", - "trialEnd must be a finite timestamp in the future", ]; export const INTERVAL_CASES = ` diff --git a/.agents/skills/verify-interface/verify.config.ts b/.agents/skills/verify-interface/verify.config.ts index a882cb0..7fc5d04 100644 --- a/.agents/skills/verify-interface/verify.config.ts +++ b/.agents/skills/verify-interface/verify.config.ts @@ -7,6 +7,12 @@ import { ERROR_EXPECTED, ERROR_SCRIPT, STATUS_EXPECTED, STATUS_SCRIPT, VOCABULAR import { FIELD_TYPES, SIGNATURES } from "./probes.ts"; import { CONFORMANCE_FILES, CONFORMANCE_SCRIPT } from "./suite-scripts.ts"; +const EUR_19 = { value: 1900, currency: "EUR" }; + +function errorFacts(error: SubscriptionsInterface.SubscriptionError, api: typeof SubscriptionsInterface): [string, string, string | null, boolean] { + return [error.name, error.message, error.providerCode ?? null, error instanceof api.SubscriptionError]; +} + const CALLS: NonNullable["calls"]> = { ".:ValidateSubscriptionRequest": { async call(api) { @@ -25,6 +31,37 @@ const CALLS: NonNullable["calls"]> }, expect: "InvalidSubscriptionRequestError", }, + ".:SubscriptionError": { + call: (api) => Promise.resolve(errorFacts(new api.SubscriptionError("could not call", "network"), api)), + expect: ["SubscriptionError", "could not call", "network", true], + }, + ".:SubscriptionNotFoundError": { + call: (api) => Promise.resolve(errorFacts(new api.SubscriptionNotFoundError("sub_1", "missing"), api)), + expect: ["SubscriptionNotFoundError", "No such subscription: sub_1", "missing", true], + }, + ".:InvalidSubscriptionRequestError": { + call: (api) => Promise.resolve(errorFacts(new api.InvalidSubscriptionRequestError("bad amount", "invalid"), api)), + expect: ["InvalidSubscriptionRequestError", "bad amount", "invalid", true], + }, + ".:InvalidSubscriptionStateError": { + call: (api) => Promise.resolve(errorFacts(new api.InvalidSubscriptionStateError("ended", "canceled", "state"), api)), + expect: ["InvalidSubscriptionStateError", "ended", "state", true], + }, + ".:SubscriptionPaymentError": { + call: (api) => + Promise.resolve( + errorFacts(new api.SubscriptionPaymentError({ + id: "pay_1", + status: "failed", + amount: EUR_19, + amountCaptured: { value: 0, currency: "EUR" }, + amountRefunded: { value: 0, currency: "EUR" }, + reference: "acct_1", + createdAt: 0, + }, "declined"), api), + ), + expect: ["SubscriptionPaymentError", "The first period could not be charged: payment pay_1 is failed", "declined", true], + }, ".:isTerminalSubscriptionStatus": { call: (api) => Promise.resolve([api.isTerminalSubscriptionStatus("canceled"), api.isTerminalSubscriptionStatus("past_due")]), expect: [true, false], diff --git a/.agents/skills/verify-interface/vocabulary-cases.ts b/.agents/skills/verify-interface/vocabulary-cases.ts index 5e6e58f..fc7cda9 100644 --- a/.agents/skills/verify-interface/vocabulary-cases.ts +++ b/.agents/skills/verify-interface/vocabulary-cases.ts @@ -66,9 +66,9 @@ const state = new c.InvalidSubscriptionStateError("already ended", "canceled", " const stateBare = new c.InvalidSubscriptionStateError("already ended"); const payment = new c.SubscriptionPaymentError(declined, "card_declined"); const paymentBare = new c.SubscriptionPaymentError({ ...declined, status: "canceled" }); -const base = new c.SubscriptionError("could not call", "network"); +const root = new c.SubscriptionError("could not call", "network"); console.log(JSON.stringify({ - base: describe(base), + base: describe(root), missing: describe(missing), missingBare: new c.SubscriptionNotFoundError("sub_2").providerCode ?? null, invalid: describe(invalid), From 7c969271f70e88240f1af68fd32586c6272e3602 Mon Sep 17 00:00:00 2001 From: Antony Rizzitelli Date: Sat, 31 Oct 2026 23:04:36 +0100 Subject: [PATCH 3/3] fix(skills): seed breaks that build and a format door that corrupts every vocabulary --- .../seeded-breaks/calls.patch | 36 ++++++++++++++----- .../seeded-breaks/signatures.patch | 17 +++++---- .../seeded-doors/format.patch | 8 ++++- 3 files changed, 43 insertions(+), 18 deletions(-) diff --git a/.agents/skills/verify-interface/seeded-breaks/calls.patch b/.agents/skills/verify-interface/seeded-breaks/calls.patch index a25fe39..b1db9e1 100644 --- a/.agents/skills/verify-interface/seeded-breaks/calls.patch +++ b/.agents/skills/verify-interface/seeded-breaks/calls.patch @@ -1,13 +1,33 @@ diff --git a/src/functions.ts b/src/functions.ts -index 3e3ee49..88c353a 100644 +index 3e3ee49..907c48d 100644 --- a/src/functions.ts +++ b/src/functions.ts -@@ -111,7 +111,7 @@ export const CancelSubscription = +@@ -84,15 +84,19 @@ export const ListSubscriptions = * @throws SubscriptionNotFoundError if no subscription has that identifier - * @throws InvalidSubscriptionStateError if the subscription has ended + * @throws InvalidSubscriptionStateError if the subscription has already ended */ --export const UpdateSubscriptionPaymentMethod = -+export const UpdateSubscriptionMethod = - InterfaceFunction< - ( - id: string, +-export const CancelSubscription = +- InterfaceFunction< +- ( +- id: string, +- idempotencyKey: string, +- timing: CancellationTiming, +- provider?: string, +- ) => Promise +- >(); ++const Cancel = InterfaceFunction< ++ ( ++ id: string, ++ idempotencyKey: string, ++ timing: CancellationTiming, ++ provider?: string, ++ ) => Promise ++>(); ++export const CancelSubscription = (( ++ id: string, ++ idempotencyKey: string, ++ timing: CancellationTiming, ++) => Cancel(id, idempotencyKey, timing)) as typeof Cancel; + + /** + * Charges a different saved method from the next attempt onwards. diff --git a/.agents/skills/verify-interface/seeded-breaks/signatures.patch b/.agents/skills/verify-interface/seeded-breaks/signatures.patch index 9b31ab6..e21faa6 100644 --- a/.agents/skills/verify-interface/seeded-breaks/signatures.patch +++ b/.agents/skills/verify-interface/seeded-breaks/signatures.patch @@ -1,14 +1,13 @@ diff --git a/src/functions.ts b/src/functions.ts -index 3e3ee49..1947682 100644 +index 3e3ee49..cab8dff 100644 --- a/src/functions.ts +++ b/src/functions.ts -@@ -88,8 +88,8 @@ export const CancelSubscription = +@@ -66,7 +66,7 @@ export const GetSubscription = + */ + export const ListSubscriptions = InterfaceFunction< - ( - id: string, -- idempotencyKey: string, - timing: CancellationTiming, -+ idempotencyKey: string, - provider?: string, - ) => Promise +- (customer: string, provider?: string) => Promise ++ (customer: string, provider?: string) => Promise >(); + + /** diff --git a/.agents/skills/verify-interface/seeded-doors/format.patch b/.agents/skills/verify-interface/seeded-doors/format.patch index ca9287d..e933e27 100644 --- a/.agents/skills/verify-interface/seeded-doors/format.patch +++ b/.agents/skills/verify-interface/seeded-doors/format.patch @@ -1,5 +1,5 @@ diff --git a/src/types.ts b/src/types.ts -index 60d7ab8..6ee494c 100644 +index 60d7ab8..b6b5482 100644 --- a/src/types.ts +++ b/src/types.ts @@ -58,7 +58,7 @@ export const MAX_PORTABLE_PERIOD_DAYS = 366; @@ -11,3 +11,9 @@ index 60d7ab8..6ee494c 100644 "canceled", ] as const); +@@ -295,4 +295,4 @@ export type SubscriptionEventHandler = ( + * `payment.*` events reach your payment webhook route as well. Use this key to + * tell them from one-off payments. + */ +-export const SUBSCRIPTION_METADATA_KEY = "antelopeSubscription"; ++export const SUBSCRIPTION_METADATA_KEY = "subscription";