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/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
ADDED
|
@@ -0,0 +1,166 @@
|
|
|
1
|
+
import fs from "node:fs";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
import { parseArgs } from "node:util";
|
|
4
|
+
import { loadAdapter } from "./access-adapter.ts";
|
|
5
|
+
import { listGoldens, loadGolden, requireReset, runGolden } from "./fixture-driver.ts";
|
|
6
|
+
import { LIVE_SCENARIO_IDS, runLiveLane, type LiveScenarioId } from "./live-scenarios.ts";
|
|
7
|
+
import { resolveChaosSeed } from "./chaos.ts";
|
|
8
|
+
import { ensureProLicense } from "./license.ts";
|
|
9
|
+
import { getProPack, isProPack } from "./packs.ts";
|
|
10
|
+
import { goldenDir, packageRoot, readPackageVersion, resolveUserOrPackage, userCwd } from "./paths.ts";
|
|
11
|
+
import {
|
|
12
|
+
createStripeClockClient,
|
|
13
|
+
requireLiveClockEnabled,
|
|
14
|
+
requireTestModeSecret,
|
|
15
|
+
} from "./stripe-clock.ts";
|
|
16
|
+
|
|
17
|
+
const S1 = () => path.join(goldenDir(), "paid_grants_access.yaml");
|
|
18
|
+
const NAIVE = () => path.join(packageRoot(), "src/adapters/naive.ts");
|
|
19
|
+
const HARDENED = () => path.join(packageRoot(), "src/adapters/hardened.ts");
|
|
20
|
+
|
|
21
|
+
function usage(): string {
|
|
22
|
+
return [
|
|
23
|
+
"Usage:",
|
|
24
|
+
" gatefit run --suite <dir|file|id> --adapter <path>",
|
|
25
|
+
" gatefit run --suite chaos --adapter <path> [--seed <value>]",
|
|
26
|
+
" gatefit adapter-check",
|
|
27
|
+
" gatefit live --adapter <path> [--scenario <id>]",
|
|
28
|
+
" gatefit --version",
|
|
29
|
+
].join("\n");
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
function resolveSuiteFiles(suite: string): string[] {
|
|
33
|
+
const candidates = [
|
|
34
|
+
path.resolve(userCwd(), suite),
|
|
35
|
+
path.resolve(packageRoot(), suite),
|
|
36
|
+
path.join(goldenDir(), `${suite}.yaml`),
|
|
37
|
+
];
|
|
38
|
+
for (const candidate of candidates) {
|
|
39
|
+
if (!fs.existsSync(candidate)) continue;
|
|
40
|
+
const files = listGoldens(candidate);
|
|
41
|
+
if (files.length > 0) return files;
|
|
42
|
+
}
|
|
43
|
+
throw new Error(`suite not found: ${suite}`);
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
async function cmdRun(suite: string, adapterPath: string, seed: string): Promise<number> {
|
|
47
|
+
if (isProPack(suite)) {
|
|
48
|
+
const gate = await ensureProLicense();
|
|
49
|
+
if (!gate.ok) {
|
|
50
|
+
console.error(gate.message);
|
|
51
|
+
return 1;
|
|
52
|
+
}
|
|
53
|
+
if (gate.warning) console.error(gate.warning);
|
|
54
|
+
const pack = getProPack(suite);
|
|
55
|
+
if (pack?.run) return pack.run({ adapterPath, seed });
|
|
56
|
+
console.error(`gatefit: PASS ${suite}`);
|
|
57
|
+
return 0;
|
|
58
|
+
}
|
|
59
|
+
const adapter = await loadAdapter(resolveUserOrPackage(adapterPath));
|
|
60
|
+
const files = resolveSuiteFiles(suite);
|
|
61
|
+
if (files.length === 0) {
|
|
62
|
+
throw new Error(`suite not found: ${suite}`);
|
|
63
|
+
}
|
|
64
|
+
requireReset(adapter, files.length);
|
|
65
|
+
let failed = 0;
|
|
66
|
+
for (const file of files) {
|
|
67
|
+
const golden = loadGolden(file);
|
|
68
|
+
const result = await runGolden(adapter, golden);
|
|
69
|
+
if (result.ok) {
|
|
70
|
+
console.error(`gatefit: PASS ${golden.id}`);
|
|
71
|
+
} else {
|
|
72
|
+
failed += 1;
|
|
73
|
+
console.error(`gatefit: FAIL ${golden.id}`);
|
|
74
|
+
for (const step of result.results.filter((s) => !s.ok)) {
|
|
75
|
+
console.error(` step ${step.stepIndex}: ${step.mismatches.join("; ")}`);
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
return failed === 0 ? 0 : 1;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
async function cmdAdapterCheck(): Promise<number> {
|
|
83
|
+
const suite = loadGolden(S1());
|
|
84
|
+
const naive = await runGolden(await loadAdapter(NAIVE()), suite);
|
|
85
|
+
const hardened = await runGolden(await loadAdapter(HARDENED()), suite);
|
|
86
|
+
const naiveFailed = !naive.ok;
|
|
87
|
+
const hardenedPassed = hardened.ok;
|
|
88
|
+
if (naiveFailed) console.error("gatefit adapter-check: naive failed S1 (expected)");
|
|
89
|
+
else console.error("gatefit adapter-check: naive unexpectedly passed S1");
|
|
90
|
+
if (hardenedPassed) console.error("gatefit adapter-check: hardened passed S1 (expected)");
|
|
91
|
+
else console.error("gatefit adapter-check: hardened failed S1");
|
|
92
|
+
return naiveFailed && hardenedPassed ? 0 : 1;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
function isVersionFlag(arg: string | undefined): boolean {
|
|
96
|
+
return arg === "--version" || arg === "-v";
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
async function main(argv: string[]): Promise<number> {
|
|
100
|
+
if (isVersionFlag(argv[0])) {
|
|
101
|
+
console.log(`gatefit ${readPackageVersion()}`);
|
|
102
|
+
return 0;
|
|
103
|
+
}
|
|
104
|
+
const command = argv[0];
|
|
105
|
+
if (command === "adapter-check") {
|
|
106
|
+
return cmdAdapterCheck();
|
|
107
|
+
}
|
|
108
|
+
if (command === "run") {
|
|
109
|
+
const { values } = parseArgs({
|
|
110
|
+
args: argv.slice(1),
|
|
111
|
+
options: {
|
|
112
|
+
suite: { type: "string" },
|
|
113
|
+
adapter: { type: "string" },
|
|
114
|
+
seed: { type: "string" },
|
|
115
|
+
},
|
|
116
|
+
});
|
|
117
|
+
if (!values.suite || !values.adapter) {
|
|
118
|
+
console.error(usage());
|
|
119
|
+
return 1;
|
|
120
|
+
}
|
|
121
|
+
return cmdRun(values.suite, values.adapter, resolveChaosSeed(values.seed, process.env.GATEFIT_CHAOS_SEED));
|
|
122
|
+
}
|
|
123
|
+
if (command === "live") {
|
|
124
|
+
const { values } = parseArgs({
|
|
125
|
+
args: argv.slice(1),
|
|
126
|
+
options: {
|
|
127
|
+
adapter: { type: "string" },
|
|
128
|
+
scenario: { type: "string" },
|
|
129
|
+
},
|
|
130
|
+
});
|
|
131
|
+
if (!values.adapter) {
|
|
132
|
+
console.error(usage());
|
|
133
|
+
return 1;
|
|
134
|
+
}
|
|
135
|
+
requireLiveClockEnabled();
|
|
136
|
+
const secret = requireTestModeSecret();
|
|
137
|
+
const adapter = await loadAdapter(resolveUserOrPackage(values.adapter));
|
|
138
|
+
const client = createStripeClockClient({ secret });
|
|
139
|
+
let scenarios: LiveScenarioId[] | undefined;
|
|
140
|
+
if (values.scenario) {
|
|
141
|
+
if (!(LIVE_SCENARIO_IDS as readonly string[]).includes(values.scenario)) {
|
|
142
|
+
throw new Error(`unknown live scenario: ${values.scenario}`);
|
|
143
|
+
}
|
|
144
|
+
scenarios = [values.scenario as LiveScenarioId];
|
|
145
|
+
}
|
|
146
|
+
const result = await runLiveLane(adapter, client, { scenarios });
|
|
147
|
+
for (const row of result.results) {
|
|
148
|
+
if (row.ok) console.error(`gatefit live: PASS ${row.id}`);
|
|
149
|
+
else {
|
|
150
|
+
console.error(`gatefit live: FAIL ${row.id}`);
|
|
151
|
+
for (const mismatch of row.mismatches) console.error(` ${mismatch}`);
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
return result.ok ? 0 : 1;
|
|
155
|
+
}
|
|
156
|
+
console.error(usage());
|
|
157
|
+
return 1;
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
try {
|
|
161
|
+
const code = await main(process.argv.slice(2));
|
|
162
|
+
process.exit(code);
|
|
163
|
+
} catch (err) {
|
|
164
|
+
console.error(err instanceof Error ? err.message : err);
|
|
165
|
+
process.exit(1);
|
|
166
|
+
}
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
import {
|
|
2
|
+
assertAdvanceWithinTwoIntervals,
|
|
3
|
+
MONTH_SECONDS,
|
|
4
|
+
type StripeClockClient,
|
|
5
|
+
type TestClock,
|
|
6
|
+
} from "./stripe-clock.ts";
|
|
7
|
+
|
|
8
|
+
export type ClockWaitOpts = {
|
|
9
|
+
sleep?: (ms: number) => Promise<void>;
|
|
10
|
+
timeoutMs?: number;
|
|
11
|
+
intervalMs?: number;
|
|
12
|
+
};
|
|
13
|
+
|
|
14
|
+
export async function withTestClock<T>(
|
|
15
|
+
client: StripeClockClient,
|
|
16
|
+
input: { frozenTime: number; name?: string },
|
|
17
|
+
fn: (clock: TestClock) => Promise<T>,
|
|
18
|
+
): Promise<T> {
|
|
19
|
+
const clock = await client.createClock(input);
|
|
20
|
+
try {
|
|
21
|
+
return await fn(clock);
|
|
22
|
+
} finally {
|
|
23
|
+
const deleted = await client.deleteClock(clock.id);
|
|
24
|
+
if (!deleted?.deleted) {
|
|
25
|
+
throw new Error(`clock leak: failed to delete ${clock.id}`);
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export async function advanceUntilReady(
|
|
31
|
+
client: StripeClockClient,
|
|
32
|
+
clockId: string,
|
|
33
|
+
frozenTime: number,
|
|
34
|
+
opts: ClockWaitOpts = {},
|
|
35
|
+
): Promise<TestClock> {
|
|
36
|
+
const current = await client.retrieveClock(clockId);
|
|
37
|
+
assertAdvanceWithinTwoIntervals(current.frozen_time, frozenTime, MONTH_SECONDS);
|
|
38
|
+
await client.advanceClock(clockId, frozenTime);
|
|
39
|
+
const sleep = opts.sleep ?? ((ms: number) => new Promise<void>((resolve) => setTimeout(resolve, ms)));
|
|
40
|
+
const timeoutMs = opts.timeoutMs ?? 120_000;
|
|
41
|
+
const intervalMs = opts.intervalMs ?? 500;
|
|
42
|
+
const deadline = Date.now() + timeoutMs;
|
|
43
|
+
let clock = await client.retrieveClock(clockId);
|
|
44
|
+
while (clock.status !== "ready") {
|
|
45
|
+
if (Date.now() > deadline) {
|
|
46
|
+
throw new Error(`clock ${clockId} did not become ready before timeout`);
|
|
47
|
+
}
|
|
48
|
+
await sleep(intervalMs);
|
|
49
|
+
clock = await client.retrieveClock(clockId);
|
|
50
|
+
}
|
|
51
|
+
return clock;
|
|
52
|
+
}
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
import fs from "node:fs";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
import { parse as parseYaml } from "yaml";
|
|
4
|
+
import {
|
|
5
|
+
type AccessAdapter,
|
|
6
|
+
type AccessSnapshot,
|
|
7
|
+
type BillingEvent,
|
|
8
|
+
compareAccess,
|
|
9
|
+
} from "./access-adapter.ts";
|
|
10
|
+
|
|
11
|
+
export type GoldenStep = {
|
|
12
|
+
event: BillingEvent;
|
|
13
|
+
expect: AccessSnapshot;
|
|
14
|
+
};
|
|
15
|
+
|
|
16
|
+
export type GoldenSuite = {
|
|
17
|
+
id: string;
|
|
18
|
+
subject: string;
|
|
19
|
+
steps: GoldenStep[];
|
|
20
|
+
};
|
|
21
|
+
|
|
22
|
+
export type StepResult = {
|
|
23
|
+
stepIndex: number;
|
|
24
|
+
ok: boolean;
|
|
25
|
+
expected: AccessSnapshot;
|
|
26
|
+
actual: AccessSnapshot;
|
|
27
|
+
mismatches: string[];
|
|
28
|
+
};
|
|
29
|
+
|
|
30
|
+
export function loadGolden(filePath: string): GoldenSuite {
|
|
31
|
+
const raw = fs.readFileSync(filePath, "utf8");
|
|
32
|
+
const data = parseYaml(raw) as GoldenSuite;
|
|
33
|
+
if (!data?.id || !data.subject || !Array.isArray(data.steps) || data.steps.length === 0) {
|
|
34
|
+
throw new Error(`invalid golden: ${filePath} (id, subject, and non-empty steps required)`);
|
|
35
|
+
}
|
|
36
|
+
return data;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export function requireReset(adapter: AccessAdapter, suiteCount: number): void {
|
|
40
|
+
if (suiteCount > 1 && typeof adapter.reset !== "function") {
|
|
41
|
+
throw new Error("adapter.reset is required when running more than one golden");
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export function listGoldens(suitePath: string): string[] {
|
|
46
|
+
const resolved = path.resolve(suitePath);
|
|
47
|
+
const stat = fs.statSync(resolved);
|
|
48
|
+
if (stat.isDirectory()) {
|
|
49
|
+
return fs
|
|
50
|
+
.readdirSync(resolved)
|
|
51
|
+
.filter((name) => name.endsWith(".yaml") || name.endsWith(".yml"))
|
|
52
|
+
.map((name) => path.join(resolved, name))
|
|
53
|
+
.sort();
|
|
54
|
+
}
|
|
55
|
+
return [resolved];
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
export async function runGolden(
|
|
59
|
+
adapter: AccessAdapter,
|
|
60
|
+
suite: GoldenSuite,
|
|
61
|
+
): Promise<{
|
|
62
|
+
ok: boolean;
|
|
63
|
+
results: StepResult[];
|
|
64
|
+
}> {
|
|
65
|
+
if (process.env.GATEFIT_LIVE_CLOCK) {
|
|
66
|
+
throw new Error("fixture-first driver refuses GATEFIT_LIVE_CLOCK; use YAML step order only");
|
|
67
|
+
}
|
|
68
|
+
if (adapter.reset) await adapter.reset();
|
|
69
|
+
const results: StepResult[] = [];
|
|
70
|
+
for (let i = 0; i < suite.steps.length; i += 1) {
|
|
71
|
+
const step = suite.steps[i]!;
|
|
72
|
+
await adapter.apply(step.event);
|
|
73
|
+
const actual = await adapter.snapshot(step.event.customerId ?? suite.subject);
|
|
74
|
+
const cmp = compareAccess(actual, step.expect);
|
|
75
|
+
results.push({
|
|
76
|
+
stepIndex: i,
|
|
77
|
+
ok: cmp.ok,
|
|
78
|
+
expected: step.expect,
|
|
79
|
+
actual,
|
|
80
|
+
mismatches: cmp.mismatches,
|
|
81
|
+
});
|
|
82
|
+
}
|
|
83
|
+
return { ok: results.every((r) => r.ok), results };
|
|
84
|
+
}
|
package/src/license.ts
ADDED
|
@@ -0,0 +1,160 @@
|
|
|
1
|
+
import { createHash } from "node:crypto";
|
|
2
|
+
import fs from "node:fs";
|
|
3
|
+
import os from "node:os";
|
|
4
|
+
import path from "node:path";
|
|
5
|
+
|
|
6
|
+
/** Public Polar customer-portal validate endpoint. No API token. */
|
|
7
|
+
export const POLAR_LICENSE_VALIDATE_URL =
|
|
8
|
+
"https://api.polar.sh/v1/customer-portal/license-keys/validate";
|
|
9
|
+
|
|
10
|
+
/** Gatefit organization id from the public Indie License checkout. */
|
|
11
|
+
export const POLAR_ORGANIZATION_ID = "68556619-45ab-4a88-8ad8-08606c2b820e";
|
|
12
|
+
|
|
13
|
+
/** Indie License benefit id. Scopes validation to that benefit. */
|
|
14
|
+
export const POLAR_BENEFIT_ID = "4e8e6528-a979-4060-b675-7686c752a4a2";
|
|
15
|
+
|
|
16
|
+
export const LICENSE_BUY_URL = "https://gatefit.io/#pricing";
|
|
17
|
+
|
|
18
|
+
/** Fresh cache entries skip the network. Older entries still count when Polar is down. */
|
|
19
|
+
export const LICENSE_CACHE_TTL_MS = 24 * 60 * 60 * 1000;
|
|
20
|
+
|
|
21
|
+
const KEY_PREFIX = "GATEFIT-";
|
|
22
|
+
|
|
23
|
+
export type LicenseDecision =
|
|
24
|
+
| { ok: true; warning?: string }
|
|
25
|
+
| { ok: false; message: string };
|
|
26
|
+
|
|
27
|
+
type CacheEntry = { granted: true; cachedAt: number };
|
|
28
|
+
type CacheFile = { v: 1; entries: Record<string, CacheEntry> };
|
|
29
|
+
|
|
30
|
+
function buy(reason: string): string {
|
|
31
|
+
return `gatefit: ${reason} Buy the Indie License: ${LICENSE_BUY_URL}`;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
function cachePath(env: NodeJS.ProcessEnv): string {
|
|
35
|
+
return env.GATEFIT_LICENSE_CACHE ?? path.join(os.homedir(), ".gatefit", "license-cache.json");
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
function hashKey(key: string): string {
|
|
39
|
+
return createHash("sha256").update(key).digest("hex");
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
function emptyCache(): CacheFile {
|
|
43
|
+
return { v: 1, entries: {} };
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
function readCache(file: string): CacheFile {
|
|
47
|
+
try {
|
|
48
|
+
const parsed = JSON.parse(fs.readFileSync(file, "utf8")) as CacheFile;
|
|
49
|
+
if (parsed?.v !== 1 || !parsed.entries || typeof parsed.entries !== "object") return emptyCache();
|
|
50
|
+
return parsed;
|
|
51
|
+
} catch {
|
|
52
|
+
return emptyCache();
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
function writeCache(file: string, cache: CacheFile): void {
|
|
57
|
+
const dir = path.dirname(file);
|
|
58
|
+
fs.mkdirSync(dir, { recursive: true, mode: 0o700 });
|
|
59
|
+
const tmp = path.join(dir, `.license-cache-${process.pid}.tmp`);
|
|
60
|
+
fs.writeFileSync(tmp, JSON.stringify(cache), { mode: 0o600 });
|
|
61
|
+
fs.renameSync(tmp, file);
|
|
62
|
+
fs.chmodSync(file, 0o600);
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
function remember(file: string, key: string): void {
|
|
66
|
+
const cache = readCache(file);
|
|
67
|
+
cache.entries[hashKey(key)] = { granted: true, cachedAt: Date.now() };
|
|
68
|
+
writeCache(file, cache);
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
function forget(file: string, key: string): void {
|
|
72
|
+
const cache = readCache(file);
|
|
73
|
+
delete cache.entries[hashKey(key)];
|
|
74
|
+
try {
|
|
75
|
+
writeCache(file, cache);
|
|
76
|
+
} catch {
|
|
77
|
+
/* a failed delete must not print the key */
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
function cachedGrant(file: string, key: string): CacheEntry | undefined {
|
|
82
|
+
const entry = readCache(file).entries[hashKey(key)];
|
|
83
|
+
if (!entry || entry.granted !== true || typeof entry.cachedAt !== "number") return undefined;
|
|
84
|
+
return entry;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
function validateUrl(env: NodeJS.ProcessEnv): string {
|
|
88
|
+
return env.GATEFIT_LICENSE_VALIDATE_URL || POLAR_LICENSE_VALIDATE_URL;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
type PolarVerdict = "granted" | "revoked" | "invalid" | "unreachable";
|
|
92
|
+
|
|
93
|
+
async function askPolar(key: string, env: NodeJS.ProcessEnv): Promise<PolarVerdict> {
|
|
94
|
+
try {
|
|
95
|
+
const response = await fetch(validateUrl(env), {
|
|
96
|
+
method: "POST",
|
|
97
|
+
headers: { "content-type": "application/json", accept: "application/json" },
|
|
98
|
+
body: JSON.stringify({
|
|
99
|
+
key,
|
|
100
|
+
organization_id: POLAR_ORGANIZATION_ID,
|
|
101
|
+
benefit_id: POLAR_BENEFIT_ID,
|
|
102
|
+
}),
|
|
103
|
+
signal: AbortSignal.timeout(4000),
|
|
104
|
+
});
|
|
105
|
+
const text = await response.text();
|
|
106
|
+
let payload: { status?: unknown; error?: unknown; detail?: unknown } = {};
|
|
107
|
+
try {
|
|
108
|
+
payload = JSON.parse(text) as typeof payload;
|
|
109
|
+
} catch {
|
|
110
|
+
return response.status >= 500 ? "unreachable" : "invalid";
|
|
111
|
+
}
|
|
112
|
+
const status = typeof payload.status === "string" ? payload.status : "";
|
|
113
|
+
const detail = `${payload.error ?? ""} ${payload.detail ?? ""}`;
|
|
114
|
+
if (response.status >= 500) return "unreachable";
|
|
115
|
+
if (status === "granted" && response.ok) return "granted";
|
|
116
|
+
if (status === "revoked" || /revok/i.test(detail)) return "revoked";
|
|
117
|
+
if (response.status === 404) return "invalid";
|
|
118
|
+
return "invalid";
|
|
119
|
+
} catch {
|
|
120
|
+
return "unreachable";
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
export async function ensureProLicense(env: NodeJS.ProcessEnv = process.env): Promise<LicenseDecision> {
|
|
125
|
+
const key = env.GATEFIT_LICENSE_KEY?.trim() ?? "";
|
|
126
|
+
if (!key) {
|
|
127
|
+
return { ok: false, message: buy("Pro pack requires GATEFIT_LICENSE_KEY.") };
|
|
128
|
+
}
|
|
129
|
+
if (!key.startsWith(KEY_PREFIX)) {
|
|
130
|
+
return { ok: false, message: buy("GATEFIT_LICENSE_KEY is not valid.") };
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
const file = cachePath(env);
|
|
134
|
+
const cached = cachedGrant(file, key);
|
|
135
|
+
if (cached && Date.now() - cached.cachedAt < LICENSE_CACHE_TTL_MS) {
|
|
136
|
+
return { ok: true };
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
const verdict = await askPolar(key, env);
|
|
140
|
+
if (verdict === "granted") {
|
|
141
|
+
try {
|
|
142
|
+
remember(file, key);
|
|
143
|
+
} catch {
|
|
144
|
+
/* running does not depend on writing the cache */
|
|
145
|
+
}
|
|
146
|
+
return { ok: true };
|
|
147
|
+
}
|
|
148
|
+
if (verdict === "revoked") {
|
|
149
|
+
forget(file, key);
|
|
150
|
+
return { ok: false, message: buy("GATEFIT_LICENSE_KEY is revoked.") };
|
|
151
|
+
}
|
|
152
|
+
if (verdict === "invalid") {
|
|
153
|
+
forget(file, key);
|
|
154
|
+
return { ok: false, message: buy("GATEFIT_LICENSE_KEY is not valid.") };
|
|
155
|
+
}
|
|
156
|
+
if (cached) {
|
|
157
|
+
return { ok: true, warning: "gatefit: Polar license check unreachable; using cached valid result" };
|
|
158
|
+
}
|
|
159
|
+
return { ok: true, warning: "gatefit: Polar license check unreachable; running without a cached license" };
|
|
160
|
+
}
|