@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.
- package/CHANGELOG.md +61 -1
- package/README.md +59 -0
- package/bin/homie-studio.mjs +68 -2
- package/chat/CHAT.md +101 -0
- package/chat/OWNERS.md +78 -0
- package/lib/build.mjs +20 -2
- package/lib/chat-cli.mjs +89 -0
- package/lib/cloudflare.mjs +5 -2
- package/lib/scaffold.mjs +59 -2
- package/lib/shop.mjs +270 -0
- package/lib/template-history.json +77 -0
- package/netplay/NETPLAY.md +194 -4
- package/netplay/netplay.ts +156 -3
- package/package.json +7 -1
- package/port/index.ts +3 -2
- package/port/view.ts +238 -0
- package/shop/SELLING.md +83 -0
- package/shop/SHOP.md +154 -0
- package/shop/shop.ts +130 -0
- package/site/SITE.md +22 -1
- package/starters/ember-vale/src/main.ts +22 -2
- package/starters/gem-rush/src/main.ts +21 -1
- package/starters/gem-rush-3d/src/main.ts +22 -1
- package/worker/account-page.mjs +14 -2
- package/worker/agents.mjs +3 -3
- package/worker/chat-page.mjs +556 -0
- package/worker/chat-store.mjs +161 -0
- package/worker/chat-words.mjs +46 -0
- package/worker/chat.mjs +419 -0
- package/worker/index.mjs +189 -7
- package/worker/office-page.mjs +120 -5
- package/worker/office.mjs +173 -6
- package/worker/pages.mjs +25 -8
- package/worker/players.mjs +8 -0
- package/worker/referrals.mjs +328 -0
- package/worker/room.mjs +291 -11
- package/worker/servers.mjs +5 -1
- package/worker/shop-page.mjs +532 -0
- package/worker/shop-rules.mjs +277 -0
- package/worker/shop-store.mjs +217 -0
- package/worker/shop.mjs +686 -0
- package/worker/site.mjs +4 -1
- package/worker/stats.mjs +4 -1
- package/worker/stripe.mjs +149 -0
- package/worker/version.mjs +1 -1
package/worker/site.mjs
CHANGED
|
@@ -690,7 +690,7 @@ export function watchOf(g) {
|
|
|
690
690
|
* A room as the live list draws it (the same fields /api/rooms and /<game>/live give the script). A server's room
|
|
691
691
|
* (0.16.0) names its server and policy, and how many AI are in it (always marked AI).
|
|
692
692
|
*/
|
|
693
|
-
export function roomView(g, r, max, srv = null) {
|
|
693
|
+
export function roomView(g, r, max, srv = null, { chat = false } = {}) {
|
|
694
694
|
const room = r.name ?? r.room;
|
|
695
695
|
const m = /^(?:pub|s-[a-z0-9-]+)-(\d+)$/.exec(String(room ?? ''));
|
|
696
696
|
const n = m ? Number(m[1]) : null;
|
|
@@ -701,6 +701,9 @@ export function roomView(g, r, max, srv = null) {
|
|
|
701
701
|
server: srv && srv.id !== 'public' ? { id: srv.id, name: srv.name, badge: policyWords(srv).badge } : null,
|
|
702
702
|
policy: srv?.policy ?? 'open',
|
|
703
703
|
ai: Number(r.ai ?? r.agents ?? 0) || 0,
|
|
704
|
+
// Room chat (0.23.0, NETPLAY.md section 19): its chat is on and homie.rocks's page for it may show it (and open the
|
|
705
|
+
// room's watch socket for its lines and emoji).
|
|
706
|
+
...(chat ? { chat: true } : {}),
|
|
704
707
|
};
|
|
705
708
|
}
|
|
706
709
|
|
package/worker/stats.mjs
CHANGED
|
@@ -59,7 +59,10 @@ CREATE TABLE IF NOT EXISTS stats_keys (
|
|
|
59
59
|
) WITHOUT ROWID;
|
|
60
60
|
`;
|
|
61
61
|
|
|
62
|
-
export const METRICS = Object.freeze(['visit', 'play', 'screen', 'watch', 'room', 'round', 'humans', 'peak', 'peak-room', 'song', 'video', 'agent-minutes', 'brain-calls', 'brain-neurons', 'brain-microdollars'
|
|
62
|
+
export const METRICS = Object.freeze(['visit', 'play', 'screen', 'watch', 'room', 'round', 'humans', 'peak', 'peak-room', 'song', 'video', 'agent-minutes', 'brain-calls', 'brain-neurons', 'brain-microdollars',
|
|
63
|
+
// Room chat (0.23.0): lines and reactions sent, messages held (by why: words, link, ai…), the review's calls and neurons.
|
|
64
|
+
// Counts only: never a message, never who.
|
|
65
|
+
'chat-lines', 'chat-reacts', 'chat-held', 'chat-reviews', 'chat-neurons']);
|
|
63
66
|
const MAX_METRICS = new Set(['peak', 'peak-room']);
|
|
64
67
|
const SLUG = /^[a-z0-9][a-z0-9-]{0,39}$/;
|
|
65
68
|
const DAY = /^\d{4}-\d{2}-\d{2}$/;
|
|
@@ -0,0 +1,149 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* THE STUDIO'S OWN STRIPE, from its Worker (@homie-rocks/studio 0.24.0). No SDK: a few form-encoded calls with the
|
|
3
|
+
* studio's restricted key (a Worker secret), and Stripe's documented webhook signature check with WebCrypto.
|
|
4
|
+
*
|
|
5
|
+
* createCheckoutSession(env, params) POST /v1/checkout/sessions (Stripe's hosted page; the TV never calls it)
|
|
6
|
+
* createRefund(env, params) POST /v1/refunds (only ever from the owner's tap, or a confirmed ask)
|
|
7
|
+
* verifyWebhook(payload, header, secret) the `Stripe-Signature` header: t=<seconds>,v1=<hex HMAC-SHA256 of
|
|
8
|
+
* "<t>.<payload>"> with the endpoint's whsec_ secret, within 5 minutes
|
|
9
|
+
*
|
|
10
|
+
* The key is never logged, never put in a page, never returned. STRIPE_API_BASE points the Worker at a stand-in
|
|
11
|
+
* (stripe-mock in the kit's own tests; a loopback address only) and never at anything else.
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
/** The API version the kit is written against: the first one Managed Payments takes ("2025-03-31.basil or later"). */
|
|
15
|
+
export const STRIPE_VERSION = '2025-03-31.basil';
|
|
16
|
+
export const STRIPE_API = 'https://api.stripe.com';
|
|
17
|
+
/** The webhook's freshness window, as Stripe's own libraries use: a replayed old event is refused. */
|
|
18
|
+
export const TOLERANCE_S = 300;
|
|
19
|
+
|
|
20
|
+
/** A restricted key (rk_test_/rk_live_), or a test secret key for local tests. A live full secret key is refused. */
|
|
21
|
+
export const KEY_SHAPE = /^(?:rk_(?:test|live)|sk_test)_[A-Za-z0-9]{10,247}$/;
|
|
22
|
+
export const WEBHOOK_SECRET_SHAPE = /^whsec_[A-Za-z0-9+/=_-]{16,200}$/;
|
|
23
|
+
|
|
24
|
+
/** test or live, from the key alone (never by calling Stripe). */
|
|
25
|
+
export const modeOf = (key) => (/^(?:rk|sk)_live_/.test(String(key ?? '')) ? 'live' : 'test');
|
|
26
|
+
|
|
27
|
+
/** Where the Worker sends Stripe calls: Stripe, or (tests) a loopback stand-in. */
|
|
28
|
+
export function apiBase(env) {
|
|
29
|
+
const b = String(env?.STRIPE_API_BASE ?? '');
|
|
30
|
+
if (!b) return STRIPE_API;
|
|
31
|
+
try {
|
|
32
|
+
const u = new URL(b);
|
|
33
|
+
if (u.protocol === 'http:' && ['127.0.0.1', 'localhost', '[::1]'].includes(u.hostname)) return u.origin;
|
|
34
|
+
} catch { /* not an address */ }
|
|
35
|
+
return STRIPE_API;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/** Stripe's form encoding: nested objects and arrays as a[b][0][c]=v. Undefined and null are left out. */
|
|
39
|
+
export function formEncode(params, prefix = '', out = new URLSearchParams()) {
|
|
40
|
+
for (const [k, v] of Object.entries(params ?? {})) {
|
|
41
|
+
if (v === undefined || v === null) continue;
|
|
42
|
+
const key = prefix ? `${prefix}[${k}]` : k;
|
|
43
|
+
if (Array.isArray(v)) v.forEach((x, i) => (x !== null && typeof x === 'object' ? formEncode(x, `${key}[${i}]`, out) : out.append(`${key}[${i}]`, String(x))));
|
|
44
|
+
else if (typeof v === 'object') formEncode(v, key, out);
|
|
45
|
+
else out.append(key, typeof v === 'boolean' ? (v ? 'true' : 'false') : String(v));
|
|
46
|
+
}
|
|
47
|
+
return out;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export class StripeError extends Error {
|
|
51
|
+
constructor(status, body) {
|
|
52
|
+
const e = body?.error ?? {};
|
|
53
|
+
super(e.message ? String(e.message).slice(0, 300) : `Stripe answered ${status}`);
|
|
54
|
+
this.status = status;
|
|
55
|
+
this.code = e.code ?? e.type ?? 'stripe';
|
|
56
|
+
this.type = e.type ?? null;
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/** One call with the studio's key. `idempotencyKey` makes a retried POST do the work once (Stripe keeps it 24 h). */
|
|
61
|
+
export async function stripeCall(env, method, path, params = null, { idempotencyKey = null, fetcher = fetch } = {}) {
|
|
62
|
+
const key = String(env?.STRIPE_KEY ?? '');
|
|
63
|
+
if (!KEY_SHAPE.test(key)) throw new StripeError(0, { error: { message: 'no Stripe key on this Worker', code: 'no-key' } });
|
|
64
|
+
const body = params && method !== 'GET' ? formEncode(params).toString() : null;
|
|
65
|
+
const query = params && method === 'GET' ? `?${formEncode(params).toString()}` : '';
|
|
66
|
+
const res = await fetcher(`${apiBase(env)}${path}${query}`, {
|
|
67
|
+
method,
|
|
68
|
+
headers: {
|
|
69
|
+
authorization: `Bearer ${key}`,
|
|
70
|
+
'stripe-version': STRIPE_VERSION,
|
|
71
|
+
...(body !== null ? { 'content-type': 'application/x-www-form-urlencoded' } : {}),
|
|
72
|
+
...(idempotencyKey ? { 'idempotency-key': idempotencyKey } : {}),
|
|
73
|
+
},
|
|
74
|
+
...(body !== null ? { body } : {}),
|
|
75
|
+
signal: AbortSignal.timeout(15_000),
|
|
76
|
+
});
|
|
77
|
+
let json = null;
|
|
78
|
+
try { json = await res.json(); } catch { json = null; }
|
|
79
|
+
if (!res.ok) throw new StripeError(res.status, json);
|
|
80
|
+
return json;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
export const createCheckoutSession = (env, params, opts) => stripeCall(env, 'POST', '/v1/checkout/sessions', params, opts);
|
|
84
|
+
export const retrieveCheckoutSession = (env, id, opts) => stripeCall(env, 'GET', `/v1/checkout/sessions/${encodeURIComponent(id)}`, null, opts);
|
|
85
|
+
export const createRefund = (env, params, opts) => stripeCall(env, 'POST', '/v1/refunds', params, opts);
|
|
86
|
+
|
|
87
|
+
/* ------------------------------------------------------------------ webhooks */
|
|
88
|
+
|
|
89
|
+
const enc = new TextEncoder();
|
|
90
|
+
const toHex = (buf) => [...new Uint8Array(buf)].map((b) => b.toString(16).padStart(2, '0')).join('');
|
|
91
|
+
|
|
92
|
+
/** Constant time for equal-length strings: a wrong signature takes as long as a nearly-right one. */
|
|
93
|
+
function sameText(a, b) {
|
|
94
|
+
if (typeof a !== 'string' || typeof b !== 'string' || a.length !== b.length) return false;
|
|
95
|
+
let d = 0;
|
|
96
|
+
for (let i = 0; i < a.length; i += 1) d |= a.charCodeAt(i) ^ b.charCodeAt(i);
|
|
97
|
+
return d === 0;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/** The v1 signature Stripe sends for `payload` at `t` (also what the tests sign with a test secret). */
|
|
101
|
+
export async function signPayload(payload, secret, t) {
|
|
102
|
+
const key = await crypto.subtle.importKey('raw', enc.encode(secret), { name: 'HMAC', hash: 'SHA-256' }, false, ['sign']);
|
|
103
|
+
return toHex(await crypto.subtle.sign('HMAC', key, enc.encode(`${t}.${payload}`)));
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/**
|
|
107
|
+
* Check a webhook: `{ ok: true, event }` or `{ ok: false, why }`. The payload is the raw request body (never parsed
|
|
108
|
+
* before the check); any v1 signature in the header may match (Stripe sends more than one while a secret rolls).
|
|
109
|
+
*/
|
|
110
|
+
export async function verifyWebhook(payload, header, secret, { now = Date.now(), tolerance = TOLERANCE_S } = {}) {
|
|
111
|
+
if (!WEBHOOK_SECRET_SHAPE.test(String(secret ?? ''))) return { ok: false, why: 'no-secret' };
|
|
112
|
+
if (typeof header !== 'string' || header.length > 4096) return { ok: false, why: 'no-signature' };
|
|
113
|
+
let t = null;
|
|
114
|
+
const sigs = [];
|
|
115
|
+
for (const part of header.split(',')) {
|
|
116
|
+
const [k, v] = part.trim().split('=');
|
|
117
|
+
if (k === 't' && /^\d{1,12}$/.test(v ?? '')) t = Number(v);
|
|
118
|
+
if (k === 'v1' && /^[a-f0-9]{64}$/.test(v ?? '')) sigs.push(v);
|
|
119
|
+
}
|
|
120
|
+
if (t === null || !sigs.length) return { ok: false, why: 'no-signature' };
|
|
121
|
+
if (Math.abs(now / 1000 - t) > tolerance) return { ok: false, why: 'too-old' };
|
|
122
|
+
const want = await signPayload(payload, secret, t);
|
|
123
|
+
if (!sigs.some((s) => sameText(s, want))) return { ok: false, why: 'bad-signature' };
|
|
124
|
+
let event = null;
|
|
125
|
+
try { event = JSON.parse(payload); } catch { return { ok: false, why: 'json' }; }
|
|
126
|
+
if (!event || typeof event !== 'object' || typeof event.id !== 'string' || typeof event.type !== 'string') return { ok: false, why: 'not-an-event' };
|
|
127
|
+
return { ok: true, event };
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
/** The Stripe Dashboard's own page for a thing (the office links there; it never copies Stripe's books). */
|
|
131
|
+
export function dashboardLink(mode, kind, id = '') {
|
|
132
|
+
const base = `https://dashboard.stripe.com${mode === 'test' ? '/test' : ''}`;
|
|
133
|
+
const safe = /^[A-Za-z0-9_]{1,255}$/.test(String(id)) ? id : '';
|
|
134
|
+
switch (kind) {
|
|
135
|
+
case 'payment': return safe ? `${base}/payments/${safe}` : `${base}/payments`;
|
|
136
|
+
case 'payments': return `${base}/payments`;
|
|
137
|
+
case 'refunds': return `${base}/refunds`;
|
|
138
|
+
case 'dispute': return safe ? `${base}/disputes/${safe}` : `${base}/disputes`;
|
|
139
|
+
case 'disputes': return `${base}/disputes`;
|
|
140
|
+
case 'payouts': return `${base}/payouts`;
|
|
141
|
+
case 'balance': return `${base}/balance/overview`;
|
|
142
|
+
case 'tax': return `${base}/tax`;
|
|
143
|
+
case 'keys': return `${base}/apikeys`;
|
|
144
|
+
case 'webhooks': return `${base}/workbench/webhooks`;
|
|
145
|
+
case 'invoices': return `${base}/invoices/create`;
|
|
146
|
+
case 'managed-payments': return `${base}/settings/managed-payments`;
|
|
147
|
+
default: return base;
|
|
148
|
+
}
|
|
149
|
+
}
|
package/worker/version.mjs
CHANGED
|
@@ -1,2 +1,2 @@
|
|
|
1
1
|
/** The @homie-rocks/studio version this Worker code is (package.json's; a test keeps them equal). Cache-busts /_homie/site.js. */
|
|
2
|
-
export const STUDIO_VERSION_TAG = '0.
|
|
2
|
+
export const STUDIO_VERSION_TAG = '0.24.0';
|