Oregami
Repositories/oxedyne/daimond

oxedyne/daimond/dev/verify_pro.mjs

9.6 KiB, 1 run

created by r2519314175:601, which is this file's identity for as long as the history lasts, whatever it is later renamed to

download · who wrote it · its history

1// verify_pro.mjs -- the Pro tier, end to end through the REAL gateway.
2//
3// Pro is a one-time licence that unlocks cross-device sync, cloud storage and
4// Email. This drives the contract that matters: without Pro those three refuse,
5// a webhook-minted licence turns all three on, and the licence endpoint reports
6// the price a buy button needs. It signs its own Stripe events with the sandbox
7// webhook secret, exactly as verify_admin does, so no real Stripe is touched.
8//
9// node dev/verify_pro.mjs
10//
11// Needs the release gateway (it starts its own on :9002) and dev/serve.mjs
12// (DAIMOND_PORT, default 8777).
13
14import fs from 'node:fs';
15import os from 'node:os';
16import path from 'node:path';
17import crypto from 'node:crypto';
18import { spawn } from 'node:child_process';
19import { fileURLToPath } from 'node:url';
20import { requireFreshGateway, procLog, GWCWD } from './gwbin.mjs';
21import { GW_URL as GW } from './ports.mjs';
22
23const HERE = path.dirname(fileURLToPath(import.meta.url));
24const ROOT = path.join(HERE, '..');
25const GWDIR = path.join(ROOT, 'gateway');
26/// What the gateway says while this runs. A webhook that mints no licence
27/// answers 200 either way; the reason it rejected the event is only here.
28const GW_LOG = procLog('verify_pro');
29const WHSEC = fs.readFileSync(path.join(GWDIR, 'keys/stripe/sandbox/whsec'), 'utf8').trim();
30
31const ok = [], bad = [];
32const check = (name, pass, detail) => {
33 (pass ? ok : bad).push(name);
34 console.log((pass ? ' ok ' : ' FAIL ') + name + (detail ? ' -- ' + detail : ''));
35};
36const sleep = ms => new Promise(r => setTimeout(r, ms));
37
38const procs = [];
39function cleanup() { for (const p of procs) { try { p.kill('SIGKILL'); } catch (e) {} } }
40/// What the gateway this run started was saying while it failed.
41function saidWhat() { GW_LOG.report(); }
42async function waitFor(fn, ms = 20000, gap = 300) {
43 const t0 = Date.now();
44 for (;;) {
45 try { if (await fn()) return true; } catch (e) {}
46 if (Date.now() - t0 > ms) return false;
47 await sleep(gap);
48 }
49}
50
51const b64url = b => Buffer.from(b).toString('base64').replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '');
52
53// Register an account by proving a fresh Ed25519 key, as the browser does, and
54// take a session; returns { id, cookie } so the test can call as that account.
55async function account() {
56 const { publicKey, privateKey } = crypto.generateKeyPairSync('ed25519');
57 const jwk = publicKey.export({ format: 'jwk' });
58 const pub = jwk.x;
59 const sign = msg => b64url(crypto.sign(null, Buffer.from(msg), privateKey));
60
61 const ts = Math.floor(Date.now() / 1000);
62 const reg = await fetch(`${GW}/api/account`, {
63 method: 'POST', headers: { 'content-type': 'application/json', 'x-daimond-api': '1' },
64 body: JSON.stringify({ pubkey: pub, alg: 'Ed25519', ts, sig: sign(`daimond-gw-account:v1:${pub}:${ts}`) }),
65 });
66 if (!reg.ok) return null;
67 const regJ = await reg.json();
68 const accountId = regJ.account_id;
69 const ch = await (await fetch(`${GW}/api/auth/challenge`, {
70 method: 'POST', headers: { 'content-type': 'application/json', 'x-daimond-api': '1' },
71 body: JSON.stringify({ pubkey: pub, alg: 'Ed25519' }),
72 })).json();
73 const ver = await fetch(`${GW}/api/auth/verify`, {
74 method: 'POST', headers: { 'content-type': 'application/json', 'x-daimond-api': '1' },
75 body: JSON.stringify({ challenge_id: ch.challenge_id, sig: sign(ch.challenge) }),
76 });
77 const cookie = (ver.headers.get('set-cookie') || '').split(';')[0];
78 return { id: accountId, cookie };
79}
80
81const authed = (cookie, path_, opts = {}) => fetch(`${GW}${path_}`, {
82 ...opts, headers: { ...(opts.headers || {}), cookie, 'x-daimond-api': '1' },
83});
84
85// Mint a Pro licence the one way the gateway trusts: a signed checkout event.
86async function grantPro(accountId) {
87 const payload = JSON.stringify({
88 id: `evt_pro_${accountId}`,
89 type: 'checkout.session.completed',
90 data: { object: {
91 payment_status: 'paid', amount_total: 4500, payment_intent: `pi_pro_${accountId}`,
92 metadata: { account_id: accountId, product: 'pro' },
93 } },
94 });
95 const t = Math.floor(Date.now() / 1000);
96 const mac = crypto.createHmac('sha256', WHSEC).update(`${t}.${payload}`).digest('hex');
97 return (await fetch(`${GW}/webhook/stripe`, {
98 method: 'POST', headers: { 'content-type': 'application/json', 'stripe-signature': `t=${t},v1=${mac}` },
99 body: payload,
100 })).status;
101}
102
103// Refund that Pro purchase, the event that should revoke the licence.
104//
105// The payload is shaped as Stripe really sends it: the top-level `type` comes
106// AFTER `data`, and the nested charge carries an `outcome.type` of "authorized".
107// A hand-written {type, data} order would pass even a broken parser -- this
108// ordering is what catches a scan that grabs the first `type` it sees.
109async function refundPro(accountId) {
110 const payload = JSON.stringify({
111 id: `evt_refund_${accountId}`,
112 object: 'event',
113 data: { object: {
114 id: `ch_${accountId}`, object: 'charge',
115 outcome: { network_status: 'approved_by_network', type: 'authorized' },
116 payment_method_details: { type: 'card' },
117 payment_intent: `pi_pro_${accountId}`, amount_refunded: 4500,
118 } },
119 type: 'charge.refunded',
120 });
121 const t = Math.floor(Date.now() / 1000);
122 const mac = crypto.createHmac('sha256', WHSEC).update(`${t}.${payload}`).digest('hex');
123 return (await fetch(`${GW}/webhook/stripe`, {
124 method: 'POST', headers: { 'content-type': 'application/json', 'stripe-signature': `t=${t},v1=${mac}` },
125 body: payload,
126 })).status;
127}
128
129(async () => {
130 requireFreshGateway();
131 // Spawned in `GWCWD` and not in `gateway/`: the deployed `app.jdat` is closed
132 // for registration, so a gateway started beside it answers every fresh keypair
133 // below with "the beta is closed" and this file reports a shut door as a
134 // broken product. `GWCWD` is the same config with the dev flags flipped, built
135 // by `requireFreshGateway` above -- see `dev/gwbin.mjs`.
136 const gw = spawn(path.join(GWDIR, 'target/release/daimond_gateway'), [], {
137 cwd: GWCWD, env: { ...process.env, APP_MODE: 'sandbox' }, stdio: GW_LOG.stdio,
138 });
139 procs.push(gw);
140 check('gateway comes up', await waitFor(async () => (await fetch(`${GW}/api/health`)).ok));
141
142 const a = await account();
143 check('a fresh account registers and signs in', !!(a && a.id), a && a.id);
144
145 // ── Without Pro, the three capabilities refuse ──
146 const licBefore = await (await authed(a.cookie, '/api/licence')).json();
147 check('licence endpoint reports no Pro held', licBefore.held === false && !licBefore.licence);
148 check('and names the price a buy button needs',
149 licBefore.pro_price_minor === 4500, 'price ' + licBefore.pro_price_minor);
150
151 const syncNo = await authed(a.cookie, '/api/sync', {
152 method: 'POST', headers: { 'content-type': 'application/json' },
153 body: JSON.stringify({ base_version: 0, blob: 'x', device: 'test' }),
154 });
155 check('sync refuses without Pro (402)', syncNo.status === 402, 'status ' + syncNo.status);
156
157 const chunkNo = await authed(a.cookie, '/api/chunk', {
158 method: 'POST', headers: { 'content-type': 'application/json' },
159 body: JSON.stringify({ op: 'put', chunks: [] }),
160 });
161 check('cloud storage upload refuses without Pro (402)', chunkNo.status === 402, 'status ' + chunkNo.status);
162
163 const mailNo = await authed(a.cookie, '/api/mail/accounts');
164 const mailNoJ = await mailNo.json();
165 check('email reports locked without Pro', mailNoJ.unlocked === false, 'unlocked ' + mailNoJ.unlocked);
166
167 // ── A Pro licence turns all three on ──
168 check('a Pro purchase mints a licence (webhook 200)', await grantPro(a.id) === 200);
169
170 const licAfter = await (await authed(a.cookie, '/api/licence')).json();
171 check('licence endpoint now reports Pro held', licAfter.held === true && !!licAfter.licence);
172
173 const syncYes = await authed(a.cookie, '/api/sync', {
174 method: 'POST', headers: { 'content-type': 'application/json' },
175 body: JSON.stringify({ base_version: 0, blob: 'aGVsbG8=', device: 'test' }),
176 });
177 check('sync is accepted with Pro (not 402)', syncYes.status !== 402, 'status ' + syncYes.status);
178
179 // A put of nothing is a well-formed request the gate now lets through: it is
180 // no longer 402. (An empty batch stores nothing, which is fine.)
181 const chunkYes = await authed(a.cookie, '/api/chunk', {
182 method: 'POST', headers: { 'content-type': 'application/json' },
183 body: JSON.stringify({ op: 'put', chunks: [] }),
184 });
185 check('cloud storage upload passes the gate with Pro (not 402)',
186 chunkYes.status !== 402, 'status ' + chunkYes.status);
187
188 const mailYes = await (await authed(a.cookie, '/api/mail/accounts')).json();
189 check('email reports unlocked with Pro', mailYes.unlocked === true, 'unlocked ' + mailYes.unlocked);
190
191 // ── Buying Pro twice is refused, not double-charged ──
192 const twice = await authed(a.cookie, '/api/checkout/pro', {
193 method: 'POST', headers: { 'content-type': 'application/json' }, body: '{}',
194 });
195 check('a second Pro purchase is refused (409)', twice.status === 409, 'status ' + twice.status);
196
197 // ── A refund revokes Pro, and the three lock again ──
198 check('a refund is accepted (webhook 200)', await refundPro(a.id) === 200);
199 const licRefunded = await (await authed(a.cookie, '/api/licence')).json();
200 check('licence is revoked after the refund', licRefunded.held === false);
201 const syncGone = await authed(a.cookie, '/api/sync', {
202 method: 'POST', headers: { 'content-type': 'application/json' },
203 body: JSON.stringify({ base_version: 1, blob: 'x', device: 'test' }),
204 });
205 check('sync refuses again after the refund (402)', syncGone.status === 402, 'status ' + syncGone.status);
206
207 if (bad.length) saidWhat(); // before the kill in cleanup.
208 cleanup();
209 console.log(`\n${ok.length} ok, ${bad.length} failed`);
210 process.exit(bad.length ? 1 : 0);
211})().catch(e => { console.error(e); saidWhat(); cleanup(); process.exit(1); });