@homie-rocks/studio 0.32.0 → 0.33.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.
Files changed (70) hide show
  1. package/CHANGELOG.md +85 -2
  2. package/README.md +34 -9
  3. package/agents/agents.ts +57 -24
  4. package/bin/homie-studio.mjs +13 -4
  5. package/lib/build.mjs +64 -9
  6. package/lib/cf-usage.mjs +76 -0
  7. package/lib/cloudflare.mjs +25 -2
  8. package/lib/dev.mjs +45 -4
  9. package/lib/lab-check.mjs +3 -2
  10. package/lib/mcp-tools.mjs +1 -1
  11. package/lib/mcp.mjs +1 -1
  12. package/lib/office.mjs +7 -0
  13. package/lib/perf.mjs +8 -3
  14. package/lib/player-image.mjs +40 -0
  15. package/lib/rules-build.mjs +184 -0
  16. package/lib/rules-guard.mjs +770 -0
  17. package/lib/scaffold.mjs +43 -16
  18. package/lib/shop-catalog.mjs +10 -9
  19. package/lib/shop.mjs +32 -15
  20. package/lib/standalone-cli.mjs +30 -2
  21. package/lib/standalone-tools.mjs +20 -2
  22. package/lib/standalone.mjs +215 -9
  23. package/lib/studio.mjs +21 -1
  24. package/lib/template-history.json +90 -0
  25. package/lib/upgrade.mjs +19 -15
  26. package/lib/version.mjs +2 -7
  27. package/netplay/NETPLAY.md +249 -4
  28. package/netplay/netplay.ts +55 -13
  29. package/package.json +23 -1
  30. package/rules/core.ts +1250 -0
  31. package/rules/guard.ts +419 -0
  32. package/rules/host.ts +707 -0
  33. package/rules/math.ts +266 -0
  34. package/rules/pack.ts +292 -0
  35. package/rules/rules.ts +544 -0
  36. package/rules/view.ts +447 -0
  37. package/shop/SELLING.md +14 -16
  38. package/shop/SHOP.md +119 -30
  39. package/shop/shop.ts +6 -8
  40. package/site/SITE.md +119 -2
  41. package/standalone/STANDALONE.md +61 -6
  42. package/starters/coin-dash/game.json +11 -0
  43. package/starters/coin-dash/index.html +17 -0
  44. package/starters/coin-dash/map/main.json +16 -0
  45. package/starters/coin-dash/src/move.ts +27 -0
  46. package/starters/coin-dash/src/rules.ts +86 -0
  47. package/starters/coin-dash/src/view.ts +106 -0
  48. package/starters/coin-dash/tunables.json +5 -0
  49. package/starters/ember-vale/index.html +11 -0
  50. package/worker/brain.mjs +8 -7
  51. package/worker/chat-page.mjs +1 -1
  52. package/worker/embed.mjs +135 -0
  53. package/worker/hosted.mjs +56 -0
  54. package/worker/index.mjs +413 -22
  55. package/worker/office-page.mjs +1 -0
  56. package/worker/office.mjs +24 -7
  57. package/worker/pages.mjs +189 -43
  58. package/worker/players.mjs +10 -6
  59. package/worker/referrals.mjs +192 -57
  60. package/worker/room-store.mjs +54 -0
  61. package/worker/room.mjs +294 -119
  62. package/worker/saves-shell.mjs +7 -1
  63. package/worker/schema.mjs +4 -3
  64. package/worker/shop-page.mjs +103 -37
  65. package/worker/shop-rules.mjs +156 -117
  66. package/worker/shop-store.mjs +70 -9
  67. package/worker/shop.mjs +275 -143
  68. package/worker/site.mjs +26 -5
  69. package/worker/stripe.mjs +9 -6
  70. package/worker/version.mjs +1 -1
@@ -30,7 +30,7 @@ import { cookieValue } from './stats.mjs';
30
30
  import { adoptStatements, bumpPlayerStats, dropPlayerData, fall, fallenList, getSave, listSaves, playerData, readPlayerStats, SAVE_LIMITS, savesSummary, wipeSaves, writeSaves } from './saves.mjs';
31
31
  import { bytesToB64url, randomToken, sha256Hex, verifyAssertion, verifyRegistration } from './webauthn.mjs';
32
32
  import { ACCOUNT_JS, accountPage } from './account-page.mjs';
33
- import { forgetPlayerShop, livePurchases, shopDataOf } from './shop-store.mjs';
33
+ import { adoptShopStatements, migrated as shopMigrated, forgetPlayerShop, livePurchases, shopDataOf } from './shop-store.mjs';
34
34
  import { forgetPlayerHistory, playerHistory } from './lounge-store.mjs';
35
35
 
36
36
  export const PLAYERS_MIGRATION_FILE = '0004_players.sql';
