@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.
- package/CHANGELOG.md +85 -2
- package/README.md +34 -9
- package/agents/agents.ts +57 -24
- package/bin/homie-studio.mjs +13 -4
- package/lib/build.mjs +64 -9
- package/lib/cf-usage.mjs +76 -0
- package/lib/cloudflare.mjs +25 -2
- package/lib/dev.mjs +45 -4
- package/lib/lab-check.mjs +3 -2
- package/lib/mcp-tools.mjs +1 -1
- package/lib/mcp.mjs +1 -1
- package/lib/office.mjs +7 -0
- package/lib/perf.mjs +8 -3
- package/lib/player-image.mjs +40 -0
- package/lib/rules-build.mjs +184 -0
- package/lib/rules-guard.mjs +770 -0
- package/lib/scaffold.mjs +43 -16
- package/lib/shop-catalog.mjs +10 -9
- package/lib/shop.mjs +32 -15
- package/lib/standalone-cli.mjs +30 -2
- package/lib/standalone-tools.mjs +20 -2
- package/lib/standalone.mjs +215 -9
- package/lib/studio.mjs +21 -1
- package/lib/template-history.json +90 -0
- package/lib/upgrade.mjs +19 -15
- package/lib/version.mjs +2 -7
- package/netplay/NETPLAY.md +249 -4
- package/netplay/netplay.ts +55 -13
- package/package.json +23 -1
- package/rules/core.ts +1250 -0
- package/rules/guard.ts +419 -0
- package/rules/host.ts +707 -0
- package/rules/math.ts +266 -0
- package/rules/pack.ts +292 -0
- package/rules/rules.ts +544 -0
- package/rules/view.ts +447 -0
- package/shop/SELLING.md +14 -16
- package/shop/SHOP.md +119 -30
- package/shop/shop.ts +6 -8
- package/site/SITE.md +119 -2
- package/standalone/STANDALONE.md +61 -6
- package/starters/coin-dash/game.json +11 -0
- package/starters/coin-dash/index.html +17 -0
- package/starters/coin-dash/map/main.json +16 -0
- package/starters/coin-dash/src/move.ts +27 -0
- package/starters/coin-dash/src/rules.ts +86 -0
- package/starters/coin-dash/src/view.ts +106 -0
- package/starters/coin-dash/tunables.json +5 -0
- package/starters/ember-vale/index.html +11 -0
- package/worker/brain.mjs +8 -7
- package/worker/chat-page.mjs +1 -1
- package/worker/embed.mjs +135 -0
- package/worker/hosted.mjs +56 -0
- package/worker/index.mjs +413 -22
- package/worker/office-page.mjs +1 -0
- package/worker/office.mjs +24 -7
- package/worker/pages.mjs +189 -43
- package/worker/players.mjs +10 -6
- package/worker/referrals.mjs +192 -57
- package/worker/room-store.mjs +54 -0
- package/worker/room.mjs +294 -119
- package/worker/saves-shell.mjs +7 -1
- package/worker/schema.mjs +4 -3
- package/worker/shop-page.mjs +103 -37
- package/worker/shop-rules.mjs +156 -117
- package/worker/shop-store.mjs +70 -9
- package/worker/shop.mjs +275 -143
- package/worker/site.mjs +26 -5
- package/worker/stripe.mjs +9 -6
- package/worker/version.mjs +1 -1
package/worker/players.mjs
CHANGED
|
@@ -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(
|
|
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
|
|
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));
|
package/worker/referrals.mjs
CHANGED
|
@@ -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,
|
|
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
|
-
|
|
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,
|
|
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
|
-
|
|
188
|
-
|
|
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
|
-
|
|
193
|
-
|
|
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
|
|
212
|
-
*
|
|
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
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
const
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
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
|
-
|
|
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
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
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 >
|
|
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
|
-
|
|
296
|
-
|
|
297
|
-
|
|
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
|
-
|
|
312
|
-
const
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
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
|
-
|
|
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
|
+
}
|