@homie-rocks/studio 0.22.0 → 0.24.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 (45) hide show
  1. package/CHANGELOG.md +61 -1
  2. package/README.md +59 -0
  3. package/bin/homie-studio.mjs +68 -2
  4. package/chat/CHAT.md +101 -0
  5. package/chat/OWNERS.md +78 -0
  6. package/lib/build.mjs +20 -2
  7. package/lib/chat-cli.mjs +89 -0
  8. package/lib/cloudflare.mjs +5 -2
  9. package/lib/scaffold.mjs +59 -2
  10. package/lib/shop.mjs +270 -0
  11. package/lib/template-history.json +77 -0
  12. package/netplay/NETPLAY.md +194 -4
  13. package/netplay/netplay.ts +156 -3
  14. package/package.json +7 -1
  15. package/port/index.ts +3 -2
  16. package/port/view.ts +238 -0
  17. package/shop/SELLING.md +83 -0
  18. package/shop/SHOP.md +154 -0
  19. package/shop/shop.ts +130 -0
  20. package/site/SITE.md +22 -1
  21. package/starters/ember-vale/src/main.ts +22 -2
  22. package/starters/gem-rush/src/main.ts +21 -1
  23. package/starters/gem-rush-3d/src/main.ts +22 -1
  24. package/worker/account-page.mjs +14 -2
  25. package/worker/agents.mjs +3 -3
  26. package/worker/chat-page.mjs +556 -0
  27. package/worker/chat-store.mjs +161 -0
  28. package/worker/chat-words.mjs +46 -0
  29. package/worker/chat.mjs +419 -0
  30. package/worker/index.mjs +189 -7
  31. package/worker/office-page.mjs +120 -5
  32. package/worker/office.mjs +173 -6
  33. package/worker/pages.mjs +25 -8
  34. package/worker/players.mjs +8 -0
  35. package/worker/referrals.mjs +328 -0
  36. package/worker/room.mjs +291 -11
  37. package/worker/servers.mjs +5 -1
  38. package/worker/shop-page.mjs +532 -0
  39. package/worker/shop-rules.mjs +277 -0
  40. package/worker/shop-store.mjs +217 -0
  41. package/worker/shop.mjs +686 -0
  42. package/worker/site.mjs +4 -1
  43. package/worker/stats.mjs +4 -1
  44. package/worker/stripe.mjs +149 -0
  45. package/worker/version.mjs +1 -1
