gatefit 0.0.0-stage → 0.4.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 +20 -0
- package/LICENSE +21 -0
- package/README.md +78 -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 +88 -0
- package/src/access-rules.ts +104 -0
- package/src/adapters/hardened.ts +20 -0
- package/src/adapters/naive.ts +25 -0
- package/src/adapters/sqlite.ts +83 -0
- package/src/cli.ts +163 -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/cli.ts
ADDED
|
@@ -0,0 +1,163 @@
|
|
|
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 { ensureProLicense } from "./license.ts";
|
|
8
|
+
import { getProPack, isProPack } from "./packs.ts";
|
|
9
|
+
import { goldenDir, packageRoot, readPackageVersion, resolveUserOrPackage, userCwd } from "./paths.ts";
|
|
10
|
+
import {
|
|
11
|
+
createStripeClockClient,
|
|
12
|
+
requireLiveClockEnabled,
|
|
13
|
+
requireTestModeSecret,
|
|
14
|
+
} from "./stripe-clock.ts";
|
|
15
|
+
|
|
16
|
+
const S1 = () => path.join(goldenDir(), "paid_grants_access.yaml");
|
|
17
|
+
const NAIVE = () => path.join(packageRoot(), "src/adapters/naive.ts");
|
|
18
|
+
const HARDENED = () => path.join(packageRoot(), "src/adapters/hardened.ts");
|
|
19
|
+
|
|
20
|
+
function usage(): string {
|
|
21
|
+
return [
|
|
22
|
+
"Usage:",
|
|
23
|
+
" gatefit run --suite <dir|file|id> --adapter <path>",
|
|
24
|
+
" gatefit adapter-check",
|
|
25
|
+
" gatefit live --adapter <path> [--scenario <id>]",
|
|
26
|
+
" gatefit --version",
|
|
27
|
+
].join("\n");
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
function resolveSuiteFiles(suite: string): string[] {
|
|
31
|
+
const candidates = [
|
|
32
|
+
path.resolve(userCwd(), suite),
|
|
33
|
+
path.resolve(packageRoot(), suite),
|
|
34
|
+
path.join(goldenDir(), `${suite}.yaml`),
|
|
35
|
+
];
|
|
36
|
+
for (const candidate of candidates) {
|
|
37
|
+
if (!fs.existsSync(candidate)) continue;
|
|
38
|
+
const files = listGoldens(candidate);
|
|
39
|
+
if (files.length > 0) return files;
|
|
40
|
+
}
|
|
41
|
+
throw new Error(`suite not found: ${suite}`);
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
async function cmdRun(suite: string, adapterPath: string): Promise<number> {
|
|
45
|
+
if (isProPack(suite)) {
|
|
46
|
+
const gate = await ensureProLicense();
|
|
47
|
+
if (!gate.ok) {
|
|
48
|
+
console.error(gate.message);
|
|
49
|
+
return 1;
|
|
50
|
+
}
|
|
51
|
+
if (gate.warning) console.error(gate.warning);
|
|
52
|
+
const pack = getProPack(suite);
|
|
53
|
+
if (pack?.run) return pack.run({ adapterPath });
|
|
54
|
+
console.error(`gatefit: PASS ${suite}`);
|
|
55
|
+
return 0;
|
|
56
|
+
}
|
|
57
|
+
const adapter = await loadAdapter(resolveUserOrPackage(adapterPath));
|
|
58
|
+
const files = resolveSuiteFiles(suite);
|
|
59
|
+
if (files.length === 0) {
|
|
60
|
+
throw new Error(`suite not found: ${suite}`);
|
|
61
|
+
}
|
|
62
|
+
requireReset(adapter, files.length);
|
|
63
|
+
let failed = 0;
|
|
64
|
+
for (const file of files) {
|
|
65
|
+
const golden = loadGolden(file);
|
|
66
|
+
const result = await runGolden(adapter, golden);
|
|
67
|
+
if (result.ok) {
|
|
68
|
+
console.error(`gatefit: PASS ${golden.id}`);
|
|
69
|
+
} else {
|
|
70
|
+
failed += 1;
|
|
71
|
+
console.error(`gatefit: FAIL ${golden.id}`);
|
|
72
|
+
for (const step of result.results.filter((s) => !s.ok)) {
|
|
73
|
+
console.error(` step ${step.stepIndex}: ${step.mismatches.join("; ")}`);
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
return failed === 0 ? 0 : 1;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
async function cmdAdapterCheck(): Promise<number> {
|
|
81
|
+
const suite = loadGolden(S1());
|
|
82
|
+
const naive = await runGolden(await loadAdapter(NAIVE()), suite);
|
|
83
|
+
const hardened = await runGolden(await loadAdapter(HARDENED()), suite);
|
|
84
|
+
const naiveFailed = !naive.ok;
|
|
85
|
+
const hardenedPassed = hardened.ok;
|
|
86
|
+
if (naiveFailed) console.error("gatefit adapter-check: naive failed S1 (expected)");
|
|
87
|
+
else console.error("gatefit adapter-check: naive unexpectedly passed S1");
|
|
88
|
+
if (hardenedPassed) console.error("gatefit adapter-check: hardened passed S1 (expected)");
|
|
89
|
+
else console.error("gatefit adapter-check: hardened failed S1");
|
|
90
|
+
return naiveFailed && hardenedPassed ? 0 : 1;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
function isVersionFlag(arg: string | undefined): boolean {
|
|
94
|
+
return arg === "--version" || arg === "-v";
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
async function main(argv: string[]): Promise<number> {
|
|
98
|
+
if (isVersionFlag(argv[0])) {
|
|
99
|
+
console.log(`gatefit ${readPackageVersion()}`);
|
|
100
|
+
return 0;
|
|
101
|
+
}
|
|
102
|
+
const command = argv[0];
|
|
103
|
+
if (command === "adapter-check") {
|
|
104
|
+
return cmdAdapterCheck();
|
|
105
|
+
}
|
|
106
|
+
if (command === "run") {
|
|
107
|
+
const { values } = parseArgs({
|
|
108
|
+
args: argv.slice(1),
|
|
109
|
+
options: {
|
|
110
|
+
suite: { type: "string" },
|
|
111
|
+
adapter: { type: "string" },
|
|
112
|
+
},
|
|
113
|
+
});
|
|
114
|
+
if (!values.suite || !values.adapter) {
|
|
115
|
+
console.error(usage());
|
|
116
|
+
return 1;
|
|
117
|
+
}
|
|
118
|
+
return cmdRun(values.suite, values.adapter);
|
|
119
|
+
}
|
|
120
|
+
if (command === "live") {
|
|
121
|
+
const { values } = parseArgs({
|
|
122
|
+
args: argv.slice(1),
|
|
123
|
+
options: {
|
|
124
|
+
adapter: { type: "string" },
|
|
125
|
+
scenario: { type: "string" },
|
|
126
|
+
},
|
|
127
|
+
});
|
|
128
|
+
if (!values.adapter) {
|
|
129
|
+
console.error(usage());
|
|
130
|
+
return 1;
|
|
131
|
+
}
|
|
132
|
+
requireLiveClockEnabled();
|
|
133
|
+
const secret = requireTestModeSecret();
|
|
134
|
+
const adapter = await loadAdapter(resolveUserOrPackage(values.adapter));
|
|
135
|
+
const client = createStripeClockClient({ secret });
|
|
136
|
+
let scenarios: LiveScenarioId[] | undefined;
|
|
137
|
+
if (values.scenario) {
|
|
138
|
+
if (!(LIVE_SCENARIO_IDS as readonly string[]).includes(values.scenario)) {
|
|
139
|
+
throw new Error(`unknown live scenario: ${values.scenario}`);
|
|
140
|
+
}
|
|
141
|
+
scenarios = [values.scenario as LiveScenarioId];
|
|
142
|
+
}
|
|
143
|
+
const result = await runLiveLane(adapter, client, { scenarios });
|
|
144
|
+
for (const row of result.results) {
|
|
145
|
+
if (row.ok) console.error(`gatefit live: PASS ${row.id}`);
|
|
146
|
+
else {
|
|
147
|
+
console.error(`gatefit live: FAIL ${row.id}`);
|
|
148
|
+
for (const mismatch of row.mismatches) console.error(` ${mismatch}`);
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
return result.ok ? 0 : 1;
|
|
152
|
+
}
|
|
153
|
+
console.error(usage());
|
|
154
|
+
return 1;
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
try {
|
|
158
|
+
const code = await main(process.argv.slice(2));
|
|
159
|
+
process.exit(code);
|
|
160
|
+
} catch (err) {
|
|
161
|
+
console.error(err instanceof Error ? err.message : err);
|
|
162
|
+
process.exit(1);
|
|
163
|
+
}
|
|
@@ -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
|
+
}
|
|
@@ -0,0 +1,191 @@
|
|
|
1
|
+
import type { AccessAdapter, AccessSnapshot, BillingEvent } from "./access-adapter.ts";
|
|
2
|
+
import { compareAccess } from "./access-adapter.ts";
|
|
3
|
+
import { advanceUntilReady, withTestClock, type ClockWaitOpts } from "./clock-driver.ts";
|
|
4
|
+
import {
|
|
5
|
+
MAX_CUSTOMERS_PER_CLOCK,
|
|
6
|
+
MAX_SUBSCRIPTIONS_PER_CLOCK,
|
|
7
|
+
type StripeClockClient,
|
|
8
|
+
type StripeInvoice,
|
|
9
|
+
type StripeSubscription,
|
|
10
|
+
} from "./stripe-clock.ts";
|
|
11
|
+
|
|
12
|
+
export const LIVE_SCENARIO_IDS = [
|
|
13
|
+
"paid_grants_access",
|
|
14
|
+
"cancel_revokes_access",
|
|
15
|
+
"trial_to_paid_grants",
|
|
16
|
+
] as const;
|
|
17
|
+
|
|
18
|
+
export type LiveScenarioId = (typeof LIVE_SCENARIO_IDS)[number];
|
|
19
|
+
|
|
20
|
+
export type LiveScenarioResult = {
|
|
21
|
+
id: LiveScenarioId;
|
|
22
|
+
ok: boolean;
|
|
23
|
+
mismatches: string[];
|
|
24
|
+
customerId?: string;
|
|
25
|
+
};
|
|
26
|
+
|
|
27
|
+
export function billingEventsFromStripe(input: {
|
|
28
|
+
customerId: string;
|
|
29
|
+
plan: string;
|
|
30
|
+
subscription: StripeSubscription;
|
|
31
|
+
invoices: StripeInvoice[];
|
|
32
|
+
stripeHttpStatus?: number;
|
|
33
|
+
now?: number;
|
|
34
|
+
}): BillingEvent[] {
|
|
35
|
+
void input.stripeHttpStatus;
|
|
36
|
+
const events: BillingEvent[] = [];
|
|
37
|
+
const quantity = input.subscription.items?.data?.[0]?.quantity;
|
|
38
|
+
if (input.subscription.status === "trialing") {
|
|
39
|
+
events.push({
|
|
40
|
+
id: `${input.subscription.id}:trialing`,
|
|
41
|
+
type: "customer.subscription.updated",
|
|
42
|
+
customerId: input.customerId,
|
|
43
|
+
status: "trialing",
|
|
44
|
+
plan: input.plan,
|
|
45
|
+
});
|
|
46
|
+
return events;
|
|
47
|
+
}
|
|
48
|
+
for (const invoice of input.invoices) {
|
|
49
|
+
if (invoice.paid === true || invoice.status === "paid") {
|
|
50
|
+
events.push({
|
|
51
|
+
id: invoice.id,
|
|
52
|
+
type: "invoice.paid",
|
|
53
|
+
customerId: input.customerId,
|
|
54
|
+
plan: input.plan,
|
|
55
|
+
paid: true,
|
|
56
|
+
...(quantity !== undefined ? { seats: quantity } : {}),
|
|
57
|
+
});
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
if (input.subscription.status === "canceled" || input.subscription.status === "incomplete_expired") {
|
|
61
|
+
events.push({
|
|
62
|
+
id: `${input.subscription.id}:canceled`,
|
|
63
|
+
type: "customer.subscription.deleted",
|
|
64
|
+
customerId: input.customerId,
|
|
65
|
+
canceled: true,
|
|
66
|
+
});
|
|
67
|
+
return events;
|
|
68
|
+
}
|
|
69
|
+
if (input.subscription.cancel_at_period_end === true) {
|
|
70
|
+
events.push({
|
|
71
|
+
id: `${input.subscription.id}:cape`,
|
|
72
|
+
type: "customer.subscription.updated",
|
|
73
|
+
customerId: input.customerId,
|
|
74
|
+
plan: input.plan,
|
|
75
|
+
cancel_at_period_end: true,
|
|
76
|
+
period_end: input.subscription.current_period_end,
|
|
77
|
+
now: input.now,
|
|
78
|
+
...(quantity !== undefined ? { seats: quantity } : {}),
|
|
79
|
+
});
|
|
80
|
+
}
|
|
81
|
+
return events;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
export function expectedAccess(_id: LiveScenarioId, phase: "trial" | "paid" | "canceled"): AccessSnapshot {
|
|
85
|
+
if (phase === "paid") return { locked: false, plan: "pro" };
|
|
86
|
+
return { locked: true };
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
export async function runLiveLane(
|
|
90
|
+
adapter: AccessAdapter,
|
|
91
|
+
client: StripeClockClient,
|
|
92
|
+
opts: {
|
|
93
|
+
scenarios?: LiveScenarioId[];
|
|
94
|
+
now?: () => number;
|
|
95
|
+
wait?: ClockWaitOpts;
|
|
96
|
+
} = {},
|
|
97
|
+
): Promise<{ ok: boolean; results: LiveScenarioResult[] }> {
|
|
98
|
+
const ids = opts.scenarios ?? [...LIVE_SCENARIO_IDS];
|
|
99
|
+
const results: LiveScenarioResult[] = [];
|
|
100
|
+
for (const id of ids) {
|
|
101
|
+
results.push(await runLiveScenario(adapter, client, id, { now: opts.now, wait: opts.wait }));
|
|
102
|
+
}
|
|
103
|
+
return { ok: results.every((row) => row.ok), results };
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
export async function runLiveScenario(
|
|
107
|
+
adapter: AccessAdapter,
|
|
108
|
+
client: StripeClockClient,
|
|
109
|
+
id: LiveScenarioId,
|
|
110
|
+
opts: { now?: () => number; wait?: ClockWaitOpts } = {},
|
|
111
|
+
): Promise<LiveScenarioResult> {
|
|
112
|
+
if (!LIVE_SCENARIO_IDS.includes(id)) {
|
|
113
|
+
throw new Error(`unknown live scenario: ${id}`);
|
|
114
|
+
}
|
|
115
|
+
const frozenTime = opts.now?.() ?? Math.floor(Date.now() / 1000);
|
|
116
|
+
return withTestClock(client, { frozenTime, name: `gatefit-${id}` }, async (clock) => {
|
|
117
|
+
let customers = 0;
|
|
118
|
+
let subscriptions = 0;
|
|
119
|
+
const customer = await client.createCustomer({ testClock: clock.id });
|
|
120
|
+
customers += 1;
|
|
121
|
+
if (customers > MAX_CUSTOMERS_PER_CLOCK) {
|
|
122
|
+
throw new Error("too many customers on this clock");
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
if (id === "trial_to_paid_grants") {
|
|
126
|
+
const sub = await client.createSubscription({ customer: customer.id, trialPeriodDays: 14 });
|
|
127
|
+
subscriptions += 1;
|
|
128
|
+
assertSubCap(subscriptions);
|
|
129
|
+
await applyAndCheck(adapter, client, customer.id, sub, expectedAccess(id, "trial"), {
|
|
130
|
+
now: clock.frozen_time,
|
|
131
|
+
});
|
|
132
|
+
const ready = await advanceUntilReady(client, clock.id, frozenTime + 15 * 24 * 60 * 60, opts.wait);
|
|
133
|
+
const after = await client.retrieveSubscription(sub.id);
|
|
134
|
+
return applyAndCheck(adapter, client, customer.id, after, expectedAccess(id, "paid"), {
|
|
135
|
+
id,
|
|
136
|
+
now: ready.frozen_time,
|
|
137
|
+
});
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
const sub = await client.createSubscription({ customer: customer.id });
|
|
141
|
+
subscriptions += 1;
|
|
142
|
+
assertSubCap(subscriptions);
|
|
143
|
+
if (id === "paid_grants_access") {
|
|
144
|
+
return applyAndCheck(adapter, client, customer.id, sub, expectedAccess(id, "paid"), {
|
|
145
|
+
id,
|
|
146
|
+
now: clock.frozen_time,
|
|
147
|
+
});
|
|
148
|
+
}
|
|
149
|
+
await applyAndCheck(adapter, client, customer.id, sub, expectedAccess(id, "paid"), {
|
|
150
|
+
now: clock.frozen_time,
|
|
151
|
+
});
|
|
152
|
+
const canceled = await client.cancelSubscription(sub.id);
|
|
153
|
+
return applyAndCheck(adapter, client, customer.id, canceled, expectedAccess(id, "canceled"), {
|
|
154
|
+
id,
|
|
155
|
+
now: clock.frozen_time,
|
|
156
|
+
});
|
|
157
|
+
});
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
function assertSubCap(subscriptions: number): void {
|
|
161
|
+
if (subscriptions > MAX_SUBSCRIPTIONS_PER_CLOCK) {
|
|
162
|
+
throw new Error("too many subscriptions on this clock");
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
async function applyAndCheck(
|
|
167
|
+
adapter: AccessAdapter,
|
|
168
|
+
client: StripeClockClient,
|
|
169
|
+
customerId: string,
|
|
170
|
+
subscription: StripeSubscription,
|
|
171
|
+
expect: AccessSnapshot,
|
|
172
|
+
meta: { id?: LiveScenarioId; now?: number } = {},
|
|
173
|
+
): Promise<LiveScenarioResult> {
|
|
174
|
+
if (adapter.reset) await adapter.reset();
|
|
175
|
+
const invoices = await client.listInvoices(customerId);
|
|
176
|
+
const events = billingEventsFromStripe({
|
|
177
|
+
customerId,
|
|
178
|
+
plan: "pro",
|
|
179
|
+
subscription,
|
|
180
|
+
invoices,
|
|
181
|
+
now: meta.now,
|
|
182
|
+
});
|
|
183
|
+
for (const event of events) await adapter.apply(event);
|
|
184
|
+
const actual = await adapter.snapshot(customerId);
|
|
185
|
+
const cmp = compareAccess(actual, expect);
|
|
186
|
+
if (!meta.id) {
|
|
187
|
+
if (!cmp.ok) throw new Error(cmp.mismatches.join("; "));
|
|
188
|
+
return { id: "paid_grants_access", ok: true, mismatches: [], customerId };
|
|
189
|
+
}
|
|
190
|
+
return { id: meta.id, ok: cmp.ok, mismatches: cmp.mismatches, customerId };
|
|
191
|
+
}
|
package/src/packs.ts
ADDED
|
@@ -0,0 +1,27 @@
|
|
|
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.
|
|
4
|
+
*/
|
|
5
|
+
export type ProPack = {
|
|
6
|
+
id: string;
|
|
7
|
+
run?: (ctx: { adapterPath: string }) => Promise<number>;
|
|
8
|
+
};
|
|
9
|
+
|
|
10
|
+
const proPacks = new Map<string, ProPack>();
|
|
11
|
+
|
|
12
|
+
export function registerProPack(pack: ProPack): void {
|
|
13
|
+
if (!pack.id || proPacks.has(pack.id)) {
|
|
14
|
+
throw new Error(`pro pack id is missing or already registered: ${pack.id}`);
|
|
15
|
+
}
|
|
16
|
+
proPacks.set(pack.id, pack);
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export function getProPack(id: string): ProPack | undefined {
|
|
20
|
+
return proPacks.get(id);
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export function isProPack(id: string): boolean {
|
|
24
|
+
return proPacks.has(id);
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
registerProPack({ id: "pro-stub" });
|