From 3d0b3a460b99041bd7dd19a465d3b69f1888f1ba Mon Sep 17 00:00:00 2001 From: Antony Rizzitelli Date: Sat, 31 Oct 2026 00:25:46 +0100 Subject: [PATCH 1/6] test: type the contract suite against local source (#12) * test: type the contract suite against local source The suite loads dist at run time but had no types for it, so it was typed only once the package was built. CI lints before any build, so the unbuilt copy typed every call as any. The module is now typed from src/index, and test/tsconfig.json gives the suite the node and mocha types. * test: resolve the contract suite's types from src through a package import The suite now requires #built, a package import whose types condition points at src/index.ts and whose default points at dist/index.js. TypeScript types the module from source with no dist on disk, and Node still runs the built file. This replaces the JSDoc type on the require, which left the value any until dist existed. --- package.json | 6 ++++++ test/contract.test.js | 2 +- test/tsconfig.json | 12 ++++++++++++ 3 files changed, 19 insertions(+), 1 deletion(-) create mode 100644 test/tsconfig.json diff --git a/package.json b/package.json index bfb6731..8f9d107 100644 --- a/package.json +++ b/package.json @@ -25,6 +25,12 @@ ], "main": "dist/index.js", "types": "dist/index.d.ts", + "imports": { + "#built": { + "types": "./src/index.ts", + "default": "./dist/index.js" + } + }, "exports": { ".": { "types": "./dist/index.d.ts", diff --git a/test/contract.test.js b/test/contract.test.js index 4daacce..bf3f9c7 100644 --- a/test/contract.test.js +++ b/test/contract.test.js @@ -1,7 +1,7 @@ const assert = require("node:assert/strict"); const { RegisteringProxy } = require("@antelopejs/interface-core"); -const subscriptions = require("../dist/index.js"); +const subscriptions = require("#built"); /** * This package is a contract: the calls are `InterfaceFunction` declarations diff --git a/test/tsconfig.json b/test/tsconfig.json new file mode 100644 index 0000000..d5528ec --- /dev/null +++ b/test/tsconfig.json @@ -0,0 +1,12 @@ +{ + "extends": "../tsconfig.json", + "compilerOptions": { + "allowJs": true, + "composite": false, + "declaration": false, + "noEmit": true, + "module": "nodenext", + "moduleResolution": "nodenext" + }, + "include": ["*.js"] +} From db88bdec99225ed4449c0d1727cf9d661d1aba78 Mon Sep 17 00:00:00 2001 From: Antony Rizzitelli Date: Sat, 31 Oct 2026 01:56:09 +0100 Subject: [PATCH 2/6] build(deps): bump @antelopejs/tooling-configs to 0.2.17 (#11) * build(deps): bump @antelopejs/tooling-configs to 0.2.17 * refactor(tests): clear the type-aware findings the 0.2.17 rules report * chore: delete the emptied guardrails ratchet * chore: sync the guardrails pack after the ratchet deletion * Revert "chore: sync the guardrails pack after the ratchet deletion" This reverts commit ea96ad9f77c080afa49f1daecd5d07726decdbf2. * Revert "chore: delete the emptied guardrails ratchet" This reverts commit a845d7b1f52708fff392967608354c784d6685c2. * refactor(tests): keep the deliberately invalid interval as an assertion * chore: lower the ratchet to the one row that stays --- .github/workflows/ci.yml | 11 +++++++++-- AGENTS.md | 22 ++++++++++++++++------ README.md | 12 +++++++----- guardrails.ratchet.json | 1 + guardrails.suppressions.json | 3 ++- oxlint.config.ts | 6 +++++- package.json | 2 +- pnpm-lock.yaml | 10 +++++----- src/tests/support.ts | 2 +- tests/json-object.types.ts | 28 ++++++++++++++-------------- 10 files changed, 61 insertions(+), 36 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index affb25a..90ab75f 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -17,9 +17,9 @@ jobs: README.md docs docs-typescript-project: docs/tsconfig.json + renovate-sync: false permissions: - contents: write - actions: write + contents: read checks: if: always() @@ -29,3 +29,10 @@ jobs: steps: - if: contains(needs.*.result, 'failure') || contains(needs.*.result, 'cancelled') || contains(needs.*.result, 'skipped') run: exit 1 + renovate-sync: + if: >- + github.event_name == 'pull_request' && startsWith(github.head_ref, 'renovate/') && github.event.pull_request.head.repo.full_name == github.repository + permissions: + contents: write + actions: write + uses: AntelopeJS/.github/.github/workflows/ci-renovate-sync.yml@v1 diff --git a/AGENTS.md b/AGENTS.md index 6469976..75b09f5 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -44,9 +44,9 @@ Hold every test you write or keep to this checklist: ### Cleanup proof -A behaviour-preserving cleanup PR shows `antelopejs-api-diff ` passing. Published declarations and built runtime exports stay unchanged; pass `--allow-added ` (repeatable, e.g. `'**/internal/**'`) to let added entries under that glob pass when their module is `@internal`, and copy its "Added (allowed)" list. A comment-only PR shows `antelopejs-comment-proof ` passing instead of a verifier. Both compare committed HEAD. +A behaviour-preserving cleanup PR shows `antelopejs-api-diff ` passing. Published declarations, built runtime exports and the names and signatures a DMS frontend module auto-imports stay unchanged; pass `--allow-added ` (repeatable, e.g. `'**/internal/**'`) to let added entries under that glob pass when their module is `@internal`, and copy its "Added (allowed)" list. Every export under a directory that `dms.frontend.build.ts` auto-imports is published, `@internal` or not; a helper that must stay private goes under `app/build/`. A comment-only PR shows `antelopejs-comment-proof ` passing instead of a verifier. Both compare committed HEAD. -Copy counts from the proof's base and HEAD table. Never type counts by hand. Keep the command and the committed head beside the copied output. The table counts a lint directive once per rule it names, inline directives included. +Copy the proof's whole output into the PR body, down to its closing `` line, which stamps the command, base, committed head and a digest. Never type counts by hand. List the lint findings a PR leaves in place by pasting the block `antelopejs-body-check --findings` prints and replacing each ``. `antelopejs-body-check ` fails an edited or stale proof block, proof output outside one, and a findings line that is not a live diagnostic at HEAD; counts in prose are not checked, so leave them out. The table counts a lint directive once per rule it names, inline directives included. Pass `--probe ` to api-diff when changing receivers, positional calls, or modules that no published entry reaches. The same captured script runs against both worktree roots, supplied as `argv[2]`, and prints JSON observations. Matching metadata alone does not prove those behaviours. @@ -58,11 +58,11 @@ Verify a boundary against the imports a developer or agent would plausibly write ### Suppression budget -`guardrails.suppressions.json` caps the suppressions in authored code: lint directives per rule they name (`*` for none), `@ts-expect-error`, `@ts-ignore` and `SAFETY:` comments. A suppression over its ceiling fails: fix the code, or remove another suppression of the same kind. A rule that keeps needing suppressions should be changed in the preset. Never raise or remove a number in that file: the check fails a ceiling higher than on the pull request base, and only the operator approves a raise. A directive disabling `anti-slop/require-safety-comment-for-type-assertion` is burn-down debt: remove the assertion, or, where it holds, replace the directive with `SAFETY: `, which takes over its room without a raise. After you remove suppressions, run `pnpm run suppressions:update` and commit the lowered file. +`guardrails.suppressions.json` caps the suppressions in authored code: lint directives per rule they name (`*` for none), `@ts-expect-error`, `@ts-ignore` and `SAFETY:` comments, and under `config` each rule a lint config (`oxlint.config.*`, `.oxlintrc.json`, `eslint.config.*`) switches off or to a warning, keyed by rule and `files` glob anchored at the config's directory, so widening or moving a scope is a new ceiling of 0, while narrowing one takes over the room it gives up. Switching a rule off in a config is a suppression too: fix the code its rule reports instead. A suppression over its ceiling fails: fix the code, or remove another suppression of the same kind. A rule that keeps needing suppressions should be changed in the preset. Never raise or remove a number in that file: the check fails a ceiling higher than on the pull request base, and only the operator approves a raise. A directive disabling `anti-slop/require-safety-comment-for-type-assertion` is burn-down debt: remove the assertion, or, where it holds, replace the directive with `SAFETY: `, which takes over its room without a raise. After you remove suppressions, run `pnpm run suppressions:update` and commit the lowered file. ### Debt -`guardrails.ratchet.json` records the lint findings and oversize files this repository already had. Its numbers only go down. After you remove debt, run `pnpm run ratchet:update` and commit the lowered file. A rule listed there is off for `oxlint`. The ratchet counts it instead, and the pre-commit hook rejects it on every line you add. +`guardrails.ratchet.json` records the lint findings and oversize files this repository already had. Its numbers only go down. After you remove debt, or after a pack upgrade, run `pnpm run ratchet:update` and commit the file: it lowers the numbers and adds only the rules the new pack release enabled, at today's count. Never edit the numbers by hand; CI fails one added or raised against the base. A rule listed there is off for `oxlint`. The ratchet counts it instead, and the pre-commit hook rejects it on every line you add. ### What enforces what @@ -76,24 +76,34 @@ Verify a boundary against the imports a developer or agent would plausibly write | Tests import the code under test from local source, not their own package name | `antelopejs/no-self-import-in-tests` | | No assertion that restates a constant | `antelopejs/no-constant-value-assertion` | | No test whose only check is "is a function" | `antelopejs/no-typeof-function-only-test` | +| String-keyed object tables are read through own keys only | `antelopejs/no-inherited-key-lookup` | +| A type guard checks each member it narrows to; keys with `Object.hasOwn` | `antelopejs/no-presence-only-type-guard` | +| A type guard checks the nested elements and values it claims | review; no tool | | Params ≤ 5, function ≤ 120 lines, file ≤ 500 lines, depth ≤ 4, complexity ≤ 20 | `eslint/max-*`, `eslint/complexity` | -| Cleanup preserves declarations and built runtime export metadata | `antelopejs-api-diff ` | +| Cleanup preserves declarations, runtime exports and DMS auto-imports | `antelopejs-api-diff ` | | Comment-only changes preserve JavaScript emit | `antelopejs-comment-proof ` | -| PR-body counts come from tracked code files | api-diff and comment-proof tables | +| PR-body proof tables are copied unedited from the current head | `antelopejs-body-check ` | +| PR-body findings-left lines are live diagnostics at HEAD | `antelopejs-body-check ` | | Receiver, positional and unpublished-module behaviour | `antelopejs-api-diff --probe ` | | Directive and `SAFETY:` reasons are true of the code | review; no tool | | Evasion-only and patched-internal findings go under Attention | review; no tool | | No-upward rules reject same-tier peers | review and import probes | | No `@ts-ignore` | `typescript/ban-ts-comment` | +| No once-used type parameter standing in for `unknown` | `typescript/no-unnecessary-type-parameters` | +| No `any` passed, assigned or returned where a type is declared | `typescript/no-unsafe-{argument,assignment,return}` | | Files ≤ 20,000 bytes, tests included | `antelopejs-check-size` (pre-commit) | | Suppressions stay within `guardrails.suppressions.json` | `antelopejs-suppressions` (pre-commit, checks) | +| No lint config switches off a rule to stay under the budget | `antelopejs-suppressions` (pre-commit, checks) | | No added directive disables the `SAFETY:`, comment or test rules | `antelopejs-suppressions` (pre-commit, checks) | | An added suppression gives a specific reason | `antelopejs-suppressions` (pre-commit, checks) | +| A directive in a file a change touches suppresses something and gives a reason | `antelopejs-suppressions` (pre-commit, checks) | | A block disable is closed in its file | `antelopejs-suppressions` (pre-commit, checks) | | Types check | `tsc` (pre-push) | | No dead files, exports, or dependencies | knip | | Managed files match the pack | `antelopejs-sync --check` | +| A CI gate reads `needs.` only of jobs in its `needs` | `antelopejs-sync --check` | | Entry-only rules exempt no tracked outsider | `antelopejs-sync --check` | +| A DMS layer's `build/backend-boundary.ts` is the pack's plugin | `antelopejs-sync --check` | | A test names the wrong implementation it catches | review; no tool | | Existing debt only shrinks | `antelopejs-ratchet` | diff --git a/README.md b/README.md index adbb737..5521ab4 100644 --- a/README.md +++ b/README.md @@ -151,12 +151,14 @@ events by `event.id` as described in [Events](./docs/3.events.md). ## Compatibility - + -| Package | Range | -| ------------------------------- | ---------------- | -| `@antelopejs/interface-core` | `>=0.0.5 <1.0.0` | -| `@antelopejs/interface-payment` | `>=0.0.2 <1.0.0` | +| Package | Supported | Tested with | +| ------------------------------- | ---------------- | ----------- | +| `@antelopejs/interface-core` | `>=0.0.5 <1.0.0` | `0.1.1` | +| `@antelopejs/interface-payment` | `>=0.0.2 <1.0.0` | `0.0.2` | + + These are the peer requirements in `package.json`. Development uses the same payment-interface semver range. Check your implementing module's requirements diff --git a/guardrails.ratchet.json b/guardrails.ratchet.json index 8286b65..e22d714 100644 --- a/guardrails.ratchet.json +++ b/guardrails.ratchet.json @@ -1,4 +1,5 @@ { + "pack": "0.2.17", "rules": { "anti-slop/require-safety-comment-for-type-assertion": 1 }, diff --git a/guardrails.suppressions.json b/guardrails.suppressions.json index 6936c18..76c39b1 100644 --- a/guardrails.suppressions.json +++ b/guardrails.suppressions.json @@ -2,5 +2,6 @@ "rules": {}, "tsExpectError": 0, "tsIgnore": 0, - "safety": 0 + "safety": 0, + "config": {} } diff --git a/oxlint.config.ts b/oxlint.config.ts index fbd8c24..b654d03 100644 --- a/oxlint.config.ts +++ b/oxlint.config.ts @@ -1,7 +1,11 @@ import { defineConfig } from "oxlint"; -import { antelopePreset } from "@antelopejs/tooling-configs/oxc/lint"; +import { + ANTELOPE_IGNORE_PATTERNS, + antelopePreset, +} from "@antelopejs/tooling-configs/oxc/lint"; export default defineConfig({ extends: [antelopePreset()], + ignorePatterns: ANTELOPE_IGNORE_PATTERNS, options: { typeAware: true }, }); diff --git a/package.json b/package.json index 8f9d107..e594644 100644 --- a/package.json +++ b/package.json @@ -71,7 +71,7 @@ "@antelopejs/core": "1.16.0", "@antelopejs/interface-core": ">=0.0.5 <1.0.0", "@antelopejs/interface-payment": ">=0.0.2 <1.0.0", - "@antelopejs/tooling-configs": "^0.2.11", + "@antelopejs/tooling-configs": "^0.2.17", "@types/mocha": "^10.0.10", "@types/node": "^22.19.15", "eslint-plugin-perfectionist": "^5.11.0", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 9e28c00..a87b292 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -18,8 +18,8 @@ importers: specifier: '>=0.0.2 <1.0.0' version: 0.0.2(@antelopejs/interface-core@0.1.1) '@antelopejs/tooling-configs': - specifier: ^0.2.11 - version: 0.2.11(eslint-plugin-perfectionist@5.12.1(eslint@10.11.0(jiti@2.7.0))(typescript@5.9.3))(knip@6.39.0)(oxfmt@0.66.0)(oxlint@1.81.0(oxlint-tsgolint@7.0.2003)) + specifier: ^0.2.17 + version: 0.2.17(eslint-plugin-perfectionist@5.12.1(eslint@10.11.0(jiti@2.7.0))(typescript@5.9.3))(knip@6.39.0)(oxfmt@0.66.0)(oxlint@1.81.0(oxlint-tsgolint@7.0.2003)) '@types/mocha': specifier: ^10.0.10 version: 10.0.10 @@ -75,8 +75,8 @@ packages: peerDependencies: '@antelopejs/interface-core': '>=0.0.3 <1.0.0' - '@antelopejs/tooling-configs@0.2.11': - resolution: {integrity: sha512-11reej4rSQuPEQqQgef60SyQZ8XGWlUSshD4RXLXPBpyFGVQmOqagKmj1zTg8QAktzwxxokhk/8JrIyChIv3LA==} + '@antelopejs/tooling-configs@0.2.17': + resolution: {integrity: sha512-8fwh7xQPzazP/l/ivEwRKpfN8qShV9+PRizpI9cBewe2TwoRPu4n95w6sYSlygiRmm0HxLJYZFhmSPRgtiNo1Q==} engines: {node: '>=22.18.0'} hasBin: true peerDependencies: @@ -2511,7 +2511,7 @@ snapshots: dependencies: '@antelopejs/interface-core': 0.1.1 - '@antelopejs/tooling-configs@0.2.11(eslint-plugin-perfectionist@5.12.1(eslint@10.11.0(jiti@2.7.0))(typescript@5.9.3))(knip@6.39.0)(oxfmt@0.66.0)(oxlint@1.81.0(oxlint-tsgolint@7.0.2003))': + '@antelopejs/tooling-configs@0.2.17(eslint-plugin-perfectionist@5.12.1(eslint@10.11.0(jiti@2.7.0))(typescript@5.9.3))(knip@6.39.0)(oxfmt@0.66.0)(oxlint@1.81.0(oxlint-tsgolint@7.0.2003))': dependencies: '@oxlint/plugins': 1.81.0 dts-bundle-generator: 9.5.1 diff --git a/src/tests/support.ts b/src/tests/support.ts index a72b439..b97fbaa 100644 --- a/src/tests/support.ts +++ b/src/tests/support.ts @@ -270,7 +270,7 @@ export async function rejectsWith( ): Promise { const error: unknown = await work.then( () => undefined, - (reason) => reason, + (reason: unknown) => reason, ); assert.ok( error instanceof expected, diff --git a/tests/json-object.types.ts b/tests/json-object.types.ts index 28f1b32..e8beefd 100644 --- a/tests/json-object.types.ts +++ b/tests/json-object.types.ts @@ -3,21 +3,21 @@ import type { JsonObject, Subscription } from "../src/index"; type Accepts = [Value] extends [Target] ? true : false; type Not = Flag extends true ? false : true; +type Proof = Flag; + interface InterfaceTyped { name: string; } -declare function assertType(): Flag; - -assertType>(); -assertType>(); -assertType< - Accepts ->(); -assertType, { phase: null }>>(); -assertType>>(); -assertType>>(); -assertType string }>>>(); -assertType>>(); -assertType>>(); -assertType>>(); +export type Proofs = [ + Proof>, + Proof>, + Proof>, + Proof, { phase: null }>>, + Proof>>, + Proof>>, + Proof string }>>>, + Proof>>, + Proof>>, + Proof>>, +]; From 9eebc708114931daf7531d12b7b48f4b93060001 Mon Sep 17 00:00:00 2001 From: Antony Rizzitelli Date: Sat, 31 Oct 2026 06:29:10 +0100 Subject: [PATCH 3/6] docs: typecheck every example and drop hand-written version prose (#13) --- .github/workflows/ci.yml | 2 +- README.md | 11 ++++++-- docs/2.subscriptions.md | 60 ++++++++++++++++++++++++++++------------ docs/3.events.md | 22 ++++++++++++--- docs/4.implementing.md | 2 +- docs/tsconfig.json | 2 ++ 6 files changed, 72 insertions(+), 27 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 90ab75f..9ee1268 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -11,7 +11,7 @@ permissions: jobs: ci: - uses: AntelopeJS/.github/.github/workflows/ci-node.yml@v1 + uses: AntelopeJS/.github/.github/workflows/ci-node.yml@v1-campaign with: docs-files: | README.md diff --git a/README.md b/README.md index 5521ab4..84dd034 100644 --- a/README.md +++ b/README.md @@ -139,14 +139,19 @@ off-session before `CreateSubscription` resolves. Adapt those amounts to your billing policy. Register an idempotent handler for subsequent lifecycle events: ```ts -import { SubscriptionEvents } from "@antelopejs/interface-subscriptions"; +import { + SubscriptionEvents, + type Subscription, +} from "@antelopejs/interface-subscriptions"; + +declare function grantOrRevokeAccess(subscription: Subscription): Promise; SubscriptionEvents.register("billing", async (event) => { await grantOrRevokeAccess(event.subscription); }); ``` -`grantOrRevokeAccess` stands for your application's access policy. Deduplicate +`grantOrRevokeAccess` stands for your application's access policy, and its `declare` line only gives it a type. Deduplicate events by `event.id` as described in [Events](./docs/3.events.md). ## Compatibility @@ -162,7 +167,7 @@ events by `event.id` as described in [Events](./docs/3.events.md). These are the peer requirements in `package.json`. Development uses the same payment-interface semver range. Check your implementing module's requirements -too. `@antelopejs/core` 1.16.0 is a development test runner, not a runtime peer. +too. `@antelopejs/core` is a development test runner, not a runtime peer. ## Conventions diff --git a/docs/2.subscriptions.md b/docs/2.subscriptions.md index 723beb8..7eb0154 100644 --- a/docs/2.subscriptions.md +++ b/docs/2.subscriptions.md @@ -20,17 +20,26 @@ taken from a payer who is there. That first payment is an ordinary one-off; the subscription starts after it, and its first period is a separate charge. ```ts -const subscription = await CreateSubscription( - { - reference: accountId, - customer, - paymentMethod: method.id, - amount: { value: 1900, currency: "EUR" }, - interval: "month", - intervalCount: 1, - }, - `subscribe-${accountId}`, -); +import type { PaymentMethod } from "@antelopejs/interface-payment"; +import { CreateSubscription } from "@antelopejs/interface-subscriptions"; + +export async function subscribe( + accountId: string, + customer: string, + method: PaymentMethod, +) { + return await CreateSubscription( + { + reference: accountId, + customer, + paymentMethod: method.id, + amount: { value: 1900, currency: "EUR" }, + interval: "month", + intervalCount: 1, + }, + `subscribe-${accountId}`, + ); +} ``` Without a trial or a billing anchor, the first period is charged off-session @@ -102,11 +111,22 @@ If the saved method will not work again — an expired card — take a new manda on-session and switch to it: ```ts -await UpdateSubscriptionPaymentMethod( - subscription.id, - newMethodId, - idempotencyKey, -); +import { + UpdateSubscriptionPaymentMethod, + type Subscription, +} from "@antelopejs/interface-subscriptions"; + +export async function switchMethod( + subscription: Subscription, + newMethodId: string, + idempotencyKey: string, +) { + await UpdateSubscriptionPaymentMethod( + subscription.id, + newMethodId, + idempotencyKey, + ); +} ``` Nothing is charged by the switch. The next scheduled attempt uses the new method. @@ -114,8 +134,12 @@ Nothing is charged by the switch. The next scheduled attempt uses the new method ## Cancelling ```ts -await CancelSubscription(id, `cancel-period-end-${id}`, "period_end"); // run out the paid period -await CancelSubscription(id, `cancel-now-${id}`, "now"); // end immediately +import { CancelSubscription } from "@antelopejs/interface-subscriptions"; + +export async function cancel(id: string) { + await CancelSubscription(id, `cancel-period-end-${id}`, "period_end"); // run out the paid period + await CancelSubscription(id, `cancel-now-${id}`, "now"); // end immediately +} ``` Use distinct keys for distinct operations. Reuse a key only when retrying the diff --git a/docs/3.events.md b/docs/3.events.md index f046c4c..95d4a6c 100644 --- a/docs/3.events.md +++ b/docs/3.events.md @@ -24,7 +24,13 @@ event: the caller made those changes and already knows. ## Receiving them ```ts -import { SubscriptionEvents } from "@antelopejs/interface-subscriptions"; +import { + SubscriptionEvents, + type SubscriptionEvent, +} from "@antelopejs/interface-subscriptions"; + +declare function alreadyHandled(eventId: string): Promise; +declare function applyToAccount(event: SubscriptionEvent): Promise; SubscriptionEvents.register("billing", async (event) => { if (await alreadyHandled(event.id)) return; @@ -68,9 +74,17 @@ Every subscription charge is an ordinary payment-interface payment, so its the subscription's identifier: ```ts -const event = await VerifyWebhook(request); -if (event?.payment?.metadata?.[SUBSCRIPTION_METADATA_KEY]) { - return; // a subscription charge: handled by the subscription event +import { + VerifyWebhook, + type WebhookRequest, +} from "@antelopejs/interface-payment"; +import { SUBSCRIPTION_METADATA_KEY } from "@antelopejs/interface-subscriptions"; + +export async function handlePaymentWebhook(request: WebhookRequest) { + const event = await VerifyWebhook(request); + if (event?.payment?.metadata?.[SUBSCRIPTION_METADATA_KEY]) { + return; // a subscription charge: handled by the subscription event + } } ``` diff --git a/docs/4.implementing.md b/docs/4.implementing.md index afa2d44..bfb45a7 100644 --- a/docs/4.implementing.md +++ b/docs/4.implementing.md @@ -61,7 +61,7 @@ own: that would put a mandatory database and scheduler into a payment module. The calls are declared directly as the public names, so the whole module is the declaration: -```ts +```ts fragment import * as subscriptions from "@antelopejs/interface-subscriptions"; import { ImplementInterface } from "@antelopejs/interface-core"; diff --git a/docs/tsconfig.json b/docs/tsconfig.json index c3e8775..fff0882 100644 --- a/docs/tsconfig.json +++ b/docs/tsconfig.json @@ -1,6 +1,8 @@ { "extends": "../tsconfig.json", "compilerOptions": { + "module": "esnext", + "moduleResolution": "bundler", "noEmit": true, "composite": false, "incremental": false, From fa3d01347839a81f7d14d4c0661da62c547473fc Mon Sep 17 00:00:00 2001 From: Antony Rizzitelli Date: Sat, 31 Oct 2026 12:26:45 +0100 Subject: [PATCH 4/6] test: assert the invalid interval rejection with ts-expect-error instead of a cast (#14) * test: assert the invalid interval rejection with ts-expect-error instead of a cast The conformance case for an interval outside the vocabulary now passes the invalid value directly, with a @ts-expect-error that names the rejection under test, and a Partial return type so the error stays on that one line. The ratchet has no debt left, so its file is deleted; the suppressions budget raises tsExpectError from 0 to 1 for this one directive. * chore: sync the guardrails pack after the ratchet is deleted --- AGENTS.md | 7 +------ guardrails.ratchet.json | 7 ------- guardrails.suppressions.json | 2 +- lefthook.yml | 3 --- package.json | 2 -- src/tests/subscriptions-changes.test.ts | 5 +++-- 6 files changed, 5 insertions(+), 21 deletions(-) delete mode 100644 guardrails.ratchet.json diff --git a/AGENTS.md b/AGENTS.md index 75b09f5..992f372 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -22,7 +22,7 @@ The AntelopeJS interface for recurring billing of a saved payment method, publis ## Guardrails -Finish every change with `pnpm run checks`. It runs typecheck, lint, format, knip, tests, size, ratchet, suppressions, and sync, and prints only what failed. The change is done when it prints no failure. +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. The tools enforce most rules, listed in the table at the end. The sections below hold the rules no tool checks. @@ -60,10 +60,6 @@ Verify a boundary against the imports a developer or agent would plausibly write `guardrails.suppressions.json` caps the suppressions in authored code: lint directives per rule they name (`*` for none), `@ts-expect-error`, `@ts-ignore` and `SAFETY:` comments, and under `config` each rule a lint config (`oxlint.config.*`, `.oxlintrc.json`, `eslint.config.*`) switches off or to a warning, keyed by rule and `files` glob anchored at the config's directory, so widening or moving a scope is a new ceiling of 0, while narrowing one takes over the room it gives up. Switching a rule off in a config is a suppression too: fix the code its rule reports instead. A suppression over its ceiling fails: fix the code, or remove another suppression of the same kind. A rule that keeps needing suppressions should be changed in the preset. Never raise or remove a number in that file: the check fails a ceiling higher than on the pull request base, and only the operator approves a raise. A directive disabling `anti-slop/require-safety-comment-for-type-assertion` is burn-down debt: remove the assertion, or, where it holds, replace the directive with `SAFETY: `, which takes over its room without a raise. After you remove suppressions, run `pnpm run suppressions:update` and commit the lowered file. -### Debt - -`guardrails.ratchet.json` records the lint findings and oversize files this repository already had. Its numbers only go down. After you remove debt, or after a pack upgrade, run `pnpm run ratchet:update` and commit the file: it lowers the numbers and adds only the rules the new pack release enabled, at today's count. Never edit the numbers by hand; CI fails one added or raised against the base. A rule listed there is off for `oxlint`. The ratchet counts it instead, and the pre-commit hook rejects it on every line you add. - ### What enforces what | Rule | Enforced by | @@ -105,6 +101,5 @@ Verify a boundary against the imports a developer or agent would plausibly write | Entry-only rules exempt no tracked outsider | `antelopejs-sync --check` | | A DMS layer's `build/backend-boundary.ts` is the pack's plugin | `antelopejs-sync --check` | | A test names the wrong implementation it catches | review; no tool | -| Existing debt only shrinks | `antelopejs-ratchet` | diff --git a/guardrails.ratchet.json b/guardrails.ratchet.json deleted file mode 100644 index e22d714..0000000 --- a/guardrails.ratchet.json +++ /dev/null @@ -1,7 +0,0 @@ -{ - "pack": "0.2.17", - "rules": { - "anti-slop/require-safety-comment-for-type-assertion": 1 - }, - "oversizeFiles": {} -} diff --git a/guardrails.suppressions.json b/guardrails.suppressions.json index 76c39b1..4618231 100644 --- a/guardrails.suppressions.json +++ b/guardrails.suppressions.json @@ -1,6 +1,6 @@ { "rules": {}, - "tsExpectError": 0, + "tsExpectError": 1, "tsIgnore": 0, "safety": 0, "config": {} diff --git a/lefthook.yml b/lefthook.yml index ff792b5..779c267 100644 --- a/lefthook.yml +++ b/lefthook.yml @@ -8,9 +8,6 @@ pre-commit: run: pnpm exec oxlint --no-error-on-unmatched-pattern {staged_files} antelopejs-size: run: pnpm exec antelopejs-check-size --staged {staged_files} - antelopejs-ratchet: - glob: "*.{js,mjs,cjs,jsx,ts,mts,cts,tsx,vue}" - run: pnpm exec antelopejs-ratchet --staged {staged_files} antelopejs-suppressions: glob: "*.{js,mjs,cjs,jsx,ts,mts,cts,tsx,vue}" run: pnpm exec antelopejs-suppressions --staged diff --git a/package.json b/package.json index e594644..19f16e9 100644 --- a/package.json +++ b/package.json @@ -59,9 +59,7 @@ "prepare": "if git rev-parse --is-inside-work-tree >/dev/null 2>&1 && command -v lefthook >/dev/null 2>&1; then lefthook install; fi", "checks": "antelopejs-checks", "size": "antelopejs-check-size", - "ratchet": "antelopejs-ratchet", "sync": "antelopejs-sync", - "ratchet:update": "antelopejs-ratchet --update", "sync:check": "antelopejs-sync --check", "typecheck": "tsc --noEmit", "suppressions": "antelopejs-suppressions", diff --git a/src/tests/subscriptions-changes.test.ts b/src/tests/subscriptions-changes.test.ts index 7c65326..4d5a834 100644 --- a/src/tests/subscriptions-changes.test.ts +++ b/src/tests/subscriptions-changes.test.ts @@ -221,8 +221,9 @@ describe(`requests outside the contract (${CHARGEABLE})`, function () { "a fractional amount": () => ({ amount: amount(10.5) }), "an interval count of zero": () => ({ intervalCount: 0 }), "a fractional interval count": () => ({ intervalCount: 1.5 }), - "an interval outside the vocabulary": () => ({ - interval: "fortnight" as SubscriptionRequest["interval"], + "an interval outside the vocabulary": (): Partial => ({ + // @ts-expect-error "fortnight" is outside the interval vocabulary, which CreateSubscription must refuse with InvalidSubscriptionRequestError + interval: "fortnight", }), "a trial that has already ended": (now) => ({ trialEnd: now - DAY_MS }), "a billing anchor in the past": (now) => ({ From b6645bdd0feba722d681067fe445df342cd7fda6 Mon Sep 17 00:00:00 2001 From: Antony Rizzitelli Date: Sun, 1 Nov 2026 00:44:12 +0100 Subject: [PATCH 5/6] feat(skills): add the verify-interface verification skill (#16) * feat(skills): add the verify-interface verification skill * fix(skills): cover the error classes and load the conformance helpers in the verify-interface skill * fix(skills): seed breaks that build and a format door that corrupts every vocabulary --- .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 | 33 +++ .../seeded-breaks/conformance.patch | 13 ++ .../seeded-breaks/errors.patch | 13 ++ .../seeded-breaks/field-types.patch | 13 ++ .../seeded-breaks/signatures.patch | 13 ++ .../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 | 19 ++ .../seeded-doors/publish.patch | 13 ++ .../skills/verify-interface/suite-scripts.ts | 33 +++ .agents/skills/verify-interface/tsconfig.json | 14 ++ .../verify-interface/validation-cases.ts | 170 ++++++++++++++ .../skills/verify-interface/verify.config.ts | 214 ++++++++++++++++++ .../verify-interface/vocabulary-cases.ts | 105 +++++++++ .claude/skills/verify-interface | 1 + AGENTS.md | 2 + 39 files changed, 1475 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..b1db9e1 --- /dev/null +++ b/.agents/skills/verify-interface/seeded-breaks/calls.patch @@ -0,0 +1,33 @@ +diff --git a/src/functions.ts b/src/functions.ts +index 3e3ee49..907c48d 100644 +--- a/src/functions.ts ++++ b/src/functions.ts +@@ -84,15 +84,19 @@ export const ListSubscriptions = + * @throws SubscriptionNotFoundError if no subscription has that identifier + * @throws InvalidSubscriptionStateError if the subscription has already ended + */ +-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/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..e21faa6 --- /dev/null +++ b/.agents/skills/verify-interface/seeded-breaks/signatures.patch @@ -0,0 +1,13 @@ +diff --git a/src/functions.ts b/src/functions.ts +index 3e3ee49..cab8dff 100644 +--- a/src/functions.ts ++++ b/src/functions.ts +@@ -66,7 +66,7 @@ export const GetSubscription = + */ + export const ListSubscriptions = + InterfaceFunction< +- (customer: string, provider?: string) => Promise ++ (customer: 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..e933e27 --- /dev/null +++ b/.agents/skills/verify-interface/seeded-doors/format.patch @@ -0,0 +1,19 @@ +diff --git a/src/types.ts b/src/types.ts +index 60d7ab8..b6b5482 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); + +@@ -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"; 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..30c8857 --- /dev/null +++ b/.agents/skills/verify-interface/suite-scripts.ts @@ -0,0 +1,33 @@ +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; } })(); +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..00a40b0 --- /dev/null +++ b/.agents/skills/verify-interface/validation-cases.ts @@ -0,0 +1,170 @@ +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", +]; + +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..7fc5d04 --- /dev/null +++ b/.agents/skills/verify-interface/verify.config.ts @@ -0,0 +1,214 @@ +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 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) { + 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", + }, + ".: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], + }, +}; + +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..fc7cda9 --- /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 root = new c.SubscriptionError("could not call", "network"); +console.log(JSON.stringify({ + base: describe(root), + 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 f4d7e5f244d43a2e9e8b0b62ea6592df979a12e0 Mon Sep 17 00:00:00 2001 From: Antony Rizzitelli Date: Tue, 3 Nov 2026 00:35:19 +0100 Subject: [PATCH 6/6] chore: merge main into campaign/integration (#18) * docs(readme): use one Discord invite (#15) * docs(github): send questions and ideas to Discord (#17) --- .github/ISSUE_TEMPLATE/config.yml | 4 ++-- README.md | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/.github/ISSUE_TEMPLATE/config.yml b/.github/ISSUE_TEMPLATE/config.yml index 9e41a21..9ff80ed 100644 --- a/.github/ISSUE_TEMPLATE/config.yml +++ b/.github/ISSUE_TEMPLATE/config.yml @@ -4,8 +4,8 @@ contact_links: url: https://antelopejs.com/ about: Read the documentation before opening a usage issue. - name: Questions and ideas - url: https://github.com/AntelopeJS/antelopejs/discussions - about: Ask for help or discuss an idea with the community. + url: https://discord.gg/g4pFt6eXqu + about: Ask for help or share an idea in #support on the AntelopeJS Discord. - name: Security vulnerability url: https://github.com/AntelopeJS/.github/blob/main/SECURITY.md about: Read the private reporting instructions. Do not open a public issue. diff --git a/README.md b/README.md index 84dd034..c9ca5b4 100644 --- a/README.md +++ b/README.md @@ -3,7 +3,7 @@
npm License -Discord +Discord Docs