@@ -0,0 +1,328 @@
1
+ /**
2
+ * REFERRALS (@homie-rocks/studio 0.24.0): one set of rules for every referrer, homie.rocks included.
3
+ *
4
+ * arrival A person's browser opens one of this studio's pages with ?via=<host> (a link from another studio,
5
+ * from homie.rocks, from anywhere). If this browser has never been here (no player of this site), the
6
+ * Worker keeps the host and the time in a signed cookie (HttpOnly, this site only, for the window).
7
+ * Prefetches, crawlers, this site itself and server rooms are never recorded.
8
+ * a sale A paid order within the window, by an account made after that first visit, writes a referral line in
9
+ * THIS studio's D1 (the seller's books): the pre-tax price, the published rate, the share, held for
10
+ * `holdDays` (a refund or a lost dispute inside the hold voids it; after it, it nets off the next
11
+ * statement). The referrer must prove itself: its own /.well-known/homie-studio.json says it takes
12
+ * referral statements. No list anywhere says who is real; homie.rocks is not a registry.
13
+ * statements Monthly, signed with this studio's own Ed25519 key (its public half is in the manifest): the order
14
+ * hashes, items, pre-tax amounts, dates and states, and what is owed. No player, name or email. POSTed
15
+ * to the referrer's /api/referrals/statement, which checks the signature against the seller's manifest.
16
+ * paying Never through Homie. The referrer invoices the seller from its own Stripe (or PayPal, Wise, or the
17
+ * seller's gift codes); the seller pays the invoice. The office shows both sides and links to Stripe.
18
+ */
19
+ import { isVisit } from './stats.mjs';
20
+
21
+ const DAY = 86_400_000;
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])?)+$/;
24
+ export const STATEMENT_KIND = 'homie-referral-statement';
25
+
26
+ const b64url = (bytes) => btoa(String.fromCharCode(...new Uint8Array(bytes))).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '');
27
+ const fromB64url = (s) => Uint8Array.from(atob(String(s).replace(/-/g, '+').replace(/_/g, '/') + '='.repeat((4 - (String(s).length % 4)) % 4)), (c) => c.charCodeAt(0));
28
+ const enc = new TextEncoder();
29
+
30
+ /** A host as a referrer names itself: lower-case, no port, no path, never an address. */
31
+ export function hostOf(raw) {
32
+ const h = String(raw ?? '').trim().toLowerCase().replace(/^www\./, '');
33
+ return HOST.test(h) && !/^\d+\.\d+\.\d+\.\d+$/.test(h) ? h : null;
34
+ }
35
+
36
+ /** JSON with sorted keys: what a signature covers, byte for byte, on both sides. */
37
+ export function canonicalJson(v) {
38
+ if (Array.isArray(v)) return `[${v.map(canonicalJson).join(',')}]`;
39
+ if (v && typeof v === 'object') return `{${Object.keys(v).sort().filter((k) => v[k] !== undefined).map((k) => `${JSON.stringify(k)}:${canonicalJson(v[k])}`).join(',')}}`;
40
+ return JSON.stringify(v ?? null);
41
+ }
42
+
43
+ /* ------------------------------------------------------------------ this studio's secrets in D1 meta */
44
+
45
+ async function metaGet(env, key) {
46
+ try { return (await env.DB.prepare('SELECT value FROM meta WHERE key = ?1').bind(key).first())?.value ?? null; } catch { return null; }
47
+ }
48
+ async function metaPutOnce(env, key, value) {
49
+ await env.DB.prepare('INSERT INTO meta (key, value) VALUES (?1, ?2) ON CONFLICT(key) DO NOTHING').bind(key, value).run();
50
+ return metaGet(env, key);
51
+ }
52
+
53
+ let hmacCache = null;
54
+ /** The key that signs the arrival cookie (random, in D1 meta, made on first use). */
55
+ async function cookieKey(env) {
56
+ if (hmacCache?.env === env) return hmacCache.key;
57
+ let hex = await metaGet(env, 'referral_cookie_key');
58
+ if (!hex) {
59
+ const raw = new Uint8Array(32);
60
+ crypto.getRandomValues(raw);
61
+ hex = await metaPutOnce(env, 'referral_cookie_key', [...raw].map((b) => b.toString(16).padStart(2, '0')).join(''));
62
+ }
63
+ const bytes = Uint8Array.from(hex.match(/../g).map((h) => parseInt(h, 16)));
64
+ const key = await crypto.subtle.importKey('raw', bytes, { name: 'HMAC', hash: 'SHA-256' }, false, ['sign']);
65
+ hmacCache = { env, key };
66
+ return key;
67
+ }
68
+ const macOf = async (env, text) => b64url(await crypto.subtle.sign('HMAC', await cookieKey(env), enc.encode(text))).slice(0, 22);
69
+
70
+ let signingCache = null;
71
+ /** This studio's Ed25519 statement key (private half in D1 meta, made on first use; public half in the manifest). */
72
+ export async function signingKey(env) {
73
+ if (signingCache?.env === env) return signingCache.pair;
74
+ let stored = await metaGet(env, 'referral_signing_key');
75
+ if (!stored) {
76
+ const pair = await crypto.subtle.generateKey({ name: 'Ed25519' }, true, ['sign', 'verify']);
77
+ const jwk = await crypto.subtle.exportKey('jwk', pair.privateKey);
78
+ stored = await metaPutOnce(env, 'referral_signing_key', JSON.stringify({ kty: jwk.kty, crv: jwk.crv, d: jwk.d, x: jwk.x }));
79
+ }
80
+ const jwk = JSON.parse(stored);
81
+ const privateKey = await crypto.subtle.importKey('jwk', { ...jwk, key_ops: ['sign'], ext: true }, { name: 'Ed25519' }, false, ['sign']);
82
+ const pair = { privateKey, publicJwk: { kty: 'OKP', crv: 'Ed25519', x: jwk.x } };
83
+ signingCache = { env, pair };
84
+ return pair;
85
+ }
86
+
87
+ export async function signStatement(env, statement) {
88
+ const { privateKey, publicJwk } = await signingKey(env);
89
+ const sig = b64url(await crypto.subtle.sign({ name: 'Ed25519' }, privateKey, enc.encode(canonicalJson(statement))));
90
+ return { v: 1, kind: STATEMENT_KIND, statement, sig, key: publicJwk.x };
91
+ }
92
+
93
+ /** Check a statement's signature against a public key (the `x` of the seller's manifest key). */
94
+ export async function verifyStatement(envelope, x) {
95
+ try {
96
+ if (envelope?.kind !== STATEMENT_KIND || typeof envelope.sig !== 'string' || typeof x !== 'string') return false;
97
+ const pub = await crypto.subtle.importKey('jwk', { kty: 'OKP', crv: 'Ed25519', x, ext: true }, { name: 'Ed25519' }, false, ['verify']);
98
+ return await crypto.subtle.verify({ name: 'Ed25519' }, pub, fromB64url(envelope.sig), enc.encode(canonicalJson(envelope.statement)));
99
+ } catch { return false; }
100
+ }
101
+
102
+ /* ------------------------------------------------------------------ arrival */
103
+
104
+ /** Pages where an arrival may be recorded: the studio's own pages and Play; never a server's page or room. */
105
+ const ARRIVAL_PAGE = /^\/(?:|games\/|shop\/|[a-z0-9][a-z0-9-]{0,39}\/(?:play)?)$/;
106
+
107
+ /**
108
+ * A Set-Cookie for this arrival, or null. Only a person's real page load (not a prefetch, a crawler or this site's
109
+ * own link), with ?via=<host> of another site, from a browser with no player and no earlier arrival here, on a studio
110
+ * that sells and pays referrals, never on a server's page or room.
111
+ */
112
+ export async function arrivalCookie(request, env, url, shop) {
113
+ if (!shop?.open || !shop.referrals || !env?.DB) return null;
114
+ const via = hostOf(url.searchParams.get('via'));
115
+ if (!via || via === hostOf(url.hostname)) return null;
116
+ if (!ARRIVAL_PAGE.test(url.pathname) || url.searchParams.has('room') || url.searchParams.has('server')) return null;
117
+ if (!isVisit(request)) return null;
118
+ const cookies = request.headers.get('cookie') ?? '';
119
+ // Been here before: a player of this site (a guest who saved, or an account), or an earlier arrival.
120
+ if (/(?:^|;\s*)(?:studio_player|studio_via)=/.test(cookies)) return null;
121
+ const at = Math.floor(Date.now() / 1000);
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' : ''}`;
124
+ }
125
+
126
+ /** The arrival this request's browser carries ({ via, at }), checked, or null. */
127
+ export async function arrivalOf(request, env) {
128
+ 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
+ if (!m || !hostOf(m[1])) return null;
131
+ try { if (m[3] !== await macOf(env, `${m[1]}.${m[2]}`)) return null; } catch { return null; }
132
+ return { via: m[1], at: Number(m[2]) * 1000 };
133
+ }
134
+
135
+ /* ------------------------------------------------------------------ is a referrer real? */
136
+
137
+ const proven = new Map();
138
+ /**
139
+ * Whether `host` takes referral statements: its own manifest says so (fetched over https, cached a day; a failure is
140
+ * cached an hour). `fetcher` is for tests.
141
+ */
142
+ export async function referrerOk(host, { fetcher = fetch, now = Date.now() } = {}) {
143
+ const h = hostOf(host);
144
+ if (!h) return false;
145
+ const hit = proven.get(h);
146
+ if (hit && now - hit.at < (hit.ok ? DAY : 3_600_000)) return hit.ok;
147
+ let ok = false;
148
+ try {
149
+ const res = await fetcher(`https://${h}/.well-known/homie-studio.json`, { headers: { accept: 'application/json' }, signal: AbortSignal.timeout(3000) });
150
+ if (res.ok) {
151
+ const m = await res.json();
152
+ const st = String(m?.referrals?.statements ?? '');
153
+ ok = m?.referrals?.accepts === true && st.startsWith(`https://${h}/`);
154
+ }
155
+ } catch { ok = false; }
156
+ proven.set(h, { ok, at: now });
157
+ if (proven.size > 2000) proven.clear();
158
+ return ok;
159
+ }
160
+ /** For tests: forget what was proven. */
161
+ export function resetReferrers() { proven.clear(); }
162
+
163
+ /** What the manifest says about referrals: this studio takes statements (as a referrer) and, if it sells, its terms. */
164
+ export async function manifestReferrals(env, origin, shop, studio) {
165
+ if (studio?.referrals === false || !env?.DB) return null;
166
+ let key = null;
167
+ try { key = (await signingKey(env)).publicJwk; } catch { key = null; }
168
+ return {
169
+ accepts: true,
170
+ statements: `${origin}/api/referrals/statement`,
171
+ ...(key ? { key } : {}),
172
+ // The seller's terms, the same for every referrer (homie.rocks included); absent: this studio pays no referrals.
173
+ ...(shop?.open && shop.referrals ? { terms: shop.referrals } : {}),
174
+ };
175
+ }
176
+
177
+ /* ------------------------------------------------------------------ the seller's books */
178
+
179
+ /**
180
+ * A paid order with an arrival on it: its referral line (pending, held), or nothing when the terms say no (rate 0, out
181
+ * of the window, the per-player cap reached). Statements for D1's batch.
182
+ */
183
+ export async function lineFor(env, order, shop, { touchAt = null, now = Date.now() } = {}) {
184
+ const terms = shop?.referrals;
185
+ 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));
190
+ if (share <= 0) return [];
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)];
194
+ }
195
+
196
+ /** A refund or a lost dispute: a held line is void; a settled one becomes a clawback that nets off the next statement. */
197
+ export function voidLineStatements(env, orderId) {
198
+ return [
199
+ env.DB.prepare("UPDATE referral_lines SET state = 'void' WHERE order_id = ?1 AND state IN ('pending', 'owed')").bind(orderId),
200
+ env.DB.prepare("UPDATE referral_lines SET state = 'clawback' WHERE order_id = ?1 AND state = 'settled'").bind(orderId),
201
+ ];
202
+ }
203
+
204
+ const lineState = (r, now) => (r.state === 'pending' && Number(r.hold_until) <= now ? 'owed' : r.state);
205
+
206
+ async function orderHash(seller, orderId) {
207
+ 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('');
208
+ }
209
+
210
+ /**
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.
213
+ */
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);
229
+ }
230
+ return [...by.values()].map((b) => ({ ...b, due: b.owed - b.clawback }));
231
+ }
232
+
233
+ /** One referrer's signed statement for a period (no player, name or email in it: order hashes only). */
234
+ export async function statementFor(env, origin, book, period, terms) {
235
+ const seller = origin;
236
+ const lines = [];
237
+ 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 });
238
+ const statement = {
239
+ seller, referrer: book.via, period, currency: book.currency, issued: new Date().toISOString(),
240
+ 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 },
242
+ lines,
243
+ note: 'Invoice the seller for "due" from your own Stripe (or the way its terms accept). Nothing moves through Homie.',
244
+ };
245
+ return signStatement(env, statement);
246
+ }
247
+
248
+ /** 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() } = {}) {
250
+ const h = hostOf(via);
251
+ if (!h) return { ok: false, error: 'bad-request', message: 'via is the referrer\'s host' };
252
+ const note = String(ref ?? '').replace(/[^\w .:#/-]/g, '').slice(0, 80) || 'paid';
253
+ 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),
256
+ ]);
257
+ return { ok: true, via: h, ref: note };
258
+ }
259
+
260
+ /* ------------------------------------------------------------------ as a referrer */
261
+
262
+ const received = [];
263
+ /**
264
+ * POST /api/referrals/statement: a seller's signed statement to this studio. It must name this site as the referrer,
265
+ * and its signature must check against the key in the SELLER's own manifest (fetched now, over https). Kept per
266
+ * seller and period; a newer one replaces the older.
267
+ */
268
+ export async function receiveStatement(request, env, url, { fetcher = fetch } = {}) {
269
+ 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' } });
270
+ if (request.method !== 'POST') return json({ ok: false, error: 'method' }, 405);
271
+ // A statement makes this Worker read the seller's manifest: a few a minute is all a real seller sends.
272
+ 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);
277
+ const text = await request.text();
278
+ if (text.length > 65_536) return json({ ok: false, error: 'too-large' }, 413);
279
+ let env0 = null;
280
+ try { env0 = JSON.parse(text); } catch { return json({ ok: false, error: 'json' }, 400); }
281
+ const s = env0?.statement;
282
+ if (env0?.kind !== STATEMENT_KIND || !s || typeof s !== 'object') return json({ ok: false, error: 'not-a-statement' }, 400);
283
+ if (hostOf(s.referrer) !== hostOf(url.hostname)) return json({ ok: false, error: 'not-ours', message: 'this statement names another referrer' }, 400);
284
+ let seller = null;
285
+ try { const u = new URL(String(s.seller)); seller = u.protocol === 'https:' && hostOf(u.hostname) ? u.origin : null; } catch { seller = null; }
286
+ if (!seller || !/^\d{4}-\d{2}$/.test(String(s.period ?? ''))) return json({ ok: false, error: 'bad-statement' }, 400);
287
+ let x = null;
288
+ try {
289
+ const res = await fetcher(`${seller}/.well-known/homie-studio.json`, { headers: { accept: 'application/json' }, signal: AbortSignal.timeout(5000) });
290
+ x = res.ok ? (await res.json())?.referrals?.key?.x ?? null : null;
291
+ } catch { x = null; }
292
+ 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
+ const t = s.totals ?? {};
294
+ 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); }
298
+ return json({ ok: true, seller, period: s.period });
299
+ }
300
+
301
+ /** The statements other studios sent this one: what each says it owes. */
302
+ export async function statementsIn(env) {
303
+ try {
304
+ 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 ?? [])
305
+ .map((r) => ({ seller: r.seller, period: r.period, due: Number(r.owed), pending: Number(r.pending), currency: r.currency, receivedAt: Number(r.received_at) }));
306
+ } catch { return []; }
307
+ }
308
+
309
+ /** 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) }); }
326
+ }
327
+ return sent;
328
+ }