@homie-rocks/studio 0.50.4 → 0.50.6

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.
Files changed (46) hide show
  1. package/CHANGELOG.md +36 -0
  2. package/README.md +1 -2
  3. package/bin/homie-studio.mjs +7 -7
  4. package/functions/FUNCTIONS.md +69 -13
  5. package/guides/office/SKILL.md +17 -0
  6. package/guides/servers/SKILL.md +17 -0
  7. package/guides/shop/SKILL.md +4 -3
  8. package/lib/build.mjs +3 -4
  9. package/lib/cloudflare.mjs +8 -0
  10. package/lib/functions-build.mjs +10 -3
  11. package/lib/functions-cli.mjs +9 -3
  12. package/lib/parts-build.mjs +1 -1
  13. package/lib/parts.mjs +1 -1
  14. package/lib/purchase-links.mjs +1 -1
  15. package/lib/scaffold.mjs +18 -12
  16. package/lib/shop.mjs +2 -30
  17. package/lib/template-history.json +88 -1
  18. package/lib/worker-config.mjs +20 -3
  19. package/package.json +1 -1
  20. package/shop/SELLING.md +1 -1
  21. package/shop/SHOP.md +11 -12
  22. package/worker/build-id.mjs +1 -1
  23. package/worker/canonical-json.mjs +6 -0
  24. package/worker/customer-mcp.mjs +1 -1
  25. package/worker/customer-resources.mjs +1 -1
  26. package/worker/function-schedule.mjs +68 -0
  27. package/worker/functions-schema.mjs +13 -0
  28. package/worker/functions.mjs +45 -9
  29. package/worker/index.mjs +1 -14
  30. package/worker/mcp-tools.mjs +7 -3
  31. package/worker/office-page.mjs +1 -1
  32. package/worker/office.mjs +7 -14
  33. package/worker/parts-sale.mjs +1 -1
  34. package/worker/purchase-core.mjs +1 -1
  35. package/worker/purchase-crypto.mjs +1 -1
  36. package/worker/purchase-keys.mjs +1 -1
  37. package/worker/purchase-links.mjs +1 -1
  38. package/worker/purchase-owner.mjs +1 -1
  39. package/worker/purchase-routes.mjs +1 -1
  40. package/worker/purchase-store.mjs +1 -1
  41. package/worker/shop-page.mjs +5 -69
  42. package/worker/shop-rules.mjs +5 -33
  43. package/worker/shop-store.mjs +76 -56
  44. package/worker/shop.mjs +22 -78
  45. package/worker/version.mjs +1 -1
  46. package/worker/referrals.mjs +0 -460
