gatefit 0.4.0 → 0.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -1,6 +1,19 @@
1
1
  # Changelog
2
2
 
3
- All notable Gatefit changes are listed here. The heading version matches `package.json`. Tag `v0.4.0` is not cut in this change.
3
+ All notable Gatefit changes are listed here. The version heading matches `package.json`. Tag `v0.5.0` is not cut in this change.
4
+
5
+ ## [0.5.0] - 2026-10-16
6
+
7
+ ### Added
8
+
9
+ - Chaos mode, a Pro pack. `gatefit run --suite chaos` replays every golden under seeded perturbations and compares the access row to the in-order replay. It requires `GATEFIT_LICENSE_KEY`.
10
+ - License gate (`GATEFIT_LICENSE_KEY`), first shipped in 0.4.0.
11
+ - Token-free npm trusted publishing. An annotated tag `vX.Y.Z` runs `.github/workflows/publish.yml` with GitHub OIDC. The publish job uses the GitHub environment `npm`. Provenance is automatic.
12
+
13
+ ### Not in this version
14
+
15
+ - Provider packs.
16
+ - A monthly plan.
4
17
 
5
18
  ## [0.4.0] - 2026-10-16
6
19
 
package/README.md CHANGED
@@ -9,7 +9,7 @@ Fixture-first S1–S6 goldens, an `npx gatefit` CLI, and a GitHub Action. Node 2
9
9
  The CLI and GitHub Action in this repository stay free. The docs site is live. npm is live: `npx gatefit` ([gatefit](https://www.npmjs.com/package/gatefit)). Polar checkout is.
10
10
 
11
11
  - **Docs site:** live at https://gatefit.io. Cloudflare Pages project `gatefit`, hostname `gatefit-bdu.pages.dev`. From `site/`: `npm run build`, then `npx wrangler@4 pages deploy dist --project-name gatefit`. `CLOUDFLARE_API_TOKEN` and `CLOUDFLARE_ACCOUNT_ID` come from the environment. GitHub Pages needs a public repository or a paid plan and is not the live host. `scripts/deploy-site.sh` can copy `site/dist` locally or deploy that Cloudflare target.
12
- - **npm:** live. `npx gatefit` is `gatefit@0.4.0` at https://www.npmjs.com/package/gatefit. CI runs `npm publish --dry-run` only.
12
+ - **npm:** live. `npx gatefit` is the last published tarball (`gatefit@0.4.0` until tag `v0.5.0`) at https://www.npmjs.com/package/gatefit. This repo is `0.5.0`. The named gate runs `npm publish --dry-run` only. A `v*` tag publishes with OIDC. See [Releasing](#releasing).
13
13
  - **Polar checkout:** live. Gatefit Indie License is **$49** one-time at `https://buy.polar.sh/polar_cl_biyvx7HYtN2u19tUJCCkIuGOc98uVv7IG3gcB04eIJ0`. A plain `site` build and `scripts/deploy-site.sh` use that committed link. `PUBLIC_POLAR_CHECKOUT_URL` may override it. Any other value fails closed.
14
14
 
15
15
  ## Install / run
@@ -20,7 +20,7 @@ npx gatefit adapter-check
20
20
  npx gatefit run --suite paid_grants_access --adapter ./src/your-access-adapter.ts
21
21
  ```
22
22
 
23
- `gatefit@0.4.0`: https://www.npmjs.com/package/gatefit
23
+ This repo is `0.5.0`. Registry: https://www.npmjs.com/package/gatefit
24
24
 
25
25
  From a clone:
26
26
 
@@ -41,13 +41,13 @@ Bring your own AccessAdapter in ≤30 minutes: [docs/bring-your-adapter.md](docs
41
41
 
42
42
  ## GitHub Action
43
43
 
44
- Marketplace-ready Action lives at the repo root (`action.yml`). Tag `v0.4.0` is not cut yet. After a human tags the release commit:
44
+ Marketplace-ready Action lives at the repo root (`action.yml`). Tag `v0.5.0` is not cut yet. After a human pushes the annotated tag:
45
45
 
46
46
  ```yaml
47
47
  - uses: actions/setup-node@v4
48
48
  with:
49
49
  node-version: "22"
50
- - uses: gatefit/gatefit@v0.4.0
50
+ - uses: gatefit/gatefit@v0.5.0
51
51
  with:
52
52
  adapter: src/your-access-adapter.ts
53
53
  ```
@@ -55,22 +55,43 @@ Marketplace-ready Action lives at the repo root (`action.yml`). Tag `v0.4.0` is
55
55
  Nested path still works:
56
56
 
57
57
  ```yaml
58
- - uses: gatefit/gatefit/.github/actions/gatefit@v0.4.0
58
+ - uses: gatefit/gatefit/.github/actions/gatefit@v0.5.0
59
59
  with:
60
60
  adapter: src/your-access-adapter.ts
61
61
  ```
62
62
 
63
- The Action wraps `gatefit run`. Adapter path is in *your* repo; goldens default to Gatefit’s S1–S6 pack. See [docs/publish.md](docs/publish.md) and the release draft [docs/release-v0.4.0.md](docs/release-v0.4.0.md).
63
+ The Action wraps `gatefit run`. Adapter path is in *your* repo; goldens default to Gatefit’s S1–S6 pack. See [docs/publish.md](docs/publish.md) and the release notes [docs/release-v0.5.0.md](docs/release-v0.5.0.md).
64
64
 
65
65
  Do not set `GATEFIT_LIVE_CLOCK` for the named gate. Do not use `sk_live`. Never treat Stripe HTTP 200 as proof of access.
66
66
 
67
67
  Opt-in live Test Clocks (not default CI): [docs/live-clock.md](docs/live-clock.md).
68
68
 
69
+ ## Releasing
70
+
71
+ Bump `version` in `package.json` and the lockfile `version` field, and add the matching `CHANGELOG.md` heading, in a pull request. Merge to `main`. Then push an annotated tag whose name is `v` plus that version:
72
+
73
+ ```bash
74
+ git tag -a vX.Y.Z -m "vX.Y.Z"
75
+ git push origin vX.Y.Z
76
+ ```
77
+
78
+ The tag must match `v*`. The workflow fails when the tag without the leading `v` is not `package.json` `version`. That push runs `.github/workflows/publish.yml`: unit tests (`npm test`), the tarball checks (bin present, no `site/`, no secrets), then `npm publish --access public`. Provenance is automatic. The publish job requires the GitHub environment `npm`, so a required reviewer can be added there. `workflow_dispatch` runs the checks and does not publish. Do not set a registry token.
79
+
80
+ One-time setup on npmjs.com for package `gatefit`: open Package settings, Trusted Publisher, GitHub Actions. Organization or user `gatefit`, repository `gatefit`, workflow filename `publish.yml`, environment name `npm`, and allow `npm publish`. Then set Publishing access to **Require two-factor authentication and disallow tokens**.
81
+
82
+ `./ci-local.sh` stays the merge gate. It runs `npm publish --dry-run` and does not push tags.
83
+
69
84
  ## License
70
85
 
71
86
  S1–S6, seats/races, dispute/unpaid, and live Test Clocks stay free. Those packs do not read `GATEFIT_LICENSE_KEY` and do not call Polar.
72
87
 
73
- The $49 Gatefit Indie License unlocks Pro packs. Chaos mode is coming and is not in this release. Provider packs are not in this release. A missing, invalid, or revoked key exits non-zero and points at https://gatefit.io/#pricing.
88
+ The $49 Gatefit Indie License unlocks Pro packs. Chaos mode is live. Provider packs are not in this release. A missing, invalid, or revoked key exits non-zero and points at https://gatefit.io/#pricing.
89
+
90
+ ```bash
91
+ gatefit run --suite chaos --adapter src/adapters/hardened.ts --seed 1
92
+ ```
93
+
94
+ Chaos mode replays every golden under a seeded set of delivery perturbations and compares the access row to the in-order replay. The perturbations are duplicated deliveries, reordered events, stale events, late events, dropped-then-replayed events, and the checkout-return page loading before the webhook. HTTP 200 is not access. The seed defaults to `1`. `--seed` wins over `GATEFIT_CHAOS_SEED`. The same seed prints the same bytes. A failure prints that seed and the perturbed events. Details: [docs/chaos.md](docs/chaos.md).
74
95
 
75
96
  ```bash
76
97
  export GATEFIT_LICENSE_KEY='GATEFIT-…'
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "gatefit",
3
- "version": "0.4.0",
3
+ "version": "0.5.0",
4
4
  "description": "Stripe Entitlement Edge Pack — CI that asserts the access DB survives ugly billing transitions",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -25,6 +25,8 @@ export type BillingEvent = {
25
25
  cancel_at_period_end?: boolean;
26
26
  period_end?: number;
27
27
  now?: number;
28
+ /** Original timeline position. Chaos folds on this so arrival order is not access. */
29
+ created?: number;
28
30
  };
29
31
 
30
32
  export type AccessAdapter = {
@@ -3,10 +3,12 @@ import type { AccessSnapshot, BillingEvent } from "./access-adapter.ts";
3
3
  export type AccessStore = {
4
4
  rows: Map<string, AccessSnapshot>;
5
5
  seen: Set<string>;
6
+ events: BillingEvent[];
7
+ arrival: number;
6
8
  };
7
9
 
8
10
  export function emptyStore(): AccessStore {
9
- return { rows: new Map(), seen: new Set() };
11
+ return { rows: new Map(), seen: new Set(), events: [], arrival: 0 };
10
12
  }
11
13
 
12
14
  export function isPaid(event: BillingEvent): boolean {
@@ -96,9 +98,26 @@ export function nextSnapshot(current: AccessSnapshot, event: BillingEvent): Acce
96
98
  return current;
97
99
  }
98
100
 
101
+ function foldCustomer(store: AccessStore, customerId: string): void {
102
+ const ordered = store.events.filter((event) => event.customerId === customerId);
103
+ ordered.sort((a, b) => {
104
+ const byCreated = (a.created ?? 0) - (b.created ?? 0);
105
+ if (byCreated !== 0) return byCreated;
106
+ if (a.id < b.id) return -1;
107
+ if (a.id > b.id) return 1;
108
+ return 0;
109
+ });
110
+ let snapshot: AccessSnapshot = { locked: true };
111
+ for (const event of ordered) snapshot = nextSnapshot(snapshot, event);
112
+ store.rows.set(customerId, snapshot);
113
+ }
114
+
99
115
  export function applyBillingEvent(store: AccessStore, event: BillingEvent): void {
100
116
  if (store.seen.has(event.id)) return;
101
117
  store.seen.add(event.id);
102
- const current = store.rows.get(event.customerId) ?? { locked: true };
103
- store.rows.set(event.customerId, nextSnapshot(current, event));
118
+ const stamped: BillingEvent = { ...event };
119
+ if (stamped.created === undefined) stamped.created = store.arrival;
120
+ store.arrival += 1;
121
+ store.events.push(stamped);
122
+ foldCustomer(store, event.customerId);
104
123
  }
@@ -13,6 +13,8 @@ export function createAdapter(): AccessAdapter {
13
13
  reset() {
14
14
  store.rows.clear();
15
15
  store.seen.clear();
16
+ store.events.length = 0;
17
+ store.arrival = 0;
16
18
  },
17
19
  };
18
20
  }
package/src/chaos.ts ADDED
@@ -0,0 +1,169 @@
1
+ import { createHash } from "node:crypto";
2
+ import type { AccessSnapshot, BillingEvent } from "./access-adapter.ts";
3
+ import { loadAdapter } from "./access-adapter.ts";
4
+ import { listGoldens, loadGolden } from "./fixture-driver.ts";
5
+ import type { GoldenSuite } from "./fixture-driver.ts";
6
+ import { registerProPack } from "./packs.ts";
7
+ import { goldenDir, resolveUserOrPackage } from "./paths.ts";
8
+
9
+ export const CHAOS_DEFAULT_SEED = "1";
10
+
11
+ const PERTURBATIONS = [
12
+ "duplicate",
13
+ "reorder",
14
+ "stale",
15
+ "late",
16
+ "dropped_replay",
17
+ "checkout_return",
18
+ ] as const;
19
+
20
+ type Perturbation = (typeof PERTURBATIONS)[number];
21
+
22
+ export function resolveChaosSeed(flag: string | undefined, envValue: string | undefined): string {
23
+ const raw = flag !== undefined ? flag : envValue;
24
+ const trimmed = (raw ?? "").trim();
25
+ return trimmed.length > 0 ? trimmed : CHAOS_DEFAULT_SEED;
26
+ }
27
+
28
+ function pickIndex(seed: string, goldenId: string, kind: string, salt: string, n: number): number {
29
+ const digest = createHash("sha256").update(`${seed}\0${goldenId}\0${kind}\0${salt}`).digest();
30
+ return digest.readUInt32BE(0) % n;
31
+ }
32
+
33
+ function cloneEvents(events: BillingEvent[]): BillingEvent[] {
34
+ return events.map((event) => ({ ...event }));
35
+ }
36
+
37
+ function stamp(suite: GoldenSuite): BillingEvent[] {
38
+ return suite.steps.map((step, index) => ({ ...step.event, created: index }));
39
+ }
40
+
41
+ function duplicate(seed: string, goldenId: string, events: BillingEvent[]): BillingEvent[] {
42
+ const next = cloneEvents(events);
43
+ const index = pickIndex(seed, goldenId, "duplicate", "i", next.length);
44
+ next.push({ ...next[index]! });
45
+ return next;
46
+ }
47
+
48
+ function reorder(seed: string, goldenId: string, events: BillingEvent[]): BillingEvent[] {
49
+ const next = cloneEvents(events);
50
+ if (next.length < 2) return next;
51
+ const left = pickIndex(seed, goldenId, "reorder", "a", next.length);
52
+ let right = pickIndex(seed, goldenId, "reorder", "b", next.length);
53
+ if (left === right) right = (right + 1) % next.length;
54
+ const swap = next[left]!;
55
+ next[left] = next[right]!;
56
+ next[right] = swap;
57
+ return next;
58
+ }
59
+
60
+ function moveToEnd(events: BillingEvent[], index: number): BillingEvent[] {
61
+ const next = cloneEvents(events);
62
+ const [picked] = next.splice(index, 1);
63
+ next.push(picked!);
64
+ return next;
65
+ }
66
+
67
+ function stale(seed: string, goldenId: string, events: BillingEvent[]): BillingEvent[] {
68
+ if (events.length < 2) return cloneEvents(events);
69
+ const index = pickIndex(seed, goldenId, "stale", "i", events.length - 1);
70
+ return moveToEnd(events, index);
71
+ }
72
+
73
+ function late(seed: string, goldenId: string, events: BillingEvent[]): BillingEvent[] {
74
+ if (events.length < 2) return cloneEvents(events);
75
+ const index = pickIndex(seed, goldenId, "late", "i", events.length);
76
+ return moveToEnd(events, index);
77
+ }
78
+
79
+ function droppedReplay(seed: string, goldenId: string, events: BillingEvent[]): BillingEvent[] {
80
+ const next = cloneEvents(events);
81
+ const index = pickIndex(seed, goldenId, "dropped_replay", "i", next.length);
82
+ next.splice(index, 1);
83
+ return [...next, ...cloneEvents(events)];
84
+ }
85
+
86
+ function checkoutReturn(suite: GoldenSuite, events: BillingEvent[]): BillingEvent[] {
87
+ const page: BillingEvent = {
88
+ id: `evt_chaos_checkout_${suite.id}`,
89
+ type: "checkout.session.completed",
90
+ customerId: suite.subject,
91
+ httpStatus: 200,
92
+ stripeHttpStatus: 200,
93
+ created: -1,
94
+ };
95
+ return [page, ...cloneEvents(events)];
96
+ }
97
+
98
+ function perturb(kind: Perturbation, seed: string, suite: GoldenSuite, events: BillingEvent[]): BillingEvent[] {
99
+ if (kind === "duplicate") return duplicate(seed, suite.id, events);
100
+ if (kind === "reorder") return reorder(seed, suite.id, events);
101
+ if (kind === "stale") return stale(seed, suite.id, events);
102
+ if (kind === "late") return late(seed, suite.id, events);
103
+ if (kind === "dropped_replay") return droppedReplay(seed, suite.id, events);
104
+ return checkoutReturn(suite, events);
105
+ }
106
+
107
+ function stableSnapshot(snapshot: AccessSnapshot): string {
108
+ const keys = (Object.keys(snapshot) as (keyof AccessSnapshot)[])
109
+ .filter((key) => snapshot[key] !== undefined)
110
+ .sort();
111
+ const ordered: Record<string, unknown> = {};
112
+ for (const key of keys) ordered[key] = snapshot[key];
113
+ return JSON.stringify(ordered);
114
+ }
115
+
116
+ async function replay(
117
+ adapter: { apply: (event: BillingEvent) => void | Promise<void>; snapshot: (id: string) => AccessSnapshot | Promise<AccessSnapshot>; reset?: () => void | Promise<void> },
118
+ subject: string,
119
+ events: BillingEvent[],
120
+ ): Promise<AccessSnapshot> {
121
+ if (adapter.reset) await adapter.reset();
122
+ for (const event of events) await adapter.apply(event);
123
+ return adapter.snapshot(subject);
124
+ }
125
+
126
+ function fail(seed: string, goldenId: string, kind: Perturbation, events: BillingEvent[], expected: string, actual: string): void {
127
+ console.error(`gatefit: FAIL chaos ${goldenId} perturbation ${kind}`);
128
+ console.error(`seed: ${seed}`);
129
+ console.error("repro:");
130
+ for (const event of events) console.error(JSON.stringify(event));
131
+ console.error(`access: expected ${expected} got ${actual}`);
132
+ }
133
+
134
+ export async function runChaos(adapterPath: string, seed: string): Promise<number> {
135
+ console.error(`gatefit chaos: seed ${seed}`);
136
+ if (process.env.GATEFIT_LIVE_CLOCK) {
137
+ console.error("gatefit chaos: refuses GATEFIT_LIVE_CLOCK; fixture order only");
138
+ return 1;
139
+ }
140
+ const adapter = await loadAdapter(resolveUserOrPackage(adapterPath));
141
+ if (typeof adapter.reset !== "function") {
142
+ console.error("gatefit chaos: adapter.reset is required");
143
+ return 1;
144
+ }
145
+ const files = listGoldens(goldenDir());
146
+ for (const file of files) {
147
+ const suite = loadGolden(file);
148
+ const ordered = stamp(suite);
149
+ const expected = stableSnapshot(await replay(adapter, suite.subject, ordered));
150
+ for (const kind of PERTURBATIONS) {
151
+ const events = perturb(kind, seed, suite, ordered);
152
+ const actual = stableSnapshot(await replay(adapter, suite.subject, events));
153
+ if (actual !== expected) {
154
+ fail(seed, suite.id, kind, events, expected, actual);
155
+ return 1;
156
+ }
157
+ }
158
+ console.error(`gatefit: PASS chaos ${suite.id}`);
159
+ }
160
+ console.error("gatefit: PASS chaos");
161
+ return 0;
162
+ }
163
+
164
+ registerProPack({
165
+ id: "chaos",
166
+ run({ adapterPath, seed }) {
167
+ return runChaos(adapterPath, seed);
168
+ },
169
+ });
package/src/cli.ts CHANGED
@@ -4,6 +4,7 @@ import { parseArgs } from "node:util";
4
4
  import { loadAdapter } from "./access-adapter.ts";
5
5
  import { listGoldens, loadGolden, requireReset, runGolden } from "./fixture-driver.ts";
6
6
  import { LIVE_SCENARIO_IDS, runLiveLane, type LiveScenarioId } from "./live-scenarios.ts";
7
+ import { resolveChaosSeed } from "./chaos.ts";
7
8
  import { ensureProLicense } from "./license.ts";
8
9
  import { getProPack, isProPack } from "./packs.ts";
9
10
  import { goldenDir, packageRoot, readPackageVersion, resolveUserOrPackage, userCwd } from "./paths.ts";
@@ -21,6 +22,7 @@ function usage(): string {
21
22
  return [
22
23
  "Usage:",
23
24
  " gatefit run --suite <dir|file|id> --adapter <path>",
25
+ " gatefit run --suite chaos --adapter <path> [--seed <value>]",
24
26
  " gatefit adapter-check",
25
27
  " gatefit live --adapter <path> [--scenario <id>]",
26
28
  " gatefit --version",
@@ -41,7 +43,7 @@ function resolveSuiteFiles(suite: string): string[] {
41
43
  throw new Error(`suite not found: ${suite}`);
42
44
  }
43
45
 
44
- async function cmdRun(suite: string, adapterPath: string): Promise<number> {
46
+ async function cmdRun(suite: string, adapterPath: string, seed: string): Promise<number> {
45
47
  if (isProPack(suite)) {
46
48
  const gate = await ensureProLicense();
47
49
  if (!gate.ok) {
@@ -50,7 +52,7 @@ async function cmdRun(suite: string, adapterPath: string): Promise<number> {
50
52
  }
51
53
  if (gate.warning) console.error(gate.warning);
52
54
  const pack = getProPack(suite);
53
- if (pack?.run) return pack.run({ adapterPath });
55
+ if (pack?.run) return pack.run({ adapterPath, seed });
54
56
  console.error(`gatefit: PASS ${suite}`);
55
57
  return 0;
56
58
  }
@@ -109,13 +111,14 @@ async function main(argv: string[]): Promise<number> {
109
111
  options: {
110
112
  suite: { type: "string" },
111
113
  adapter: { type: "string" },
114
+ seed: { type: "string" },
112
115
  },
113
116
  });
114
117
  if (!values.suite || !values.adapter) {
115
118
  console.error(usage());
116
119
  return 1;
117
120
  }
118
- return cmdRun(values.suite, values.adapter);
121
+ return cmdRun(values.suite, values.adapter, resolveChaosSeed(values.seed, process.env.GATEFIT_CHAOS_SEED));
119
122
  }
120
123
  if (command === "live") {
121
124
  const { values } = parseArgs({
package/src/packs.ts CHANGED
@@ -1,10 +1,10 @@
1
1
  /**
2
- * Pro pack registry. H17 plugs chaos mode in by calling `registerProPack`
3
- * with a `run` function. The stub has no runner and is not a user-facing feature.
2
+ * Pro pack registry. Chaos mode registers here and runs only after the license gate.
3
+ * The stub has no runner and is not a user-facing feature.
4
4
  */
5
5
  export type ProPack = {
6
6
  id: string;
7
- run?: (ctx: { adapterPath: string }) => Promise<number>;
7
+ run?: (ctx: { adapterPath: string; seed: string }) => Promise<number>;
8
8
  };
9
9
 
10
10
  const proPacks = new Map<string, ProPack>();