@@ -234,9 +234,9 @@ async function sessionOf(request, env, url = new URL(request.url)) {
234
234
  }
235
235
 
236
236
  /** A guest for a request with no player yet (the first save). Refused past the address's or the day's limit. */
237
- async function newGuest(request, env, url) {
237
+ async function newGuest(request, env, url, addressLimit = PLAYER_LIMITS.newPlayersPerHour, counter = 'new') {
238
238
  const ip = addressOf(request);
239
- if (limited(`new:${ip}`, PLAYER_LIMITS.newPlayersPerHour, HOUR)) return { error: fail(429, 'rate', 'too many new players from this address; try again later') };
239
+ if (limited(`${counter}:${ip}`, addressLimit, HOUR)) return { error: fail(429, 'rate', 'too many new players from this address; try again later') };
240
240
  const capped = await dailyCapReached(env);
241
241
  if (capped) return { error: fail(503, 'busy', 'this studio has taken all the new players it takes in one day; try again tomorrow') };
242
242
  const id = newPlayerId();
@@ -258,8 +258,10 @@ async function dailyCapReached(env) {
258
258
  }
259
259
 
260
260
  /** Guests nobody has used for guestIdleDays are forgotten (a few at a time), with everything they kept. */
261
- async function pruneGuests(env) {
262
- const old = (await env.DB.prepare('SELECT id FROM players WHERE guest = 1 AND seen_at < ?1 LIMIT 25').bind(Date.now() - PLAYER_LIMITS.guestIdleDays * DAY).all()).results ?? [];
261
+ export async function pruneGuests(env) {
262
+ const now = Date.now();
263
+ const ownership = await shopMigrated(env, { complete: false }) ? "AND NOT EXISTS (SELECT 1 FROM entitlements e WHERE e.player = players.id AND e.state = 'active' AND (e.ends_at IS NULL OR e.ends_at > ?2))" : 'AND ?2 > 0';
264
+ const old = (await env.DB.prepare(`SELECT id FROM players WHERE guest = 1 AND seen_at < ?1 ${ownership} LIMIT 25`).bind(now - PLAYER_LIMITS.guestIdleDays * DAY, now).all()).results ?? [];
263
265
  for (const { id } of old) await removePlayer(env, id);
264
266
  await env.DB.prepare('DELETE FROM player_challenges WHERE expires_at < ?1').bind(Date.now()).run();
265
267
  await env.DB.prepare('DELETE FROM player_sessions WHERE expires_at < ?1').bind(Date.now()).run();
@@ -383,6 +385,8 @@ export const players = Object.freeze({
383
385
  async of(request, env) {
384
386
  try { const s = await sessionOf(request, env); return s ? { id: s.player.id, name: s.player.name, guest: s.player.guest, owner: s.player.owner } : null; } catch { return null; }
385
387
  },
388
+ /** Shop guest creation uses the studio's new-guest address rate and the daily player limit. */
389
+ async shopGuest(request, env, url, addressLimit) { return newGuest(request, env, url, addressLimit, 'shop-new'); },
386
390
  /** True when the request carries the session of an account its owner marked (`homie-studio players owner`). */
387
391
  async isOwner(request, env) { return Boolean((await players.of(request, env))?.owner); },
388
392
  /** A short-lived pass naming a player (for a room to recognise them): 'pp_…'. */
@@ -689,7 +693,7 @@ async function apiRoute(request, env, ctx, url, parts, { catalogueOf, read, f })
689
693
  // A guest signing in to their account: what only the guest had moves over, then the guest is gone.
690
694
  if (current.player.guest) {
691
695
  adopted = await adoptStatements(env, current.player.id, key.player);
692
- writes.push(...adopted.statements,
696
+ writes.push(...adopted.statements, ...(await adoptShopStatements(env, current.player.id, key.player)),
693
697
  env.DB.prepare('DELETE FROM player_sessions WHERE player = ?1').bind(current.player.id),
694
698
  env.DB.prepare('DELETE FROM player_challenges WHERE player = ?1').bind(current.player.id),
695
699
  env.DB.prepare('DELETE FROM players WHERE id = ?1 AND guest = 1').bind(current.player.id));
@@ -20,7 +20,7 @@ import { isVisit } from './stats.mjs';
20
20
 
21
21
  const DAY = 86_400_000;
22
22
  export const VIA_COOKIE = 'studio_via';
23
- const HOST = /^(?=.{3,80}$)[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?(?:\.[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?)+$/;
23
+ 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])?)+$/;
24
24
  export const STATEMENT_KIND = 'homie-referral-statement';
25
25
 
26
26
  const b64url = (bytes) => btoa(String.fromCharCode(...new Uint8Array(bytes))).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '');
@@ -120,13 +120,14 @@ export async function arrivalCookie(request, env, url, shop) {
120
120
  if (/(?:^|;\s*)(?:studio_player|studio_via)=/.test(cookies)) return null;
121
121
  const at = Math.floor(Date.now() / 1000);
122
122
  const value = `${via}.${at}.${await macOf(env, `${via}.${at}`)}`;
123
- return `${VIA_COOKIE}=${value}; Path=/; HttpOnly; SameSite=Lax; Max-Age=${shop.referrals.windowDays * 86400}${url.protocol === 'https:' ? '; Secure' : ''}`;
123
+ // Persistent integer lifetime; browsers may apply their own cookie retention policy.
124
+ 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' : ''}`;
124
125
  }
125
126
 
126
127
  /** The arrival this request's browser carries ({ via, at }), checked, or null. */
127
128
  export async function arrivalOf(request, env) {
128
129
  const raw = /(?:^|;\s*)studio_via=([^;]+)/.exec(request.headers.get('cookie') ?? '')?.[1];
129
- const m = /^([a-z0-9.-]{3,80})\.(\d{9,11})\.([A-Za-z0-9_-]{22})$/.exec(raw ?? '');
130
+ const m = /^([a-z0-9.-]{3,253})\.(\d{9,11})\.([A-Za-z0-9_-]{22})$/.exec(raw ?? '');
130
131
  if (!m || !hostOf(m[1])) return null;
131
132
  try { if (m[3] !== await macOf(env, `${m[1]}.${m[2]}`)) return null; } catch { return null; }
132
133
  return { via: m[1], at: Number(m[2]) * 1000 };
@@ -183,14 +184,14 @@ export async function manifestReferrals(env, origin, shop, studio) {
183
184
  export async function lineFor(env, order, shop, { touchAt = null, now = Date.now() } = {}) {
184
185
  const terms = shop?.referrals;
185
186
  if (!terms || !order.via || !(terms.rate > 0)) return [];
186
- if (touchAt !== null && now - touchAt > terms.windowDays * DAY) return [];
187
- let so = 0;
188
- try { so = Number((await env.DB.prepare("SELECT COALESCE(SUM(l.share), 0) AS n FROM referral_lines l JOIN shop_orders o ON o.id = l.order_id WHERE o.player = ?1 AND l.via = ?2 AND l.state != 'void'").bind(order.player, order.via).first())?.n) || 0; } catch { so = 0; }
189
- const share = Math.max(0, Math.min(Math.round(Number(order.amount) * terms.rate), terms.capPerPlayer - so));
187
+ if (touchAt !== null && terms.windowDays !== null && now - touchAt > Math.round(terms.windowDays * DAY)) return [];
188
+ const share = Math.round(Number(order.amount) * terms.rate);
189
+ if (!Number.isSafeInteger(share)) throw new RangeError('Referral share exceeds safe integer arithmetic; lower the studio rate or amount.');
190
190
  if (share <= 0) return [];
191
191
  const period = new Date(now).toISOString().slice(0, 7);
192
- return [env.DB.prepare("INSERT INTO referral_lines (order_id, via, net, rate, share, currency, state, period, hold_until, settled_ref, created_at) VALUES (?1, ?2, ?3, ?4, ?5, ?6, 'pending', ?7, ?8, NULL, ?9) ON CONFLICT(order_id) DO NOTHING")
193
- .bind(order.id, order.via, Number(order.amount), terms.rate, share, order.currency, period, now + terms.holdDays * DAY, now)];
192
+ // Compute remaining room in the INSERT itself: simultaneous paid orders share one exact cap.
193
+ 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")
194
+ .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)];
194
195
  }
195
196
 
196
197
  /** A refund or a lost dispute: a held line is void; a settled one becomes a clawback that nets off the next statement. */
@@ -208,26 +209,82 @@ async function orderHash(seller, orderId) {
208
209
  }
209
210
 
210
211
  /**
211
- * The seller's books per referrer: { via, currency, pending, owed, clawback, lines } (lines not yet settled, and
212
- * clawbacks), newest first. `period` (YYYY-MM) limits lines to sales up to the end of that month.
212
+ * The seller's books page by referrer and currency; totals and counts include every open line.
213
+ * `period` (YYYY-MM) limits lines to sales up to the end of that month. Lines have their own cursor.
213
214
  */
214
- export async function booksOf(env, { period = null, now = Date.now() } = {}) {
215
- let rows = [];
216
- try {
217
- rows = (await env.DB.prepare("SELECT l.*, o.item, o.paid_at FROM referral_lines l JOIN shop_orders o ON o.id = l.order_id WHERE l.state IN ('pending', 'owed', 'clawback', 'void') ORDER BY l.created_at DESC LIMIT 2000").all()).results ?? [];
218
- } catch { return []; }
219
- const by = new Map();
220
- for (const r of rows) {
221
- if (period && r.period > period) continue;
222
- const b = by.get(r.via) ?? { via: r.via, currency: r.currency, pending: 0, owed: 0, clawback: 0, lines: [] };
223
- const state = lineState(r, now);
224
- if (state === 'pending') b.pending += Number(r.share);
225
- if (state === 'owed') b.owed += Number(r.share);
226
- if (state === 'clawback') b.clawback += Number(r.share);
227
- b.lines.push({ order: r.order_id, item: r.item, net: Number(r.net), rate: Number(r.rate), share: Number(r.share), state, period: r.period, date: new Date(Number(r.paid_at ?? r.created_at)).toISOString().slice(0, 10), holdUntil: Number(r.hold_until) });
228
- by.set(r.via, b);
215
+ 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 []; } };
216
+ const openLines = "state IN ('pending', 'owed', 'clawback', 'void')";
217
+
218
+ export async function booksOf(env, { period = null, now = Date.now(), cursor = '', pageSize = 100, edition = null } = {}) {
219
+ const parts = readCursor(cursor);
220
+ const [via = '', currency = ''] = parts.length === 2 && parts.every((x) => typeof x === 'string') ? parts : [];
221
+ // The index starts at the next referrer; each referrer's rows are aggregated once in a full traversal.
222
+ const rows = (await env.DB.prepare(`SELECT via, currency, COUNT(*) AS lineCount,
223
+ SUM(CASE WHEN state = 'pending' AND hold_until > ?2 THEN share ELSE 0 END) AS pending,
224
+ SUM(CASE WHEN state = 'owed' OR (state = 'pending' AND hold_until <= ?2) THEN share ELSE 0 END) AS owed,
225
+ SUM(CASE WHEN state = 'clawback' THEN share ELSE 0 END) AS clawback
226
+ 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)
227
+ GROUP BY via, currency ORDER BY via, currency LIMIT ?5`).bind(...[period, now, via, currency, pageSize + 1, ...(edition ? [edition] : [])]).all()).results ?? [];
228
+ const more = rows.length > pageSize;
229
+ if (more) rows.pop();
230
+ const books = rows.map((r) => ({ ...r, due: r.owed - r.clawback }));
231
+ return { books, nextCursor: more ? JSON.stringify([rows.at(-1).via, rows.at(-1).currency]) : null };
232
+ }
233
+
234
+ /** Lines inside one book, without repeating its totals. The covering prefix makes a full read linear. */
235
+ export async function linesOf(env, { via, currency, period = null, now = Date.now(), cursor = '', pageSize = 100, edition = null }) {
236
+ 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'}
237
+ 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)
238
+ ORDER BY l.order_id LIMIT ?5`).bind(...[via, currency, cursor, period, pageSize + 1, ...(edition ? [edition] : [])]).all()).results ?? [];
239
+ const more = rows.length > pageSize;
240
+ if (more) rows.pop();
241
+ 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 };
242
+ }
243
+
244
+ /** One statement per referrer, continued by line pages; totals occur only on its first page. */
245
+ export async function statementPage(env, origin, terms, period, cursor = '', { snapshot = true } = {}) {
246
+ let edition, after = '', via, currency, lineCursor = '';
247
+ if (cursor) {
248
+ const parts = readCursor(cursor);
249
+ if (parts.length !== 5 || !parts.every((x) => typeof x === 'string')) return { statements: [], nextCursor: null, error: 'cursor' };
250
+ [edition, after, via, currency, lineCursor] = parts;
251
+ 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' };
252
+ } else if (snapshot) {
253
+ edition = crypto.randomUUID();
254
+ await env.DB.batch([
255
+ env.DB.prepare('INSERT INTO referral_editions (id, period, issued) VALUES (?1, ?2, ?3)').bind(edition, period, Date.now()),
256
+ 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),
257
+ ]);
229
258
  }
230
- return [...by.values()].map((b) => ({ ...b, due: b.owed - b.clawback }));
259
+ if (!snapshot && !edition) edition = String(Date.now());
260
+ const sourceEdition = snapshot ? edition : null;
261
+ 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) };
262
+ if (!saved || !Number.isSafeInteger(saved.issued)) return { statements: [], nextCursor: null, error: 'cursor' };
263
+ const issued = saved.issued;
264
+ let book;
265
+ if (!via) {
266
+ const page = await booksOf(env, { period, cursor: after, pageSize: 1, now: issued, edition: sourceEdition });
267
+ book = page.books[0];
268
+ if (!book) {
269
+ if (snapshot) await env.DB.batch([
270
+ env.DB.prepare('DELETE FROM referral_edition_lines WHERE edition = ?1').bind(edition),
271
+ env.DB.prepare('DELETE FROM referral_editions WHERE id = ?1').bind(edition),
272
+ ]);
273
+ return { statements: [], nextCursor: null };
274
+ }
275
+ via = book.via; currency = book.currency;
276
+ } else {
277
+ // A continuation must name a real line in this immutable edition, never a caller-selected host.
278
+ 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' };
279
+ book = { via, currency };
280
+ }
281
+ const page = await linesOf(env, { via, currency, period, cursor: lineCursor, now: issued, edition: sourceEdition });
282
+ book.lines = page.lines;
283
+ book.page = { cursor: lineCursor, nextCursor: page.nextCursor, issued };
284
+ const envelope = await statementFor(env, origin, book, period, terms);
285
+ // A following empty summary page terminates the walk, avoiding a second aggregate of this book.
286
+ const nextCursor = page.nextCursor ? JSON.stringify([edition, after, via, currency, page.nextCursor]) : JSON.stringify([edition, JSON.stringify([via, currency]), '', '', '']);
287
+ return { statements: [envelope], nextCursor };
231
288
  }
232
289
 
233
290
  /** One referrer's signed statement for a period (no player, name or email in it: order hashes only). */
@@ -238,28 +295,36 @@ export async function statementFor(env, origin, book, period, terms) {
238
295
  const statement = {
239
296
  seller, referrer: book.via, period, currency: book.currency, issued: new Date().toISOString(),
240
297
  terms: terms ? { rate: terms.rate, holdDays: terms.holdDays, minimumInvoice: terms.minimumInvoice, basis: 'pre-tax' } : null,
241
- totals: { pending: book.pending, owed: book.owed, clawback: book.clawback, due: book.due },
298
+ ...(book.due !== undefined ? { totals: { pending: book.pending, owed: book.owed, clawback: book.clawback, due: book.due }, lineCount: book.lineCount } : {}),
242
299
  lines,
300
+ page: book.page ?? { cursor: '', nextCursor: null },
243
301
  note: 'Invoice the seller for "due" from your own Stripe (or the way its terms accept). Nothing moves through Homie.',
244
302
  };
245
303
  return signStatement(env, statement);
246
304
  }
247
305
 
248
306
  /** The owner marks what was paid (an invoice paid by card, a PayPal transfer): those owed lines are settled. */
249
- export async function settle(env, via, ref, { now = Date.now() } = {}) {
307
+ export async function settle(env, via, ref, { now = Date.now(), currency } = {}) {
250
308
  const h = hostOf(via);
309
+ if (!/^[a-z]{3}$/.test(currency ?? '')) return { ok: false, error: 'currency', message: 'Name the currency to mark paid.' };
251
310
  if (!h) return { ok: false, error: 'bad-request', message: 'via is the referrer\'s host' };
252
311
  const note = String(ref ?? '').replace(/[^\w .:#/-]/g, '').slice(0, 80) || 'paid';
253
312
  await env.DB.batch([
254
- env.DB.prepare("UPDATE referral_lines SET state = 'settled', settled_ref = ?2 WHERE via = ?1 AND state = 'pending' AND hold_until <= ?3").bind(h, note, now),
255
- env.DB.prepare("UPDATE referral_lines SET state = 'settled-clawback', settled_ref = ?2 WHERE via = ?1 AND state = 'clawback'").bind(h, note),
313
+ 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),
314
+ 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),
256
315
  ]);
257
316
  return { ok: true, via: h, ref: note };
258
317
  }
259
318
 
260
319
  /* ------------------------------------------------------------------ as a referrer */
261
320
 
262
- const received = [];
321
+ const received = new Map();
322
+ const positiveSetting = (value, fallback) => {
323
+ if (value === undefined || value === null) return fallback;
324
+ 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');
325
+ return Number(value);
326
+ };
327
+ export function resetReferralLimits() { received.clear(); }
263
328
  /**
264
329
  * POST /api/referrals/statement: a seller's signed statement to this studio. It must name this site as the referrer,
265
330
  * and its signature must check against the key in the SELLER's own manifest (fetched now, over https). Kept per
@@ -270,12 +335,23 @@ export async function receiveStatement(request, env, url, { fetcher = fetch } =
270
335
  if (request.method !== 'POST') return json({ ok: false, error: 'method' }, 405);
271
336
  // A statement makes this Worker read the seller's manifest: a few a minute is all a real seller sends.
272
337
  const now = Date.now();
273
- while (received.length && received[0] < now - 60_000) received.shift();
274
- if (received.length >= 30) return json({ ok: false, error: 'rate', message: 'too many statements; try again in a minute' }, 429);
275
- received.push(now);
276
- if (Number(request.headers.get('content-length') ?? 0) > 65_536) return json({ ok: false, error: 'too-large' }, 413);
338
+ const address = request.headers.get('cf-connecting-ip') || 'local';
339
+ const attempts = (received.get(address) ?? []).filter((at) => at > now - 60000);
340
+ let rate, globalRate, maxBytes;
341
+ try {
342
+ rate = positiveSetting(env.REFERRAL_STATEMENTS_PER_MINUTE, 30);
343
+ globalRate = positiveSetting(env.REFERRAL_STATEMENTS_GLOBAL_PER_MINUTE, 30);
344
+ maxBytes = positiveSetting(env.REFERRAL_STATEMENT_BYTES, 65536);
345
+ } 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); }
346
+ const global = (received.get('*') ?? []).filter((at) => at > now - 60000);
347
+ if (global.length >= globalRate) return json({ ok: false, error: 'rate', message: 'The studio referral rate limit was reached; try again in a minute.' }, 429);
348
+ global.push(now); received.set('*', global);
349
+ 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);
350
+ attempts.push(now); received.set(address, attempts);
351
+ if (received.size > 10000) received.delete([...received.keys()].find((key) => key !== '*'));
352
+ 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);
277
353
  const text = await request.text();
278
- if (text.length > 65_536) return json({ ok: false, error: 'too-large' }, 413);
354
+ 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);
279
355
  let env0 = null;
280
356
  try { env0 = JSON.parse(text); } catch { return json({ ok: false, error: 'json' }, 400); }
281
357
  const s = env0?.statement;
@@ -283,7 +359,9 @@ export async function receiveStatement(request, env, url, { fetcher = fetch } =
283
359
  if (hostOf(s.referrer) !== hostOf(url.hostname)) return json({ ok: false, error: 'not-ours', message: 'this statement names another referrer' }, 400);
284
360
  let seller = null;
285
361
  try { const u = new URL(String(s.seller)); seller = u.protocol === 'https:' && hostOf(u.hostname) ? u.origin : null; } catch { seller = null; }
362
+ if (!/^[a-z]{3}$/.test(s.currency ?? '')) return json({ ok: false, error: 'currency' }, 400);
286
363
  if (!seller || !/^\d{4}-\d{2}$/.test(String(s.period ?? ''))) return json({ ok: false, error: 'bad-statement' }, 400);
364
+ 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);
287
365
  let x = null;
288
366
  try {
289
367
  const res = await fetcher(`${seller}/.well-known/homie-studio.json`, { headers: { accept: 'application/json' }, signal: AbortSignal.timeout(5000) });
@@ -292,12 +370,32 @@ export async function receiveStatement(request, env, url, { fetcher = fetch } =
292
370
  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);
293
371
  const t = s.totals ?? {};
294
372
  try {
295
- await 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) DO UPDATE SET owed = excluded.owed, pending = excluded.pending, currency = excluded.currency, body = excluded.body, received_at = excluded.received_at')
296
- .bind(seller, s.period, Math.max(0, Math.floor(Number(t.due ?? t.owed) || 0)), Math.max(0, Math.floor(Number(t.pending) || 0)), String(s.currency ?? 'usd').slice(0, 3), text.slice(0, 65_536), Date.now()).run();
297
- } catch { return json({ ok: false, error: 'not-migrated', message: 'this studio has no referral tables yet (migration 0008)' }, 503); }
373
+ const prefix = `referral-page:${seller}|${s.period}|${s.currency}|`;
374
+ const cursor = s.page?.cursor ?? '';
375
+ 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();
376
+ const previous = prior ? JSON.parse(prior.body).statement : null;
377
+ const storedPage = await metaGet(env, prefix + cursor);
378
+ if (storedPage && JSON.parse(storedPage).sig === env0.sig) return json({ ok: true, duplicate: true });
379
+ if (cursor && (!previous || previous.page?.nextCursor !== cursor || previous.page?.issued !== s.page?.issued)) return json({ ok: false, error: 'page-order' }, 409);
380
+ const writes = [];
381
+ // A fresh first page replaces the previous edition, including all its old continuations.
382
+ if (!cursor) writes.push(env.DB.prepare('DELETE FROM meta WHERE key >= ?1 AND key < ?2').bind(prefix, prefix + '\uffff'));
383
+ 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));
384
+ 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')
385
+ .bind(seller, s.period, Number(t.due ?? t.owed) || 0, Number(t.pending) || 0, String(s.currency ?? 'usd'), text, Date.now()));
386
+ 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));
387
+ await env.DB.batch(writes);
388
+ } catch { return json({ ok: false, error: 'not-migrated', message: 'this studio needs referral migrations 0008_studio_shop.sql and 0011_shop_statements.sql' }, 503); }
298
389
  return json({ ok: true, seller, period: s.period });
299
390
  }
300
391
 
392
+ /** Stored signed pages, one bounded document at a time, with a continuation cursor. */
393
+ export async function receivedPages(env, seller, period, cursor = '') {
394
+ const prefix = `referral-page:${seller}|${period}|`;
395
+ 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 ?? [];
396
+ return { ok: true, statements: rows.length ? [JSON.parse(rows[0].value)] : [], nextCursor: rows.length > 1 ? rows[0].key : null };
397
+ }
398
+
301
399
  /** The statements other studios sent this one: what each says it owes. */
302
400
  export async function statementsIn(env) {
303
401
  try {
@@ -307,22 +405,59 @@ export async function statementsIn(env) {
307
405
  }
308
406
 
309
407
  /** Send each referrer its statement for `period` (to the address in its own manifest). */
310
- export async function sendStatements(env, origin, shop, period, { fetcher = fetch } = {}) {
311
- const books = await booksOf(env, { period });
312
- const sent = [];
313
- for (const book of books) {
314
- const env0 = await statementFor(env, origin, book, period, shop?.referrals);
315
- let to = null;
316
- try {
317
- const m = await (await fetcher(`https://${book.via}/.well-known/homie-studio.json`, { headers: { accept: 'application/json' }, signal: AbortSignal.timeout(5000) })).json();
318
- const st = String(m?.referrals?.statements ?? '');
319
- to = st.startsWith(`https://${book.via}/`) ? st : null;
320
- } catch { to = null; }
321
- if (!to) { sent.push({ via: book.via, ok: false, why: 'no statement address in its manifest' }); continue; }
322
- try {
323
- const res = await fetcher(to, { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify(env0), signal: AbortSignal.timeout(8000) });
324
- sent.push({ via: book.via, ok: res.ok, status: res.status, due: book.due });
325
- } catch (error) { sent.push({ via: book.via, ok: false, why: String(error?.message ?? error).slice(0, 80) }); }
408
+ export async function sendStatements(env, origin, shop, period, { fetcher = fetch, cursor = '' } = {}) {
409
+ // The server owns progress. A caller cursor can neither skip a page nor select a destination.
410
+ const key = `referral-send:${origin}|${period}`;
411
+ await metaPutOnce(env, key, JSON.stringify({ cursor: '', attempts: 0 }));
412
+ const raw = await metaGet(env, key);
413
+ let state = JSON.parse(raw);
414
+ if (state.done) state = { cursor: '', attempts: 0 };
415
+ if (state.nextAt > Date.now()) return { sent: [], nextCursor: 'resume', retryAfter: (state.nextAt - Date.now()) / 1000 };
416
+ const locked = JSON.stringify({ ...state, nextAt: Date.now() + 30000 });
417
+ const claim = await env.DB.prepare('UPDATE meta SET value = ?3 WHERE key = ?1 AND value = ?2').bind(key, raw, locked).run();
418
+ if (!claim.meta?.changes) return { sent: [], nextCursor: 'resume', retryAfter: 2.1 };
419
+ const save = async (value) => env.DB.prepare('UPDATE meta SET value = ?2 WHERE key = ?1').bind(key, JSON.stringify(value)).run();
420
+ const page = state.page ?? await statementPage(env, origin, shop?.referrals, period, state.cursor);
421
+ const envelope = page.statements[0];
422
+ if (!envelope) { await save({ done: true, failures: state.failures ?? [] }); return { sent: [], nextCursor: null, failures: state.failures ?? [] }; }
423
+ // Save the exact signed page BEFORE delivery: timeouts and process termination replay identical bytes.
424
+ state.page = page;
425
+ await save({ ...state, nextAt: Date.now() + 30000 });
426
+ const via = envelope.statement.referrer;
427
+ let result;
428
+ try {
429
+ const manifestResponse = await fetcher(`https://${via}/.well-known/homie-studio.json`, { redirect: 'manual', signal: AbortSignal.timeout(5000) });
430
+ if (!manifestResponse.ok) throw new Error(`Manifest answered ${manifestResponse.status}`);
431
+ const manifest = await manifestResponse.json();
432
+ const to = new URL(String(manifest?.referrals?.statements ?? ''));
433
+ if (to.protocol !== 'https:' || to.host !== via || to.username || to.password) throw new Error('no statement address in its manifest');
434
+ const res = await fetcher(to.href, { method: 'POST', redirect: 'manual', headers: { 'content-type': 'application/json' }, body: JSON.stringify(envelope), signal: AbortSignal.timeout(8000) });
435
+ result = { via, ok: res.ok, status: res.status };
436
+ } catch (error) { result = { via, ok: false, why: String(error?.message ?? error).slice(0, 80) }; }
437
+ if (!result.ok && state.attempts < 3) {
438
+ const retryAfter = result.status === 429 ? 60 : 2 ** (state.attempts + 1);
439
+ await save({ ...state, attempts: state.attempts + 1, nextAt: Date.now() + retryAfter * 1000 });
440
+ return { sent: [], nextCursor: 'resume', retryAfter };
441
+ }
442
+ let next = page.nextCursor;
443
+ if (!result.ok) {
444
+ // Skip this referrer's remaining pages after a persistent failure; the next referrer still runs.
445
+ const [edition] = readCursor(next);
446
+ next = JSON.stringify([edition, JSON.stringify([via, envelope.statement.currency]), '', '', '']);
326
447
  }
327
- return sent;
448
+ const retryAfter = 2.1;
449
+ await save({ cursor: next, attempts: 0, failures: [...(state.failures ?? []), ...(!result.ok ? [result] : [])], nextAt: Date.now() + retryAfter * 1000, done: next === null });
450
+ return { sent: [result], nextCursor: next === null ? null : 'resume', retryAfter };
451
+ }
452
+
453
+ export async function statementFailures(env) {
454
+ 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 ?? [];
455
+ return rows.flatMap((r) => JSON.parse(r.value).failures ?? []);
456
+ }
457
+
458
+ /** A default Send resumes an unfinished period even if the calendar month changed while closed. */
459
+ export async function pendingStatementPeriod(env, origin) {
460
+ const prefix = `referral-send:${origin}|`;
461
+ 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();
462
+ return row ? row.key.slice(prefix.length) : null;
328
463
  }
@@ -0,0 +1,54 @@
1
+ /**
2
+ * A whole server-hosted room in SQLite. A small room updates one row; a large one uses pieces in the same synchronous
3
+ * transaction. No index besides the integer primary key: the default is one row written per second, not two.
4
+ * The boot row outlives a save being replayed, so two restores of that save can never reuse an epoch.
5
+ */
6
+ export function roomStore(storage) {
7
+ const sql = storage.sql;
8
+ sql.exec('CREATE TABLE IF NOT EXISTS save (id INTEGER PRIMARY KEY, data BLOB NOT NULL)');
9
+ sql.exec('CREATE TABLE IF NOT EXISTS boots (id INTEGER PRIMARY KEY, data TEXT NOT NULL)');
10
+ const encoder = new TextEncoder();
11
+ const decoder = new TextDecoder('utf-8', { fatal: true });
12
+ const pieceBytes = 1_000_000;
13
+ return {
14
+ read() {
15
+ if (this.boots()?.retired === true) return null;
16
+ const total = sql.exec('SELECT SUM(length(data)) AS bytes FROM save').toArray()[0]?.bytes ?? 0;
17
+ if (total > 16_000_000) throw new Error('save exceeds 16 MB');
18
+ const rows = sql.exec('SELECT id, data FROM save ORDER BY id').toArray();
19
+ if (!rows.length) return null;
20
+ if (rows.some((r, i) => r.id !== i) || rows.reduce((n, r) => n + r.data.byteLength, 0) > 16_000_000) throw new Error('save pieces missing or save exceeds 16 MB');
21
+ const pieces = rows.map((r) => new Uint8Array(r.data));
22
+ const bytes = new Uint8Array(pieces.reduce((n, p) => n + p.length, 0));
23
+ let at = 0;
24
+ for (const p of pieces) { bytes.set(p, at); at += p.length; }
25
+ const value = JSON.parse(decoder.decode(bytes));
26
+ if (typeof value.host === 'string') {
27
+ const hostText = value.host;
28
+ value.host = JSON.parse(hostText);
29
+ Object.defineProperty(value, 'hostText', { value: hostText });
30
+ }
31
+ return value;
32
+ },
33
+ write(value) {
34
+ const bytes = encoder.encode(JSON.stringify(value));
35
+ if (bytes.length > 16_000_000) throw new Error('save exceeds 16 MB');
36
+ storage.transactionSync(() => {
37
+ let id = 0;
38
+ for (let at = 0; at < bytes.length; at += pieceBytes) sql.exec('INSERT INTO save (id, data) VALUES (?, ?) ON CONFLICT(id) DO UPDATE SET data = excluded.data', id++, bytes.slice(at, at + pieceBytes));
39
+ sql.exec('DELETE FROM save WHERE id >= ?', id);
40
+ });
41
+ },
42
+ boots() { return JSON.parse(sql.exec('SELECT data FROM boots WHERE id = 1').toArray()[0]?.data ?? 'null'); },
43
+ boot(value) { sql.exec('INSERT INTO boots (id, data) VALUES (1, ?) ON CONFLICT(id) DO UPDATE SET data = excluded.data', JSON.stringify(value)); },
44
+ retire() { this.boot({ retired: true }); },
45
+ clear() { storage.transactionSync(() => { sql.exec('DELETE FROM save'); sql.exec('DELETE FROM boots'); }); },
46
+ };
47
+ }
48
+
49
+ /** A repeatable delay spreads rooms that shared a failing isolate over thirty seconds. */
50
+ export function restoreDelay(id) {
51
+ let h = 2166136261;
52
+ for (const c of id) h = Math.imul(h ^ c.charCodeAt(0), 16777619) >>> 0;
53
+ return h % 30_001;
54
+ }