gatefit 0.0.0-stage → 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 +33 -0
- package/LICENSE +21 -0
- package/README.md +99 -2
- package/action.yml +23 -0
- package/bin/gatefit.js +32 -0
- package/package.json +56 -4
- package/scripts/action-run.sh +18 -0
- package/src/access-adapter.ts +90 -0
- package/src/access-rules.ts +123 -0
- package/src/adapters/hardened.ts +22 -0
- package/src/adapters/naive.ts +25 -0
- package/src/adapters/sqlite.ts +83 -0
- package/src/chaos.ts +169 -0
- package/src/cli.ts +166 -0
- package/src/clock-driver.ts +52 -0
- package/src/fixture-driver.ts +84 -0
- package/src/license.ts +160 -0
- package/src/live-scenarios.ts +191 -0
- package/src/packs.ts +27 -0
- package/src/paths.ts +31 -0
- package/src/stripe-clock.ts +153 -0
- package/tests/entitlements/goldens/cancel_at_period_end_keeps_access_until_end.yaml +36 -0
- package/tests/entitlements/goldens/cancel_revokes_access.yaml +18 -0
- package/tests/entitlements/goldens/dispute_revokes_or_flags.yaml +29 -0
- package/tests/entitlements/goldens/duplicate_webhook_idempotent.yaml +33 -0
- package/tests/entitlements/goldens/paid_grants_access.yaml +11 -0
- package/tests/entitlements/goldens/past_due_denies_until_recovery.yaml +26 -0
- package/tests/entitlements/goldens/refund_revokes_access.yaml +18 -0
- package/tests/entitlements/goldens/seat_qty_vs_db_cap.yaml +29 -0
- package/tests/entitlements/goldens/trial_to_paid_grants.yaml +19 -0
- package/tests/entitlements/goldens/unpaid_after_retries_revokes.yaml +40 -0
- package/tests/entitlements/goldens/upgrade_plus_cape_race.yaml +46 -0
package/CHANGELOG.md
ADDED
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
# Changelog
|
|
2
|
+
|
|
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.
|
|
17
|
+
|
|
18
|
+
## [0.4.0] - 2026-10-16
|
|
19
|
+
|
|
20
|
+
Tag-ready pack. npm `gatefit@0.4.0` is live (`npx gatefit`, https://www.npmjs.com/package/gatefit). The docs site is live at https://gatefit.io. Polar checkout is the committed Gatefit Indie License link.
|
|
21
|
+
|
|
22
|
+
### Added
|
|
23
|
+
|
|
24
|
+
- Publishable `gatefit@0.4.0` CLI (`bin/gatefit.js`) and composite GitHub Action.
|
|
25
|
+
- Callers, once a human tags the release commit: `uses: gatefit/gatefit@v0.4.0` and `uses: gatefit/gatefit/.github/actions/gatefit@v0.4.0`.
|
|
26
|
+
- Fixture goldens for paid, cancel, refund, trial, past_due, duplicate delivery, seats, cancel-at-period-end, upgrade race, dispute, and unpaid retries.
|
|
27
|
+
- Starlight docs app under `site/` and GHA-free `scripts/deploy-site.sh`.
|
|
28
|
+
- `npm publish --dry-run` inside `./ci-local.sh`, with tarball checks that the bin is present and `site/` and secrets are absent.
|
|
29
|
+
- Pro pack license gate. `GATEFIT_LICENSE_KEY` is checked with Polar's public validate endpoint. Free packs do not call it. Chaos mode is not in this release.
|
|
30
|
+
|
|
31
|
+
### Not in this version
|
|
32
|
+
|
|
33
|
+
- A docs deploy from this changelog. The site is already live at https://gatefit.io.
|
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 edipert
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
CHANGED
|
@@ -1,3 +1,100 @@
|
|
|
1
|
-
#
|
|
1
|
+
# gatefit
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
Stripe Entitlement Edge Pack — CI that asserts the access DB survives ugly billing transitions.
|
|
4
|
+
|
|
5
|
+
Fixture-first S1–S6 goldens, an `npx gatefit` CLI, and a GitHub Action. Node 22+. Not a billing UI.
|
|
6
|
+
|
|
7
|
+
## Distribution
|
|
8
|
+
|
|
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
|
+
|
|
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 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
|
+
- **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
|
+
|
|
15
|
+
## Install / run
|
|
16
|
+
|
|
17
|
+
```bash
|
|
18
|
+
npx gatefit --version
|
|
19
|
+
npx gatefit adapter-check
|
|
20
|
+
npx gatefit run --suite paid_grants_access --adapter ./src/your-access-adapter.ts
|
|
21
|
+
```
|
|
22
|
+
|
|
23
|
+
This repo is `0.5.0`. Registry: https://www.npmjs.com/package/gatefit
|
|
24
|
+
|
|
25
|
+
From a clone:
|
|
26
|
+
|
|
27
|
+
```bash
|
|
28
|
+
npm install
|
|
29
|
+
node bin/gatefit.js --version
|
|
30
|
+
node bin/gatefit.js adapter-check
|
|
31
|
+
node bin/gatefit.js run --suite paid_grants_access --adapter ./src/your-access-adapter.ts
|
|
32
|
+
```
|
|
33
|
+
|
|
34
|
+
Named verify gate (fixture-first; do not set `GATEFIT_LIVE_CLOCK`):
|
|
35
|
+
|
|
36
|
+
```bash
|
|
37
|
+
bash ./ci-local.sh
|
|
38
|
+
```
|
|
39
|
+
|
|
40
|
+
Bring your own AccessAdapter in ≤30 minutes: [docs/bring-your-adapter.md](docs/bring-your-adapter.md).
|
|
41
|
+
|
|
42
|
+
## GitHub Action
|
|
43
|
+
|
|
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
|
+
|
|
46
|
+
```yaml
|
|
47
|
+
- uses: actions/setup-node@v4
|
|
48
|
+
with:
|
|
49
|
+
node-version: "22"
|
|
50
|
+
- uses: gatefit/gatefit@v0.5.0
|
|
51
|
+
with:
|
|
52
|
+
adapter: src/your-access-adapter.ts
|
|
53
|
+
```
|
|
54
|
+
|
|
55
|
+
Nested path still works:
|
|
56
|
+
|
|
57
|
+
```yaml
|
|
58
|
+
- uses: gatefit/gatefit/.github/actions/gatefit@v0.5.0
|
|
59
|
+
with:
|
|
60
|
+
adapter: src/your-access-adapter.ts
|
|
61
|
+
```
|
|
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 notes [docs/release-v0.5.0.md](docs/release-v0.5.0.md).
|
|
64
|
+
|
|
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
|
+
|
|
67
|
+
Opt-in live Test Clocks (not default CI): [docs/live-clock.md](docs/live-clock.md).
|
|
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
|
+
|
|
84
|
+
## License
|
|
85
|
+
|
|
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.
|
|
87
|
+
|
|
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).
|
|
95
|
+
|
|
96
|
+
```bash
|
|
97
|
+
export GATEFIT_LICENSE_KEY='GATEFIT-…'
|
|
98
|
+
```
|
|
99
|
+
|
|
100
|
+
Validation is `POST https://api.polar.sh/v1/customer-portal/license-keys/validate` with the key and organization id `68556619-45ab-4a88-8ad8-08606c2b820e`. No Polar API token. A valid result is cached for 24 hours by sha256, never as the raw key. If Polar is unreachable, a cached valid result is reused with a warning. With no cache, the command warns and still runs. Details: [docs/license.md](docs/license.md).
|
package/action.yml
ADDED
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
name: Gatefit
|
|
2
|
+
description: Run Gatefit S1–S6 entitlement goldens against an AccessAdapter (access DB, not Stripe HTTP 200)
|
|
3
|
+
author: gatefit
|
|
4
|
+
branding:
|
|
5
|
+
icon: shield
|
|
6
|
+
color: green
|
|
7
|
+
inputs:
|
|
8
|
+
adapter:
|
|
9
|
+
description: Path to the AccessAdapter module, relative to the caller workspace
|
|
10
|
+
required: true
|
|
11
|
+
suite:
|
|
12
|
+
description: Golden file, directory, or id. Defaults to bundled S1–S6 goldens
|
|
13
|
+
required: false
|
|
14
|
+
default: ""
|
|
15
|
+
runs:
|
|
16
|
+
using: composite
|
|
17
|
+
steps:
|
|
18
|
+
- name: Run gatefit
|
|
19
|
+
shell: bash
|
|
20
|
+
env:
|
|
21
|
+
GATEFIT_ADAPTER_INPUT: ${{ inputs.adapter }}
|
|
22
|
+
GATEFIT_SUITE_INPUT: ${{ inputs.suite }}
|
|
23
|
+
run: bash "${GITHUB_ACTION_PATH}/scripts/action-run.sh"
|
package/bin/gatefit.js
ADDED
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import { createRequire } from "node:module";
|
|
3
|
+
import { spawnSync } from "node:child_process";
|
|
4
|
+
import { fileURLToPath } from "node:url";
|
|
5
|
+
import path from "node:path";
|
|
6
|
+
|
|
7
|
+
const root = path.join(path.dirname(fileURLToPath(import.meta.url)), "..");
|
|
8
|
+
const cli = path.join(root, "src/cli.ts");
|
|
9
|
+
const requireFromPackage = createRequire(path.join(root, "package.json"));
|
|
10
|
+
|
|
11
|
+
function resolveTsxCli() {
|
|
12
|
+
try {
|
|
13
|
+
return path.join(path.dirname(requireFromPackage.resolve("tsx/package.json")), "dist/cli.mjs");
|
|
14
|
+
} catch {
|
|
15
|
+
return undefined;
|
|
16
|
+
}
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
const tsxCli = resolveTsxCli();
|
|
20
|
+
if (!tsxCli) {
|
|
21
|
+
console.error("gatefit: missing tsx (run npm install so the gatefit package can resolve tsx)");
|
|
22
|
+
process.exit(1);
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
process.env.GATEFIT_PACKAGE_ROOT ??= root;
|
|
26
|
+
process.env.INIT_CWD ??= process.cwd();
|
|
27
|
+
const result = spawnSync(process.execPath, [tsxCli, cli, ...process.argv.slice(2)], {
|
|
28
|
+
stdio: "inherit",
|
|
29
|
+
cwd: process.cwd(),
|
|
30
|
+
env: process.env,
|
|
31
|
+
});
|
|
32
|
+
process.exit(result.status === null ? 1 : result.status);
|
package/package.json
CHANGED
|
@@ -1,6 +1,58 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "gatefit",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"
|
|
5
|
-
"
|
|
6
|
-
|
|
3
|
+
"version": "0.5.0",
|
|
4
|
+
"description": "Stripe Entitlement Edge Pack — CI that asserts the access DB survives ugly billing transitions",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"license": "MIT",
|
|
7
|
+
"author": "gatefit",
|
|
8
|
+
"bin": {
|
|
9
|
+
"gatefit": "./bin/gatefit.js"
|
|
10
|
+
},
|
|
11
|
+
"files": [
|
|
12
|
+
"bin",
|
|
13
|
+
"src",
|
|
14
|
+
"tests/entitlements/goldens",
|
|
15
|
+
"action.yml",
|
|
16
|
+
"scripts/action-run.sh",
|
|
17
|
+
"LICENSE",
|
|
18
|
+
"README.md",
|
|
19
|
+
"CHANGELOG.md"
|
|
20
|
+
],
|
|
21
|
+
"engines": {
|
|
22
|
+
"node": ">=22"
|
|
23
|
+
},
|
|
24
|
+
"scripts": {
|
|
25
|
+
"gatefit": "tsx src/cli.ts",
|
|
26
|
+
"typecheck": "tsc --noEmit",
|
|
27
|
+
"test": "tsx --test --test-concurrency=1 tests/entitlements/*.test.ts"
|
|
28
|
+
},
|
|
29
|
+
"repository": {
|
|
30
|
+
"type": "git",
|
|
31
|
+
"url": "git+https://github.com/gatefit/gatefit.git"
|
|
32
|
+
},
|
|
33
|
+
"bugs": {
|
|
34
|
+
"url": "https://github.com/gatefit/gatefit/issues"
|
|
35
|
+
},
|
|
36
|
+
"homepage": "https://github.com/gatefit/gatefit#readme",
|
|
37
|
+
"keywords": [
|
|
38
|
+
"stripe",
|
|
39
|
+
"entitlements",
|
|
40
|
+
"access",
|
|
41
|
+
"ci",
|
|
42
|
+
"webhooks",
|
|
43
|
+
"billing"
|
|
44
|
+
],
|
|
45
|
+
"publishConfig": {
|
|
46
|
+
"access": "public"
|
|
47
|
+
},
|
|
48
|
+
"dependencies": {
|
|
49
|
+
"better-sqlite3": "^13.0.3",
|
|
50
|
+
"tsx": "^4.20.5",
|
|
51
|
+
"yaml": "^2.8.1"
|
|
52
|
+
},
|
|
53
|
+
"devDependencies": {
|
|
54
|
+
"@types/better-sqlite3": "^9.6.0",
|
|
55
|
+
"@types/node": "^22.18.0",
|
|
56
|
+
"typescript": "^5.9.2"
|
|
57
|
+
}
|
|
58
|
+
}
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
#!/usr/bin/env bash
|
|
2
|
+
set -euo pipefail
|
|
3
|
+
if [[ -f "${GITHUB_ACTION_PATH}/package.json" && -f "${GITHUB_ACTION_PATH}/bin/gatefit.js" ]]; then
|
|
4
|
+
GATEFIT_ROOT="${GITHUB_ACTION_PATH}"
|
|
5
|
+
else
|
|
6
|
+
GATEFIT_ROOT="$(cd "${GITHUB_ACTION_PATH}/../../.." && pwd)"
|
|
7
|
+
fi
|
|
8
|
+
ADAPTER="$(cd "${GITHUB_WORKSPACE}" && realpath "${GATEFIT_ADAPTER_INPUT}")"
|
|
9
|
+
if [[ -n "${GATEFIT_SUITE_INPUT}" && -e "${GITHUB_WORKSPACE}/${GATEFIT_SUITE_INPUT}" ]]; then
|
|
10
|
+
SUITE="$(cd "${GITHUB_WORKSPACE}" && realpath "${GATEFIT_SUITE_INPUT}")"
|
|
11
|
+
elif [[ -n "${GATEFIT_SUITE_INPUT}" ]]; then
|
|
12
|
+
SUITE="${GATEFIT_SUITE_INPUT}"
|
|
13
|
+
else
|
|
14
|
+
SUITE="${GATEFIT_ROOT}/tests/entitlements/goldens"
|
|
15
|
+
fi
|
|
16
|
+
cd "${GATEFIT_ROOT}"
|
|
17
|
+
npm install --ignore-scripts
|
|
18
|
+
node "${GATEFIT_ROOT}/bin/gatefit.js" run --suite "${SUITE}" --adapter "${ADAPTER}"
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
import { pathToFileURL } from "node:url";
|
|
2
|
+
import fs from "node:fs";
|
|
3
|
+
import { resolveUserOrPackage } from "./paths.ts";
|
|
4
|
+
|
|
5
|
+
export type AccessSnapshot = {
|
|
6
|
+
locked: boolean;
|
|
7
|
+
plan?: string;
|
|
8
|
+
seats?: number;
|
|
9
|
+
features?: string[];
|
|
10
|
+
};
|
|
11
|
+
|
|
12
|
+
export type BillingEvent = {
|
|
13
|
+
id: string;
|
|
14
|
+
type: string;
|
|
15
|
+
customerId: string;
|
|
16
|
+
plan?: string;
|
|
17
|
+
seats?: number;
|
|
18
|
+
features?: string[];
|
|
19
|
+
paid?: boolean;
|
|
20
|
+
canceled?: boolean;
|
|
21
|
+
refunded?: boolean;
|
|
22
|
+
status?: string;
|
|
23
|
+
httpStatus?: number;
|
|
24
|
+
stripeHttpStatus?: number;
|
|
25
|
+
cancel_at_period_end?: boolean;
|
|
26
|
+
period_end?: number;
|
|
27
|
+
now?: number;
|
|
28
|
+
/** Original timeline position. Chaos folds on this so arrival order is not access. */
|
|
29
|
+
created?: number;
|
|
30
|
+
};
|
|
31
|
+
|
|
32
|
+
export type AccessAdapter = {
|
|
33
|
+
apply(event: BillingEvent): void | Promise<void>;
|
|
34
|
+
snapshot(customerId: string): AccessSnapshot | Promise<AccessSnapshot>;
|
|
35
|
+
reset?: () => void | Promise<void>;
|
|
36
|
+
};
|
|
37
|
+
|
|
38
|
+
export type CompareResult = {
|
|
39
|
+
ok: boolean;
|
|
40
|
+
mismatches: string[];
|
|
41
|
+
};
|
|
42
|
+
|
|
43
|
+
const LIVE_KEY = /\bsk_live(?:_[A-Za-z0-9_]*)?/;
|
|
44
|
+
|
|
45
|
+
export function assertNoLiveStripeSecret(haystack: string): void {
|
|
46
|
+
if (LIVE_KEY.test(haystack)) {
|
|
47
|
+
throw new Error("refuse sk_live: Gatefit will not load a live Stripe secret");
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
export function compareAccess(actual: AccessSnapshot, expected: AccessSnapshot): CompareResult {
|
|
52
|
+
const mismatches: string[] = [];
|
|
53
|
+
const keys = Object.keys(expected) as (keyof AccessSnapshot)[];
|
|
54
|
+
for (const key of keys) {
|
|
55
|
+
const want = expected[key];
|
|
56
|
+
const got = actual[key];
|
|
57
|
+
if (JSON.stringify(got) !== JSON.stringify(want)) {
|
|
58
|
+
mismatches.push(`${String(key)}: expected ${JSON.stringify(want)}, got ${JSON.stringify(got)}`);
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
return { ok: mismatches.length === 0, mismatches };
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
export async function loadAdapter(spec: string): Promise<AccessAdapter> {
|
|
65
|
+
const resolved = resolveUserOrPackage(spec);
|
|
66
|
+
const source = fs.readFileSync(resolved, "utf8");
|
|
67
|
+
assertNoLiveStripeSecret(source);
|
|
68
|
+
assertNoLiveStripeSecret(JSON.stringify(process.env));
|
|
69
|
+
const mod = (await import(pathToFileURL(resolved).href)) as {
|
|
70
|
+
createAdapter?: () => AccessAdapter;
|
|
71
|
+
default?: AccessAdapter | (() => AccessAdapter);
|
|
72
|
+
adapter?: AccessAdapter;
|
|
73
|
+
};
|
|
74
|
+
const adapter = instantiateAdapter(mod);
|
|
75
|
+
if (!adapter || typeof adapter.apply !== "function" || typeof adapter.snapshot !== "function") {
|
|
76
|
+
throw new Error(`adapter ${spec} must export createAdapter() or AccessAdapter with apply + snapshot`);
|
|
77
|
+
}
|
|
78
|
+
return adapter;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
function instantiateAdapter(mod: {
|
|
82
|
+
createAdapter?: () => AccessAdapter;
|
|
83
|
+
default?: AccessAdapter | (() => AccessAdapter);
|
|
84
|
+
adapter?: AccessAdapter;
|
|
85
|
+
}): AccessAdapter | undefined {
|
|
86
|
+
if (typeof mod.createAdapter === "function") return mod.createAdapter();
|
|
87
|
+
const candidate = mod.default ?? mod.adapter;
|
|
88
|
+
if (typeof candidate === "function") return candidate();
|
|
89
|
+
return candidate;
|
|
90
|
+
}
|
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
import type { AccessSnapshot, BillingEvent } from "./access-adapter.ts";
|
|
2
|
+
|
|
3
|
+
export type AccessStore = {
|
|
4
|
+
rows: Map<string, AccessSnapshot>;
|
|
5
|
+
seen: Set<string>;
|
|
6
|
+
events: BillingEvent[];
|
|
7
|
+
arrival: number;
|
|
8
|
+
};
|
|
9
|
+
|
|
10
|
+
export function emptyStore(): AccessStore {
|
|
11
|
+
return { rows: new Map(), seen: new Set(), events: [], arrival: 0 };
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export function isPaid(event: BillingEvent): boolean {
|
|
15
|
+
return event.type === "invoice.paid" || event.paid === true;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export function isImmediateCancel(event: BillingEvent): boolean {
|
|
19
|
+
return (
|
|
20
|
+
event.canceled === true ||
|
|
21
|
+
event.type === "customer.subscription.deleted" ||
|
|
22
|
+
event.type === "customer.subscription.canceled"
|
|
23
|
+
);
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export function isCanceled(event: BillingEvent): boolean {
|
|
27
|
+
return isImmediateCancel(event);
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export function isCapePeriodEnded(event: BillingEvent): boolean {
|
|
31
|
+
return (
|
|
32
|
+
event.cancel_at_period_end === true &&
|
|
33
|
+
typeof event.now === "number" &&
|
|
34
|
+
typeof event.period_end === "number" &&
|
|
35
|
+
event.now >= event.period_end
|
|
36
|
+
);
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export function isDispute(event: BillingEvent): boolean {
|
|
40
|
+
return (
|
|
41
|
+
event.type === "charge.dispute.created" ||
|
|
42
|
+
event.type === "radar.early_fraud_warning.created"
|
|
43
|
+
);
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export function isRefund(event: BillingEvent): boolean {
|
|
47
|
+
return event.refunded === true || event.type === "charge.refunded";
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export function isUnpaidExhaust(event: BillingEvent): boolean {
|
|
51
|
+
return event.status === "unpaid" || event.type === "invoice.marked_uncollectible";
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
export function isPastDue(event: BillingEvent): boolean {
|
|
55
|
+
return event.status === "past_due" || event.type === "invoice.payment_failed";
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
export function isTrialing(event: BillingEvent): boolean {
|
|
59
|
+
return event.status === "trialing";
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
function withEntitlements(base: AccessSnapshot, event: BillingEvent): AccessSnapshot {
|
|
63
|
+
const next: AccessSnapshot = { ...base };
|
|
64
|
+
if (event.plan !== undefined) next.plan = event.plan;
|
|
65
|
+
if (event.seats !== undefined) next.seats = event.seats;
|
|
66
|
+
if (event.features !== undefined) next.features = event.features;
|
|
67
|
+
return next;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
export function nextSnapshot(current: AccessSnapshot, event: BillingEvent): AccessSnapshot {
|
|
71
|
+
if (isDispute(event)) {
|
|
72
|
+
return { ...current, locked: true, features: [] };
|
|
73
|
+
}
|
|
74
|
+
if (
|
|
75
|
+
isRefund(event) ||
|
|
76
|
+
isImmediateCancel(event) ||
|
|
77
|
+
isPastDue(event) ||
|
|
78
|
+
isUnpaidExhaust(event) ||
|
|
79
|
+
isTrialing(event)
|
|
80
|
+
) {
|
|
81
|
+
return { ...current, locked: true };
|
|
82
|
+
}
|
|
83
|
+
if (isCapePeriodEnded(event)) {
|
|
84
|
+
return { ...current, locked: true };
|
|
85
|
+
}
|
|
86
|
+
if (isPaid(event)) {
|
|
87
|
+
return withEntitlements({ ...current, locked: false }, event);
|
|
88
|
+
}
|
|
89
|
+
if (
|
|
90
|
+
event.type === "customer.subscription.updated" &&
|
|
91
|
+
(event.cancel_at_period_end === true ||
|
|
92
|
+
event.seats !== undefined ||
|
|
93
|
+
event.plan !== undefined ||
|
|
94
|
+
event.features !== undefined)
|
|
95
|
+
) {
|
|
96
|
+
return withEntitlements(current, event);
|
|
97
|
+
}
|
|
98
|
+
return current;
|
|
99
|
+
}
|
|
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
|
+
|
|
115
|
+
export function applyBillingEvent(store: AccessStore, event: BillingEvent): void {
|
|
116
|
+
if (store.seen.has(event.id)) return;
|
|
117
|
+
store.seen.add(event.id);
|
|
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);
|
|
123
|
+
}
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
import type { AccessAdapter } from "../access-adapter.ts";
|
|
2
|
+
import { applyBillingEvent, emptyStore } from "../access-rules.ts";
|
|
3
|
+
|
|
4
|
+
export function createAdapter(): AccessAdapter {
|
|
5
|
+
const store = emptyStore();
|
|
6
|
+
return {
|
|
7
|
+
apply(event) {
|
|
8
|
+
applyBillingEvent(store, event);
|
|
9
|
+
},
|
|
10
|
+
snapshot(customerId) {
|
|
11
|
+
return store.rows.get(customerId) ?? { locked: true };
|
|
12
|
+
},
|
|
13
|
+
reset() {
|
|
14
|
+
store.rows.clear();
|
|
15
|
+
store.seen.clear();
|
|
16
|
+
store.events.length = 0;
|
|
17
|
+
store.arrival = 0;
|
|
18
|
+
},
|
|
19
|
+
};
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export default createAdapter;
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
import type { AccessAdapter, AccessSnapshot, BillingEvent } from "../access-adapter.ts";
|
|
2
|
+
|
|
3
|
+
function stripeLooksPaid(event: BillingEvent): boolean {
|
|
4
|
+
return event.httpStatus === 200 || event.stripeHttpStatus === 200;
|
|
5
|
+
}
|
|
6
|
+
|
|
7
|
+
export function createAdapter(): AccessAdapter {
|
|
8
|
+
const rows = new Map<string, AccessSnapshot>();
|
|
9
|
+
return {
|
|
10
|
+
apply(event: BillingEvent) {
|
|
11
|
+
// Anti-pattern: treat Stripe HTTP 200 as access proof.
|
|
12
|
+
if (stripeLooksPaid(event)) {
|
|
13
|
+
rows.set(event.customerId, { locked: false, plan: event.plan });
|
|
14
|
+
}
|
|
15
|
+
},
|
|
16
|
+
snapshot(customerId: string) {
|
|
17
|
+
return rows.get(customerId) ?? { locked: true };
|
|
18
|
+
},
|
|
19
|
+
reset() {
|
|
20
|
+
rows.clear();
|
|
21
|
+
},
|
|
22
|
+
};
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export default createAdapter;
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
import { createRequire } from "node:module";
|
|
2
|
+
import type { AccessAdapter, AccessSnapshot, BillingEvent } from "../access-adapter.ts";
|
|
3
|
+
import { nextSnapshot } from "../access-rules.ts";
|
|
4
|
+
|
|
5
|
+
const require = createRequire(import.meta.url);
|
|
6
|
+
const Database = require("better-sqlite3") as typeof import("better-sqlite3");
|
|
7
|
+
|
|
8
|
+
type AccessRow = {
|
|
9
|
+
customer_id: string;
|
|
10
|
+
locked: number;
|
|
11
|
+
plan: string | null;
|
|
12
|
+
seats: number | null;
|
|
13
|
+
features: string | null;
|
|
14
|
+
};
|
|
15
|
+
|
|
16
|
+
function rowToSnapshot(row: AccessRow | undefined): AccessSnapshot {
|
|
17
|
+
if (!row) return { locked: true };
|
|
18
|
+
const snap: AccessSnapshot = { locked: row.locked === 1 };
|
|
19
|
+
if (row.plan != null) snap.plan = row.plan;
|
|
20
|
+
if (row.seats != null) snap.seats = row.seats;
|
|
21
|
+
if (row.features != null) snap.features = JSON.parse(row.features) as string[];
|
|
22
|
+
return snap;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export function createAdapter(): AccessAdapter {
|
|
26
|
+
const filename = process.env.GATEFIT_SQLITE_PATH ?? ":memory:";
|
|
27
|
+
const db = new Database(filename);
|
|
28
|
+
db.exec(`
|
|
29
|
+
CREATE TABLE IF NOT EXISTS access (
|
|
30
|
+
customer_id TEXT PRIMARY KEY,
|
|
31
|
+
locked INTEGER NOT NULL,
|
|
32
|
+
plan TEXT,
|
|
33
|
+
seats INTEGER,
|
|
34
|
+
features TEXT
|
|
35
|
+
);
|
|
36
|
+
CREATE TABLE IF NOT EXISTS seen_events (
|
|
37
|
+
event_id TEXT PRIMARY KEY
|
|
38
|
+
);
|
|
39
|
+
`);
|
|
40
|
+
const seenAdd = db.prepare("INSERT OR IGNORE INTO seen_events (event_id) VALUES (?)");
|
|
41
|
+
const accessGet = db.prepare(
|
|
42
|
+
"SELECT customer_id, locked, plan, seats, features FROM access WHERE customer_id = ?",
|
|
43
|
+
);
|
|
44
|
+
const accessUpsert = db.prepare(`
|
|
45
|
+
INSERT INTO access (customer_id, locked, plan, seats, features)
|
|
46
|
+
VALUES (@customer_id, @locked, @plan, @seats, @features)
|
|
47
|
+
ON CONFLICT(customer_id) DO UPDATE SET
|
|
48
|
+
locked = excluded.locked,
|
|
49
|
+
plan = excluded.plan,
|
|
50
|
+
seats = excluded.seats,
|
|
51
|
+
features = excluded.features
|
|
52
|
+
`);
|
|
53
|
+
const clearAccess = db.prepare("DELETE FROM access");
|
|
54
|
+
const clearSeen = db.prepare("DELETE FROM seen_events");
|
|
55
|
+
const applyEvent = db.transaction((event: BillingEvent) => {
|
|
56
|
+
const inserted = seenAdd.run(event.id);
|
|
57
|
+
if (inserted.changes === 0) return;
|
|
58
|
+
const current = rowToSnapshot(accessGet.get(event.customerId) as AccessRow | undefined);
|
|
59
|
+
const next = nextSnapshot(current, event);
|
|
60
|
+
accessUpsert.run({
|
|
61
|
+
customer_id: event.customerId,
|
|
62
|
+
locked: next.locked ? 1 : 0,
|
|
63
|
+
plan: next.plan ?? null,
|
|
64
|
+
seats: next.seats ?? null,
|
|
65
|
+
features: next.features ? JSON.stringify(next.features) : null,
|
|
66
|
+
});
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
return {
|
|
70
|
+
apply(event: BillingEvent) {
|
|
71
|
+
applyEvent(event);
|
|
72
|
+
},
|
|
73
|
+
snapshot(customerId: string) {
|
|
74
|
+
return rowToSnapshot(accessGet.get(customerId) as AccessRow | undefined);
|
|
75
|
+
},
|
|
76
|
+
reset() {
|
|
77
|
+
clearAccess.run();
|
|
78
|
+
clearSeen.run();
|
|
79
|
+
},
|
|
80
|
+
};
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
export default createAdapter;
|