@@ -1,460 +0,0 @@
1
- import { referralShare } from './shop-rules.mjs';
2
- /**
3
- * REFERRALS (@homie-rocks/studio 0.24.0): one set of rules for every referrer, homie.rocks included.
4
- *
5
- * arrival A person's browser opens one of this studio's pages with ?via=<host> (a link from another studio,
6
- * from homie.rocks, from anywhere). When an optional referral agreement is present, the
7
- * Worker keeps the host and the time in a signed cookie (HttpOnly, this site only, for the window).
8
- * Prefetches, crawlers and this site itself are not referrals.
9
- * a sale A paid order within the optional window writes a referral line in
10
- * THIS studio's D1 (the seller's books): the pre-tax price, the published rate, the share, held for
11
- * `holdDays` (a refund or a lost dispute inside the hold voids it; after it, it nets off the next
12
- * statement). The referrer must prove itself: its own /.well-known/homie-studio.json says it takes
13
- * referral statements. No list anywhere says who is real; homie.rocks is not a registry.
14
- * statements Monthly, signed with this studio's own Ed25519 key (its public half is in the manifest): the order
15
- * hashes, items, pre-tax amounts, dates and states, and what is owed. No player, name or email. POSTed
16
- * to the referrer's /api/referrals/statement, which checks the signature against the seller's manifest.
17
- * paying Never through Homie. The referrer invoices the seller from its own Stripe (or PayPal, Wise, or the
18
- * seller's gift codes); the seller pays the invoice. The office shows both sides and links to Stripe.
19
- */
20
- import { isVisit } from './stats.mjs';
21
-
22
- const DAY = 86_400_000;
23
- export const VIA_COOKIE = 'studio_via';
24
- const HOST = /^(?=.{3,253}$)[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?(?:\.[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?)+$/;
25
- export const STATEMENT_KIND = 'homie-referral-statement';
26
-
27
- const b64url = (bytes) => btoa(String.fromCharCode(...new Uint8Array(bytes))).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '');
28
- const fromB64url = (s) => Uint8Array.from(atob(String(s).replace(/-/g, '+').replace(/_/g, '/') + '='.repeat((4 - (String(s).length % 4)) % 4)), (c) => c.charCodeAt(0));
29
- const enc = new TextEncoder();
30
-
31
- /** A host as a referrer names itself: lower-case, no port, no path, never an address. */
32
- export function hostOf(raw) {
33
- const h = String(raw ?? '').trim().toLowerCase().replace(/^www\./, '');
34
- return HOST.test(h) && !/^\d+\.\d+\.\d+\.\d+$/.test(h) ? h : null;
35
- }
36
-
37
- /** JSON with sorted keys: what a signature covers, byte for byte, on both sides. */
38
- export function canonicalJson(v) {
39
- if (Array.isArray(v)) return `[${v.map(canonicalJson).join(',')}]`;
40
- if (v && typeof v === 'object') return `{${Object.keys(v).sort().filter((k) => v[k] !== undefined).map((k) => `${JSON.stringify(k)}:${canonicalJson(v[k])}`).join(',')}}`;
41
- return JSON.stringify(v ?? null);
42
- }
43
-
44
- /* ------------------------------------------------------------------ this studio's secrets in D1 meta */
45
-
46
- async function metaGet(env, key) {
47
- try { return (await env.DB.prepare('SELECT value FROM meta WHERE key = ?1').bind(key).first())?.value ?? null; } catch { return null; }
48
- }
49
- async function metaPutOnce(env, key, value) {
50
- await env.DB.prepare('INSERT INTO meta (key, value) VALUES (?1, ?2) ON CONFLICT(key) DO NOTHING').bind(key, value).run();
51
- return metaGet(env, key);
52
- }
53
-
54
- let hmacCache = null;
55
- /** The key that signs the arrival cookie (random, in D1 meta, made on first use). */
56
- async function cookieKey(env) {
57
- if (hmacCache?.env === env) return hmacCache.key;
58
- let hex = await metaGet(env, 'referral_cookie_key');
59
- if (!hex) {
60
- const raw = new Uint8Array(32);
61
- crypto.getRandomValues(raw);
62
- hex = await metaPutOnce(env, 'referral_cookie_key', [...raw].map((b) => b.toString(16).padStart(2, '0')).join(''));
63
- }
64
- const bytes = Uint8Array.from(hex.match(/../g).map((h) => parseInt(h, 16)));
65
- const key = await crypto.subtle.importKey('raw', bytes, { name: 'HMAC', hash: 'SHA-256' }, false, ['sign']);
66
- hmacCache = { env, key };
67
- return key;
68
- }
69
- const macOf = async (env, text) => b64url(await crypto.subtle.sign('HMAC', await cookieKey(env), enc.encode(text))).slice(0, 22);
70
-
71
- let signingCache = null;
72
- /** This studio's Ed25519 statement key (private half in D1 meta, made on first use; public half in the manifest). */
73
- export async function signingKey(env) {
74
- if (signingCache?.env === env) return signingCache.pair;
75
- let stored = await metaGet(env, 'referral_signing_key');
76
- if (!stored) {
77
- const pair = await crypto.subtle.generateKey({ name: 'Ed25519' }, true, ['sign', 'verify']);
78
- const jwk = await crypto.subtle.exportKey('jwk', pair.privateKey);
79
- stored = await metaPutOnce(env, 'referral_signing_key', JSON.stringify({ kty: jwk.kty, crv: jwk.crv, d: jwk.d, x: jwk.x }));
80
- }
81
- const jwk = JSON.parse(stored);
82
- const privateKey = await crypto.subtle.importKey('jwk', { ...jwk, key_ops: ['sign'], ext: true }, { name: 'Ed25519' }, false, ['sign']);
83
- const pair = { privateKey, publicJwk: { kty: 'OKP', crv: 'Ed25519', x: jwk.x } };
84
- signingCache = { env, pair };
85
- return pair;
86
- }
87
-
88
- export async function signStatement(env, statement) {
89
- const { privateKey, publicJwk } = await signingKey(env);
90
- const sig = b64url(await crypto.subtle.sign({ name: 'Ed25519' }, privateKey, enc.encode(canonicalJson(statement))));
91
- return { v: 1, kind: STATEMENT_KIND, statement, sig, key: publicJwk.x };
92
- }
93
-
94
- /** Check a statement's signature against a public key (the `x` of the seller's manifest key). */
95
- export async function verifyStatement(envelope, x) {
96
- try {
97
- if (envelope?.kind !== STATEMENT_KIND || typeof envelope.sig !== 'string' || typeof x !== 'string') return false;
98
- const pub = await crypto.subtle.importKey('jwk', { kty: 'OKP', crv: 'Ed25519', x, ext: true }, { name: 'Ed25519' }, false, ['verify']);
99
- return await crypto.subtle.verify({ name: 'Ed25519' }, pub, fromB64url(envelope.sig), enc.encode(canonicalJson(envelope.statement)));
100
- } catch { return false; }
101
- }
102
-
103
- /* ------------------------------------------------------------------ arrival */
104
-
105
- /**
106
- * A Set-Cookie for this arrival, or null. Only a person's real page load (not a prefetch, a crawler or this site's
107
- * own link), with ?via=<host> of another site, with no earlier attributed arrival here, on a studio
108
- * that sells and pays referrals.
109
- */
110
- export async function arrivalCookie(request, env, url, shop) {
111
- if (!shop?.open || !shop.referrals || !env?.DB) return null;
112
- const via = hostOf(url.searchParams.get('via'));
113
- if (!via || via === hostOf(url.hostname)) return null;
114
- if (!isVisit(request)) return null;
115
- const cookies = request.headers.get('cookie') ?? '';
116
- // Been here before: a player of this site (a guest who saved, or an account), or an earlier arrival.
117
- if (/(?:^|;\s*)studio_via=/.test(cookies) || shop.referralNewPlayersOnly && /(?:^|;\s*)studio_player=/.test(cookies)) return null;
118
- const at = Math.floor(Date.now() / 1000);
119
- const value = `${via}.${at}.${await macOf(env, `${via}.${at}`)}`;
120
- // Persistent integer lifetime; browsers may apply their own cookie retention policy.
121
- return `${VIA_COOKIE}=${value}; Path=/; HttpOnly; SameSite=Lax; Max-Age=${Math.min(2147483647, Math.max(1, Math.round((shop.referrals.windowDays ?? 24855) * 86400)))}${url.protocol === 'https:' ? '; Secure' : ''}`;
122
- }
123
-
124
- /** The arrival this request's browser carries ({ via, at }), checked, or null. */
125
- export async function arrivalOf(request, env) {
126
- const raw = /(?:^|;\s*)studio_via=([^;]+)/.exec(request.headers.get('cookie') ?? '')?.[1];
127
- const m = /^([a-z0-9.-]{3,253})\.(\d{9,11})\.([A-Za-z0-9_-]{22})$/.exec(raw ?? '');
128
- if (!m || !hostOf(m[1])) return null;
129
- try { if (m[3] !== await macOf(env, `${m[1]}.${m[2]}`)) return null; } catch { return null; }
130
- return { via: m[1], at: Number(m[2]) * 1000 };
131
- }
132
-
133
- /* ------------------------------------------------------------------ is a referrer real? */
134
-
135
- const proven = new Map();
136
- /**
137
- * Whether `host` takes referral statements: its own manifest says so (fetched over https, cached a day; a failure is
138
- * cached an hour). `fetcher` is for tests.
139
- */
140
- export async function referrerOk(host, { fetcher = fetch, now = Date.now() } = {}) {
141
- const h = hostOf(host);
142
- if (!h) return false;
143
- const hit = proven.get(h);
144
- if (hit && now - hit.at < (hit.ok ? DAY : 3_600_000)) return hit.ok;
145
- let ok = false;
146
- try {
147
- const res = await fetcher(`https://${h}/.well-known/homie-studio.json`, { headers: { accept: 'application/json' }, signal: AbortSignal.timeout(3000) });
148
- if (res.ok) {
149
- const m = await res.json();
150
- const st = String(m?.referrals?.statements ?? '');
151
- ok = m?.referrals?.accepts === true && st.startsWith(`https://${h}/`);
152
- }
153
- } catch { ok = false; }
154
- proven.set(h, { ok, at: now });
155
- if (proven.size > 2000) proven.clear();
156
- return ok;
157
- }
158
- /** For tests: forget what was proven. */
159
- export function resetReferrers() { proven.clear(); }
160
-
161
- /** What the manifest says about referrals: this studio takes statements (as a referrer) and, if it sells, its terms. */
162
- export async function manifestReferrals(env, origin, shop, studio) {
163
- if (studio?.referrals === false || !env?.DB) return null;
164
- let key = null;
165
- try { key = (await signingKey(env)).publicJwk; } catch { key = null; }
166
- return {
167
- accepts: true,
168
- statements: `${origin}/api/referrals/statement`,
169
- ...(key ? { key } : {}),
170
- // The seller's terms, the same for every referrer (homie.rocks included); absent: this studio pays no referrals.
171
- ...(shop?.open && shop.referrals ? { terms: shop.referrals } : {}),
172
- };
173
- }
174
-
175
- /* ------------------------------------------------------------------ the seller's books */
176
-
177
- /**
178
- * A paid order with an arrival on it: its referral line (pending, held), or nothing when the terms say no (rate 0, out
179
- * of the window, the per-player cap reached). Statements for D1's batch.
180
- */
181
- export async function lineFor(env, order, shop, { touchAt = null, now = Date.now() } = {}) {
182
- const terms = shop?.referrals;
183
- if (!terms || !order.via || !(terms.rate > 0)) return [];
184
- if (touchAt !== null && terms.windowDays !== null && now - touchAt > Math.round(terms.windowDays * DAY)) return [];
185
- const share = referralShare(Number(order.amount), terms.rate);
186
- if (!Number.isSafeInteger(share)) throw new RangeError('Referral share exceeds safe integer arithmetic; lower the studio rate or amount.');
187
- if (share <= 0) return [];
188
- const period = new Date(now).toISOString().slice(0, 7);
189
- // Compute remaining room in the INSERT itself: simultaneous paid orders share one exact cap.
190
- return [env.DB.prepare("INSERT INTO referral_lines (order_id, via, net, rate, share, currency, state, period, hold_until, settled_ref, created_at) SELECT ?1, ?2, ?3, ?4, CASE WHEN ?10 IS NULL THEN ?5 ELSE MAX(0, MIN(?5, ?10 - used)) END, ?6, 'pending', ?7, ?8, NULL, ?9 FROM (SELECT COALESCE(SUM(l.share), 0) AS used FROM referral_lines l JOIN shop_orders o ON o.id = l.order_id WHERE ?10 IS NOT NULL AND o.player = ?11 AND l.via = ?2 AND l.state != 'void') WHERE ?10 IS NULL OR used < ?10 ON CONFLICT(order_id) DO NOTHING")
191
- .bind(order.id, order.via, Number(order.amount), terms.rate, share, order.currency, period, now + Math.round((terms.holdDays ?? 0) * DAY), now, terms.capPerPlayer, order.player)];
192
- }
193
-
194
- /** A refund or a lost dispute: a held line is void; a settled one becomes a clawback that nets off the next statement. */
195
- export function voidLineStatements(env, orderId) {
196
- return [
197
- env.DB.prepare("UPDATE referral_lines SET state = 'void' WHERE order_id = ?1 AND state IN ('pending', 'owed')").bind(orderId),
198
- env.DB.prepare("UPDATE referral_lines SET state = 'clawback' WHERE order_id = ?1 AND state = 'settled'").bind(orderId),
199
- ];
200
- }
201
-
202
- const lineState = (r, now) => (r.state === 'pending' && Number(r.hold_until) <= now ? 'owed' : r.state);
203
-
204
- async function orderHash(seller, orderId) {
205
- return [...new Uint8Array(await crypto.subtle.digest('SHA-256', enc.encode(`${seller}|${orderId}`)))].slice(0, 8).map((b) => b.toString(16).padStart(2, '0')).join('');
206
- }
207
-
208
- /**
209
- * The seller's books page by referrer and currency; totals and counts include every open line.
210
- * `period` (YYYY-MM) limits lines to sales up to the end of that month. Lines have their own cursor.
211
- */
212
- const readCursor = (cursor) => { try { const value = JSON.parse(cursor); return Array.isArray(value) && value.every((part) => typeof part === 'string' || (typeof part === 'number' && Number.isFinite(part))) ? value : []; } catch { return []; } };
213
- const openLines = "state IN ('pending', 'owed', 'clawback', 'void')";
214
-
215
- export async function booksOf(env, { period = null, now = Date.now(), cursor = '', pageSize = 100, edition = null } = {}) {
216
- const parts = readCursor(cursor);
217
- const [via = '', currency = ''] = parts.length === 2 && parts.every((x) => typeof x === 'string') ? parts : [];
218
- // The index starts at the next referrer; each referrer's rows are aggregated once in a full traversal.
219
- const rows = (await env.DB.prepare(`SELECT via, currency, COUNT(*) AS lineCount,
220
- SUM(CASE WHEN state = 'pending' AND hold_until > ?2 THEN share ELSE 0 END) AS pending,
221
- SUM(CASE WHEN state = 'owed' OR (state = 'pending' AND hold_until <= ?2) THEN share ELSE 0 END) AS owed,
222
- SUM(CASE WHEN state = 'clawback' THEN share ELSE 0 END) AS clawback
223
- FROM ${edition ? 'referral_edition_lines' : 'referral_lines'} WHERE ${edition ? 'edition = ?6 AND' : ''} (via, currency) > (?3, ?4) AND ${openLines} AND (?1 IS NULL OR period <= ?1)
224
- GROUP BY via, currency ORDER BY via, currency LIMIT ?5`).bind(...[period, now, via, currency, pageSize + 1, ...(edition ? [edition] : [])]).all()).results ?? [];
225
- const more = rows.length > pageSize;
226
- if (more) rows.pop();
227
- const books = rows.map((r) => ({ ...r, due: r.owed - r.clawback }));
228
- return { books, nextCursor: more ? JSON.stringify([rows.at(-1).via, rows.at(-1).currency]) : null };
229
- }
230
-
231
- /** Lines inside one book, without repeating its totals. The covering prefix makes a full read linear. */
232
- export async function linesOf(env, { via, currency, period = null, now = Date.now(), cursor = '', pageSize = 100, edition = null }) {
233
- const rows = (await env.DB.prepare(`SELECT l.*${edition ? '' : ', o.item, o.paid_at'} FROM ${edition ? 'referral_edition_lines l' : 'referral_lines l LEFT JOIN shop_orders o ON o.id = l.order_id'}
234
- WHERE ${edition ? 'l.edition = ?6 AND' : ''} l.via = ?1 AND l.currency = ?2 AND l.order_id > ?3 AND l.${openLines} AND (?4 IS NULL OR l.period <= ?4)
235
- ORDER BY l.order_id LIMIT ?5`).bind(...[via, currency, cursor, period, pageSize + 1, ...(edition ? [edition] : [])]).all()).results ?? [];
236
- const more = rows.length > pageSize;
237
- if (more) rows.pop();
238
- return { lines: rows.map((r) => ({ order: r.order_id, item: r.item ?? '', net: Number(r.net), rate: Number(r.rate), share: Number(r.share), state: lineState(r, now), period: r.period, date: new Date(Number(r.paid_at ?? r.created_at)).toISOString().slice(0, 10), holdUntil: Number(r.hold_until) })), nextCursor: more ? rows.at(-1).order_id : null };
239
- }
240
-
241
- /** One statement per referrer, continued by line pages; totals occur only on its first page. */
242
- export async function statementPage(env, origin, terms, period, cursor = '', { snapshot = true } = {}) {
243
- let edition, after = '', via, currency, lineCursor = '';
244
- if (cursor) {
245
- const parts = readCursor(cursor);
246
- if (parts.length !== 5 || !parts.every((x) => typeof x === 'string')) return { statements: [], nextCursor: null, error: 'cursor' };
247
- [edition, after, via, currency, lineCursor] = parts;
248
- if ((snapshot ? !/^[a-f0-9-]{36}$/.test(edition) : !/^\d{13}$/.test(edition)) || (via && hostOf(via) !== via) || (currency && !/^[a-z]{3}$/.test(currency)) || lineCursor.length > 256) return { statements: [], nextCursor: null, error: 'cursor' };
249
- } else if (snapshot) {
250
- edition = crypto.randomUUID();
251
- await env.DB.batch([
252
- env.DB.prepare('INSERT INTO referral_editions (id, period, issued) VALUES (?1, ?2, ?3)').bind(edition, period, Date.now()),
253
- env.DB.prepare(`INSERT INTO referral_edition_lines SELECT ?1, l.order_id, l.via, l.currency, l.net, l.rate, l.share, l.state, l.period, l.hold_until, l.created_at, o.item, o.paid_at FROM referral_lines l LEFT JOIN shop_orders o ON o.id = l.order_id WHERE l.${openLines} AND l.period <= ?2`).bind(edition, period),
254
- ]);
255
- }
256
- if (!snapshot && !edition) edition = String(Date.now());
257
- const sourceEdition = snapshot ? edition : null;
258
- const saved = snapshot ? await env.DB.prepare('SELECT issued FROM referral_editions WHERE id = ?1 AND period = ?2').bind(edition, period).first() : { issued: Number(edition) };
259
- if (!saved || !Number.isSafeInteger(saved.issued)) return { statements: [], nextCursor: null, error: 'cursor' };
260
- const issued = saved.issued;
261
- let book;
262
- if (!via) {
263
- const page = await booksOf(env, { period, cursor: after, pageSize: 1, now: issued, edition: sourceEdition });
264
- book = page.books[0];
265
- if (!book) {
266
- if (snapshot) await env.DB.batch([
267
- env.DB.prepare('DELETE FROM referral_edition_lines WHERE edition = ?1').bind(edition),
268
- env.DB.prepare('DELETE FROM referral_editions WHERE id = ?1').bind(edition),
269
- ]);
270
- return { statements: [], nextCursor: null };
271
- }
272
- via = book.via; currency = book.currency;
273
- } else {
274
- // A continuation must name a real line in this immutable edition, never a caller-selected host.
275
- if (!lineCursor || !(snapshot ? await env.DB.prepare('SELECT 1 FROM referral_edition_lines WHERE edition = ?1 AND via = ?2 AND currency = ?3 AND order_id = ?4').bind(edition, via, currency, lineCursor).first() : await env.DB.prepare('SELECT 1 FROM referral_lines WHERE via = ?1 AND currency = ?2 AND order_id = ?3').bind(via, currency, lineCursor).first())) return { statements: [], nextCursor: null, error: 'cursor' };
276
- book = { via, currency };
277
- }
278
- const page = await linesOf(env, { via, currency, period, cursor: lineCursor, now: issued, edition: sourceEdition });
279
- book.lines = page.lines;
280
- book.page = { cursor: lineCursor, nextCursor: page.nextCursor, issued };
281
- const envelope = await statementFor(env, origin, book, period, terms);
282
- // A following empty summary page terminates the walk, avoiding a second aggregate of this book.
283
- const nextCursor = page.nextCursor ? JSON.stringify([edition, after, via, currency, page.nextCursor]) : JSON.stringify([edition, JSON.stringify([via, currency]), '', '', '']);
284
- return { statements: [envelope], nextCursor };
285
- }
286
-
287
- /** One referrer's signed statement for a period (no player, name or email in it: order hashes only). */
288
- export async function statementFor(env, origin, book, period, terms) {
289
- const seller = origin;
290
- const lines = [];
291
- for (const l of book.lines) lines.push({ order: await orderHash(seller, l.order), item: l.item, net: l.net, share: l.share, date: l.date, state: l.state });
292
- const statement = {
293
- seller, referrer: book.via, period, currency: book.currency, issued: new Date().toISOString(),
294
- terms: terms ? { rate: terms.rate, holdDays: terms.holdDays, minimumInvoice: terms.minimumInvoice, basis: 'pre-tax' } : null,
295
- ...(book.due !== undefined ? { totals: { pending: book.pending, owed: book.owed, clawback: book.clawback, due: book.due }, lineCount: book.lineCount } : {}),
296
- lines,
297
- page: book.page ?? { cursor: '', nextCursor: null },
298
- note: 'Invoice the seller for "due" from your own Stripe (or the way its terms accept). Nothing moves through Homie.',
299
- };
300
- return signStatement(env, statement);
301
- }
302
-
303
- /** The owner marks what was paid (an invoice paid by card, a PayPal transfer): those owed lines are settled. */
304
- export async function settle(env, via, ref, { now = Date.now(), currency } = {}) {
305
- const h = hostOf(via);
306
- if (!/^[a-z]{3}$/.test(currency ?? '')) return { ok: false, error: 'currency', message: 'Name the currency to mark paid.' };
307
- if (!h) return { ok: false, error: 'bad-request', message: 'via is the referrer\'s host' };
308
- const note = String(ref ?? '').trim() || 'paid';
309
- await env.DB.batch([
310
- env.DB.prepare("UPDATE referral_lines SET state = 'settled', settled_ref = ?2 WHERE via = ?1 AND currency = ?4 AND (state = 'owed' OR (state = 'pending' AND hold_until <= ?3))").bind(h, note, now, currency),
311
- env.DB.prepare("UPDATE referral_lines SET state = 'settled-clawback', settled_ref = ?2 WHERE via = ?1 AND currency = ?3 AND state = 'clawback'").bind(h, note, currency),
312
- ]);
313
- return { ok: true, via: h, ref: note };
314
- }
315
-
316
- /* ------------------------------------------------------------------ as a referrer */
317
-
318
- const received = new Map();
319
- const positiveSetting = (value, fallback) => {
320
- if (value === undefined || value === null) return fallback;
321
- if (!['string', 'number'].includes(typeof value) || !/^[1-9][0-9]*$/.test(String(value)) || !Number.isSafeInteger(Number(value))) throw new Error('use a positive whole decimal number');
322
- return Number(value);
323
- };
324
- export function resetReferralLimits() { received.clear(); }
325
- /**
326
- * POST /api/referrals/statement: a seller's signed statement to this studio. It must name this site as the referrer,
327
- * and its signature must check against the key in the SELLER's own manifest (fetched now, over https). Kept per
328
- * seller and period; a newer one replaces the older.
329
- */
330
- export async function receiveStatement(request, env, url, { fetcher = fetch } = {}) {
331
- const json = (b, s = 200) => new Response(`${JSON.stringify(b)}\n`, { status: s, headers: { 'content-type': 'application/json; charset=utf-8', 'cache-control': 'no-store' } });
332
- if (request.method !== 'POST') return json({ ok: false, error: 'method' }, 405);
333
- // A statement makes this Worker read the seller's manifest: a few a minute is all a real seller sends.
334
- const now = Date.now();
335
- const address = request.headers.get('cf-connecting-ip') || 'local';
336
- const attempts = (received.get(address) ?? []).filter((at) => at > now - 60000);
337
- let rate, globalRate, maxBytes;
338
- try {
339
- rate = positiveSetting(env.REFERRAL_STATEMENTS_PER_MINUTE, 30);
340
- globalRate = positiveSetting(env.REFERRAL_STATEMENTS_GLOBAL_PER_MINUTE, 30);
341
- maxBytes = positiveSetting(env.REFERRAL_STATEMENT_BYTES, 65536);
342
- } catch { return json({ ok: false, error: 'settings', message: 'REFERRAL_STATEMENTS_PER_MINUTE, REFERRAL_STATEMENTS_GLOBAL_PER_MINUTE and REFERRAL_STATEMENT_BYTES must be positive whole decimal numbers.' }, 503); }
343
- const global = (received.get('*') ?? []).filter((at) => at > now - 60000);
344
- if (global.length >= globalRate) return json({ ok: false, error: 'rate', message: 'The studio referral rate limit was reached; try again in a minute.' }, 429);
345
- global.push(now); received.set('*', global);
346
- if (attempts.length >= rate) return json({ ok: false, error: 'rate', message: 'The studio referral address rate limit was reached; try again in a minute.' }, 429);
347
- attempts.push(now); received.set(address, attempts);
348
- if (received.size > 10000) received.delete([...received.keys()].find((key) => key !== '*'));
349
- if (Number(request.headers.get('content-length') ?? 0) > maxBytes) return json({ ok: false, error: 'too-large', message: 'The studio can raise REFERRAL_STATEMENT_BYTES for larger signed statements.' }, 413);
350
- const text = await request.text();
351
- if (new TextEncoder().encode(text).length > maxBytes) return json({ ok: false, error: 'too-large', message: 'The studio can raise REFERRAL_STATEMENT_BYTES for larger signed statements.' }, 413);
352
- let env0 = null;
353
- try { env0 = JSON.parse(text); } catch { return json({ ok: false, error: 'json' }, 400); }
354
- const s = env0?.statement;
355
- if (env0?.kind !== STATEMENT_KIND || !s || typeof s !== 'object') return json({ ok: false, error: 'not-a-statement' }, 400);
356
- if (hostOf(s.referrer) !== hostOf(url.hostname)) return json({ ok: false, error: 'not-ours', message: 'this statement names another referrer' }, 400);
357
- let seller = null;
358
- try { const u = new URL(String(s.seller)); seller = u.protocol === 'https:' && hostOf(u.hostname) ? u.origin : null; } catch { seller = null; }
359
- if (!/^[a-z]{3}$/.test(s.currency ?? '')) return json({ ok: false, error: 'currency' }, 400);
360
- if (!seller || !/^\d{4}-\d{2}$/.test(String(s.period ?? ''))) return json({ ok: false, error: 'bad-statement' }, 400);
361
- if (typeof (s.page?.cursor ?? '') !== 'string' || String(s.page?.cursor ?? '').length > 256 || (s.page?.nextCursor != null && (typeof s.page.nextCursor !== 'string' || s.page.nextCursor.length > 256))) return json({ ok: false, error: 'cursor' }, 400);
362
- let x = null;
363
- try {
364
- const res = await fetcher(`${seller}/.well-known/homie-studio.json`, { headers: { accept: 'application/json' }, signal: AbortSignal.timeout(5000) });
365
- x = res.ok ? (await res.json())?.referrals?.key?.x ?? null : null;
366
- } catch { x = null; }
367
- if (!x || x !== env0.key || !(await verifyStatement(env0, x))) return json({ ok: false, error: 'signature', message: 'the signature does not match the key in the seller\'s own manifest' }, 401);
368
- const t = s.totals ?? {};
369
- try {
370
- const prefix = `referral-page:${seller}|${s.period}|${s.currency}|`;
371
- const cursor = s.page?.cursor ?? '';
372
- const prior = await env.DB.prepare('SELECT body FROM referral_statements WHERE seller = ?1 AND period = ?2 AND currency = ?3').bind(seller, s.period, s.currency).first();
373
- const previous = prior ? JSON.parse(prior.body).statement : null;
374
- const storedPage = await metaGet(env, prefix + cursor);
375
- if (storedPage && JSON.parse(storedPage).sig === env0.sig) return json({ ok: true, duplicate: true });
376
- if (cursor && (!previous || previous.page?.nextCursor !== cursor || previous.page?.issued !== s.page?.issued)) return json({ ok: false, error: 'page-order' }, 409);
377
- const writes = [];
378
- // A fresh first page replaces the previous edition, including all its old continuations.
379
- if (!cursor) writes.push(env.DB.prepare('DELETE FROM meta WHERE key >= ?1 AND key < ?2').bind(prefix, prefix + '\uffff'));
380
- writes.push(env.DB.prepare('INSERT INTO meta (key, value) VALUES (?1, ?2) ON CONFLICT(key) DO UPDATE SET value = excluded.value').bind(prefix + cursor, text));
381
- if (!cursor) writes.push(env.DB.prepare('INSERT INTO referral_statements (seller, period, owed, pending, currency, body, received_at) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7) ON CONFLICT(seller, period, currency) DO UPDATE SET owed = excluded.owed, pending = excluded.pending, currency = excluded.currency, body = excluded.body, received_at = excluded.received_at')
382
- .bind(seller, s.period, Number(t.due ?? t.owed) || 0, Number(t.pending) || 0, String(s.currency ?? 'usd'), text, Date.now()));
383
- else writes.push(env.DB.prepare('UPDATE referral_statements SET body = ?3, received_at = ?4 WHERE seller = ?1 AND period = ?2 AND currency = ?5').bind(seller, s.period, text, Date.now(), s.currency));
384
- await env.DB.batch(writes);
385
- } catch { return json({ ok: false, error: 'not-migrated', message: 'this studio needs referral migrations 0008_studio_shop.sql and 0011_shop_statements.sql' }, 503); }
386
- return json({ ok: true, seller, period: s.period });
387
- }
388
-
389
- /** Stored signed pages, one bounded document at a time, with a continuation cursor. */
390
- export async function receivedPages(env, seller, period, cursor = '') {
391
- const prefix = `referral-page:${seller}|${period}|`;
392
- const rows = (await env.DB.prepare('SELECT key, value FROM meta WHERE key >= ?1 AND key < ?2 AND key > ?3 ORDER BY key LIMIT 2').bind(prefix, prefix + '\uffff', cursor).all()).results ?? [];
393
- return { ok: true, statements: rows.length ? [JSON.parse(rows[0].value)] : [], nextCursor: rows.length > 1 ? rows[0].key : null };
394
- }
395
-
396
- /** The statements other studios sent this one: what each says it owes. */
397
- export async function statementsIn(env) {
398
- try {
399
- return ((await env.DB.prepare('SELECT seller, period, owed, pending, currency, received_at FROM referral_statements ORDER BY received_at DESC LIMIT 200').all()).results ?? [])
400
- .map((r) => ({ seller: r.seller, period: r.period, due: Number(r.owed), pending: Number(r.pending), currency: r.currency, receivedAt: Number(r.received_at) }));
401
- } catch { return []; }
402
- }
403
-
404
- /** Send each referrer its statement for `period` (to the address in its own manifest). */
405
- export async function sendStatements(env, origin, shop, period, { fetcher = fetch, cursor = '' } = {}) {
406
- // The server owns progress. A caller cursor can neither skip a page nor select a destination.
407
- const key = `referral-send:${origin}|${period}`;
408
- await metaPutOnce(env, key, JSON.stringify({ cursor: '', attempts: 0 }));
409
- const raw = await metaGet(env, key);
410
- let state = JSON.parse(raw);
411
- if (state.done) state = { cursor: '', attempts: 0 };
412
- if (state.nextAt > Date.now()) return { sent: [], nextCursor: 'resume', retryAfter: (state.nextAt - Date.now()) / 1000 };
413
- const locked = JSON.stringify({ ...state, nextAt: Date.now() + 30000 });
414
- const claim = await env.DB.prepare('UPDATE meta SET value = ?3 WHERE key = ?1 AND value = ?2').bind(key, raw, locked).run();
415
- if (!claim.meta?.changes) return { sent: [], nextCursor: 'resume', retryAfter: 2.1 };
416
- const save = async (value) => env.DB.prepare('UPDATE meta SET value = ?2 WHERE key = ?1').bind(key, JSON.stringify(value)).run();
417
- const page = state.page ?? await statementPage(env, origin, shop?.referrals, period, state.cursor);
418
- const envelope = page.statements[0];
419
- if (!envelope) { await save({ done: true, failures: state.failures ?? [] }); return { sent: [], nextCursor: null, failures: state.failures ?? [] }; }
420
- // Save the exact signed page BEFORE delivery: timeouts and process termination replay identical bytes.
421
- state.page = page;
422
- await save({ ...state, nextAt: Date.now() + 30000 });
423
- const via = envelope.statement.referrer;
424
- let result;
425
- try {
426
- const manifestResponse = await fetcher(`https://${via}/.well-known/homie-studio.json`, { redirect: 'manual', signal: AbortSignal.timeout(5000) });
427
- if (!manifestResponse.ok) throw new Error(`Manifest answered ${manifestResponse.status}`);
428
- const manifest = await manifestResponse.json();
429
- const to = new URL(String(manifest?.referrals?.statements ?? ''));
430
- if (to.protocol !== 'https:' || to.host !== via || to.username || to.password) throw new Error('no statement address in its manifest');
431
- const res = await fetcher(to.href, { method: 'POST', redirect: 'manual', headers: { 'content-type': 'application/json' }, body: JSON.stringify(envelope), signal: AbortSignal.timeout(8000) });
432
- result = { via, ok: res.ok, status: res.status };
433
- } catch (error) { result = { via, ok: false, why: String(error?.message ?? error).slice(0, 80) }; }
434
- if (!result.ok && state.attempts < 3) {
435
- const retryAfter = result.status === 429 ? 60 : 2 ** (state.attempts + 1);
436
- await save({ ...state, attempts: state.attempts + 1, nextAt: Date.now() + retryAfter * 1000 });
437
- return { sent: [], nextCursor: 'resume', retryAfter };
438
- }
439
- let next = page.nextCursor;
440
- if (!result.ok) {
441
- // Skip this referrer's remaining pages after a persistent failure; the next referrer still runs.
442
- const [edition] = readCursor(next);
443
- next = JSON.stringify([edition, JSON.stringify([via, envelope.statement.currency]), '', '', '']);
444
- }
445
- const retryAfter = 2.1;
446
- await save({ cursor: next, attempts: 0, failures: [...(state.failures ?? []), ...(!result.ok ? [result] : [])], nextAt: Date.now() + retryAfter * 1000, done: next === null });
447
- return { sent: [result], nextCursor: next === null ? null : 'resume', retryAfter };
448
- }
449
-
450
- export async function statementFailures(env) {
451
- const rows = (await env.DB.prepare("SELECT value FROM meta WHERE key >= 'referral-send:' AND key < 'referral-send;' ORDER BY key LIMIT 100").all()).results ?? [];
452
- return rows.flatMap((r) => JSON.parse(r.value).failures ?? []);
453
- }
454
-
455
- /** A default Send resumes an unfinished period even if the calendar month changed while closed. */
456
- export async function pendingStatementPeriod(env, origin) {
457
- const prefix = `referral-send:${origin}|`;
458
- const row = await env.DB.prepare("SELECT key FROM meta WHERE key >= ?1 AND key < ?2 AND COALESCE(json_extract(value, '$.done'), 0) = 0 ORDER BY key LIMIT 1").bind(prefix, prefix + '\uffff').first();
459
- return row ? row.key.slice(prefix.length) : null;
460
- }