@homie-rocks/studio 0.50.5 → 0.50.7
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 +40 -0
- package/apps/APPS.md +45 -1
- package/bin/homie-studio.mjs +7 -4
- package/functions/FUNCTIONS.md +108 -15
- package/guides/app/SKILL.md +48 -4
- package/guides/functions/SKILL.md +30 -0
- package/guides/office/SKILL.md +37 -0
- package/guides/servers/SKILL.md +35 -0
- package/lib/app-needs.mjs +31 -11
- package/lib/app-ready.mjs +49 -0
- package/lib/build.mjs +1 -1
- package/lib/check.mjs +51 -35
- package/lib/cloudflare.mjs +8 -0
- package/lib/dev.mjs +19 -17
- package/lib/events-cli.mjs +5 -1
- package/lib/functions-build.mjs +10 -3
- package/lib/functions-cli.mjs +9 -3
- package/lib/local-test.mjs +18 -0
- package/lib/office.mjs +1 -1
- package/lib/scaffold.mjs +15 -2
- package/lib/template-history.json +84 -0
- package/lib/tools-cli.mjs +3 -0
- package/lib/worker-config.mjs +20 -3
- package/package.json +1 -1
- package/shop/SHOP.md +1 -1
- package/tools/tools.ts +8 -1
- package/worker/app-format.mjs +2 -1
- package/worker/app-frame.mjs +9 -0
- package/worker/app-records.mjs +16 -9
- package/worker/build-id.mjs +1 -1
- package/worker/event-resources.mjs +30 -3
- package/worker/function-schedule.mjs +68 -0
- package/worker/functions-schema.mjs +13 -0
- package/worker/functions.mjs +50 -11
- package/worker/index.mjs +13 -2
- package/worker/local-test.mjs +19 -0
- package/worker/mcp-tools.mjs +24 -9
- package/worker/mcp.mjs +23 -11
- package/worker/records-page.mjs +12 -0
- package/worker/shop-page.mjs +2 -2
- package/worker/shop.mjs +1 -0
- package/worker/tool-database.mjs +36 -0
- package/worker/version.mjs +1 -1
package/lib/worker-config.mjs
CHANGED
|
@@ -2,6 +2,7 @@ import {readFileSync,writeFileSync,existsSync,renameSync} from 'node:fs';
|
|
|
2
2
|
import {join} from 'node:path';
|
|
3
3
|
import {configPath,readStudio,layoutOf,listExperiences} from './studio.mjs';
|
|
4
4
|
import {wranglerConfig} from './scaffold.mjs';
|
|
5
|
+
import {scheduleTrigger} from '../worker/function-schedule.mjs';
|
|
5
6
|
import {paidReleases} from './parts-upload.mjs';
|
|
6
7
|
const parse=t=>JSON.parse(t.replace(/^\s*\/\/.*$/gm,''));
|
|
7
8
|
const read=file=>existsSync(file)?parse(readFileSync(file,'utf8')):{};
|
|
@@ -10,9 +11,25 @@ function merge(base,custom) {
|
|
|
10
11
|
if(custom && typeof custom==='object')return Object.fromEntries([...new Set([...Object.keys(base??{}),...Object.keys(custom)])].sort().map(k=>[k,k in custom?merge(base?.[k],custom[k]):base[k]]));
|
|
11
12
|
return custom;
|
|
12
13
|
}
|
|
13
|
-
/**
|
|
14
|
+
/** Regenerate from declarations/custom config; migrate only newly encountered manual crons. */
|
|
14
15
|
export function updateWorkerConfig(root,generated) {
|
|
15
|
-
const file=configPath(root),
|
|
16
|
+
const file=configPath(root),base=parse(generated),custom=read(join(root,'wrangler.custom.json'));
|
|
17
|
+
// Preserve studio cron edits made before/after generation, including old wrangler files.
|
|
18
|
+
// Compare against the previous output to discover manual additions. Custom entries
|
|
19
|
+
// retain ownership even when their text equals a generated tick.
|
|
20
|
+
const previous=read(file);
|
|
21
|
+
let previousCrons=[];
|
|
22
|
+
try {previousCrons=JSON.parse(previous.vars?.STUDIO_FUNCTION_CRONS??'[]');}catch {}
|
|
23
|
+
const extra=(previous.triggers?.crons??[]).filter(c=>!previousCrons.includes(c)&&!(custom.triggers?.crons??[]).includes(c));
|
|
24
|
+
if(extra.length) {
|
|
25
|
+
custom.triggers={...custom.triggers,crons:[...new Set([...(custom.triggers?.crons??[]),...extra])].sort()};
|
|
26
|
+
writeFileSync(join(root,'wrangler.custom.json'),JSON.stringify(custom,null,2)+'\n');
|
|
27
|
+
}
|
|
28
|
+
const next=merge(base,custom);
|
|
29
|
+
// Crons are additive: studio-owned entries cannot erase required toolkit ticks.
|
|
30
|
+
const crons=[...new Set([...(base.triggers?.crons??[]),...(custom.triggers?.crons??[])])].sort();
|
|
31
|
+
next.triggers={...next.triggers,crons}; // [] explicitly removes old generated triggers in Wrangler.
|
|
32
|
+
next.vars={...next.vars,STUDIO_FUNCTION_CRONS:JSON.stringify(crons),STUDIO_FUNCTION_MANAGED_CRONS:JSON.stringify(base.triggers?.crons??[])};
|
|
16
33
|
const text='// Generated by homie-studio. Edit studio.json or wrangler.custom.json, then build.\n'+JSON.stringify(next,null,2)+'\n';
|
|
17
34
|
if(!existsSync(file)||readFileSync(file,'utf8')!==text) {const temp=`${file}.${process.pid}.tmp`;writeFileSync(temp,text);renameSync(temp,file);}
|
|
18
35
|
}
|
|
@@ -24,5 +41,5 @@ export function refreshWorkerConfig(root,overrides={}) {
|
|
|
24
41
|
const tools=read(join(root,'site/src/tools/declarations.json'));
|
|
25
42
|
const ai=cf.ai??listExperiences(root).some(g=>g.decide===true || g.chat!==false && (g.chat?.mode??'text')==='text' && g.chat?.ai!==false);
|
|
26
43
|
const paid=paidReleases(root).length>0||existsSync(join(root,'shop.json'))||tools.paid===true;
|
|
27
|
-
updateWorkerConfig(root,wranglerConfig({worker:cf.worker,name:studio.name,d1:cf.d1,d1Id:cf.d1Id,r2:cf.r2,layout:layoutOf(root),ai,paidParts:paid,partsRateLimit:cf.purchaseRateLimit??null,routes:cf.routes??null,eventFeeds:studio.mcp?.events,triggers:functions
|
|
44
|
+
updateWorkerConfig(root,wranglerConfig({worker:cf.worker,name:studio.name,d1:cf.d1,d1Id:cf.d1Id,r2:cf.r2,layout:layoutOf(root),ai,paidParts:paid,partsRateLimit:cf.purchaseRateLimit??null,routes:cf.routes??null,eventFeeds:studio.mcp?.events,triggers:{crons:[scheduleTrigger(functions,{eventFeeds:Object.keys(studio.mcp?.events??{}).length>0})].filter(Boolean)},...overrides}));
|
|
28
45
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@homie-rocks/studio",
|
|
3
|
-
"version": "0.50.
|
|
3
|
+
"version": "0.50.7",
|
|
4
4
|
"description": "A studio for games, music and video on its own Cloudflare: scaffold, multiplayer starters on the netplay contract, a port toolkit that makes existing single-player web games multiplayer, the site Worker with public rooms (Table/Lobby Durable Objects and D1, all on the free Workers plan, no payment method), a site with the hub's sections (Home, Games, Music, Videos, Rooms, Posts with feeds) in the studio's own look, an epic landing page for every game, big songs and videos in the studio's own R2 once it has storage (uploaded, checked by SHA-256, served at the same addresses), deploy, and the homie.rocks directory.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"license": "Apache-2.0",
|
package/shop/SHOP.md
CHANGED
|
@@ -188,7 +188,7 @@ and [expiring open sessions](https://docs.stripe.com/api/checkout/sessions/expir
|
|
|
188
188
|
Replacing an open checkout expires only reservations at least a minute old; a named cancellation can expire immediately.
|
|
189
189
|
Unresolved orders become eligible for reconciliation after one minute, even with no cap. A buyer's next shop
|
|
190
190
|
or owned-items request schedules up to three reads off the response path, with atomic claims and backoff up
|
|
191
|
-
to an hour; an optional Worker cron invokes the same reconciliation across buyers.
|
|
191
|
+
to an hour; an optional Worker cron invokes the same reconciliation across buyers. Shop/paid-part studios install a five-minute maintenance trigger. Declared functions install their own required scheduling/retry trigger. Put additional studio crons in root `wrangler.custom.json`; build and deploy merge and preserve them.
|
|
192
192
|
With an API key, every recorded payment checks Stripe’s refunds and dispute state, including payments recovered by an early refund or dispute event. The thanks page can verify a payment directly with the studio's key. Webhook events require Stripe's signature.
|
|
193
193
|
Test and live books stay separate. Owner release attempts to expire an open session, records who released it
|
|
194
194
|
and when, and never prevents a later verified payment grant.
|
package/tools/tools.ts
CHANGED
|
@@ -7,7 +7,14 @@ export interface ToolContext {
|
|
|
7
7
|
office(path: string, body?: Record<string, unknown>, query?: Record<string, string>): Promise<unknown>;
|
|
8
8
|
shop(): Promise<unknown>;
|
|
9
9
|
rooms: { send(event: {game: string; room: string; event: string; data: unknown}): Promise<unknown> };
|
|
10
|
-
database: {
|
|
10
|
+
database: {
|
|
11
|
+
get(key: string): Promise<unknown>;
|
|
12
|
+
getVersioned(key: string): Promise<{value: unknown; version: number} | null>;
|
|
13
|
+
put(key: string, value: unknown, options?: {version?: number}): Promise<{ok: boolean; version?: number; conflict?: boolean}>;
|
|
14
|
+
increment(key: string, amount?: number): Promise<{value: number; version: number}>;
|
|
15
|
+
append(key: string, value: unknown): Promise<{value: unknown[]; version: number}>;
|
|
16
|
+
list(options?: {prefix?: string; after?: string; limit?: number}): Promise<{items: {key: string; value: unknown; version: number}[]; next: string | null}>;
|
|
17
|
+
};
|
|
11
18
|
fetch: typeof fetch;
|
|
12
19
|
fetchWithSecret(name: string, url: string, init?: RequestInit): Promise<Response>;
|
|
13
20
|
ai(model: string, input: unknown): Promise<unknown>;
|
package/worker/app-format.mjs
CHANGED
|
@@ -18,7 +18,7 @@ export function appProblems(meta) {
|
|
|
18
18
|
if (!object(meta.records) || typeof meta.records.persist !== 'boolean' || !object(meta.records.collections)) bad.push('records declares persist and collections');
|
|
19
19
|
for (const [id, c] of Object.entries(collections)) {
|
|
20
20
|
if (!key.test(id) || !object(c) || !object(c.fields)) { bad.push(`collection ${id}: declare fields`); continue; }
|
|
21
|
-
for (const [field, spec] of Object.entries(c.fields)) if (
|
|
21
|
+
for (const [field, spec] of Object.entries(c.fields)) if (!/^[a-z][a-z0-9_]*$/.test(field) || !object(spec) || !['string', 'number', 'boolean'].includes(spec.type) || (spec.enum && (!Array.isArray(spec.enum) || !spec.enum.length))) bad.push(`collection ${id}.${field}: invalid field: names start with a lowercase letter and use lowercase letters, digits and underscore; declare a string, number or boolean type`);
|
|
22
22
|
}
|
|
23
23
|
for (const r of Object.values(roles)) for (const cap of Array.isArray(r?.can) ? r.can : []) if (!Object.hasOwn(meta.records?.collections ?? {}, String(cap).split(':')[1])) bad.push(`unknown collection in ${cap}`);
|
|
24
24
|
for (const [k, v] of Object.entries(meta.words ?? {})) if (!/^[a-z][a-zA-Z0-9-]*$/.test(k) || typeof v !== 'string' || v.length > 500) bad.push(`words.${k}: use a short label`);
|
|
@@ -32,6 +32,7 @@ export function appRole(meta, url) {
|
|
|
32
32
|
}
|
|
33
33
|
export function recordProblem(collection, data, { create = false } = {}) {
|
|
34
34
|
if (!object(data) || JSON.stringify(data).length > 8192) return 'record must be an object below 8 KB';
|
|
35
|
+
for (const k of Object.keys(data)) if (!/^[a-z][a-z0-9_]*$/.test(k)) return `invalid field ${k}: names start with a lowercase letter and use lowercase letters, digits and underscore`;
|
|
35
36
|
for (const k of Object.keys(data)) if (!Object.hasOwn(collection.fields, k)) return `unknown field ${k}`;
|
|
36
37
|
for (const [k, s] of Object.entries(collection.fields)) {
|
|
37
38
|
const v = data[k];
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
/** Opaque frames retain their isolation. Unavailable Web Storage falls back to frame-local memory. */
|
|
2
|
+
export const APP_STORAGE_JS=String.raw`(()=>{
|
|
3
|
+
for(const name of ['localStorage','sessionStorage']) {
|
|
4
|
+
try {const storage=window[name];const probe='homie-storage-probe';storage.setItem(probe,'1');storage.removeItem(probe);continue;}catch{}
|
|
5
|
+
const data=new Map();const methods={getItem:k=>data.get(String(k))??null,setItem:(k,v)=>{data.set(String(k),String(v));},removeItem:k=>{data.delete(String(k));},clear:()=>data.clear(),key:i=>[...data.keys()][Number(i)]??null};
|
|
6
|
+
const storage=new Proxy(methods,{get:(target,key)=>key==='length'?data.size:key in target?target[key]:data.get(String(key)),set:(_,key,value)=>{data.set(String(key),String(value));return true;},deleteProperty:(_,key)=>data.delete(String(key)),ownKeys:()=>[...data.keys()],getOwnPropertyDescriptor:()=>({enumerable:true,configurable:true})});
|
|
7
|
+
Object.defineProperty(window,name,{value:storage,configurable:true});
|
|
8
|
+
}
|
|
9
|
+
})();`;
|
package/worker/app-records.mjs
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import {localSession} from './local-test.mjs';
|
|
1
2
|
import { toolIdentity } from './tool-identity.mjs';
|
|
2
3
|
/** Lasting app records in the studio's own D1. Room hosts never authorize writes. */
|
|
3
4
|
import { appRole, recordProblem } from './app-format.mjs';
|
|
@@ -38,12 +39,14 @@ export async function appAccess(request, env, meta, url) {
|
|
|
38
39
|
const declared = Object.hasOwn(meta.roles ?? {}, role) ? meta.roles[role] : null;
|
|
39
40
|
if (!declared) return { ok: false, status: 404, error: 'unknown role' };
|
|
40
41
|
if (!declared.signIn) return { ok: true, role, can: declared.can };
|
|
42
|
+
const session = await localSession(request, env);
|
|
43
|
+
if (session?.app === meta.id && session.role === role) return {ok:true,role,can:declared.can};
|
|
41
44
|
if (!env.DB) return { ok: false, status: 503, error: 'app roles need the studio database' };
|
|
42
45
|
const caller = toolIdentity(request);
|
|
43
46
|
if (caller) {
|
|
44
47
|
if (caller.owner) return { ok: true, role, can: declared.can };
|
|
45
48
|
if (!caller.id) return { ok: false, status: 401, error: 'sign in to this studio' };
|
|
46
|
-
const grant = await env.DB.prepare(
|
|
49
|
+
const grant = await env.DB.prepare("SELECT player FROM app_roles WHERE app IN (?1, '*') AND role = ?2 AND player = ?3").bind(meta.id, role, caller.id).first();
|
|
47
50
|
return grant ? { ok: true, role, can: declared.can } : { ok: false, status: 403, error: 'this account has no role grant' };
|
|
48
51
|
}
|
|
49
52
|
const link = await env.DB.prepare('SELECT token FROM app_role_links WHERE app = ?1 AND role = ?2').bind(meta.id, role).first();
|
|
@@ -51,7 +54,7 @@ export async function appAccess(request, env, meta, url) {
|
|
|
51
54
|
if (await isOwner(request, env)) return { ok: true, role, can: declared.can };
|
|
52
55
|
const person = await players.of(request, env);
|
|
53
56
|
if (!person || person.guest) return { ok: false, status: 401, error: 'sign in to this studio' };
|
|
54
|
-
const grant = await env.DB.prepare(
|
|
57
|
+
const grant = await env.DB.prepare("SELECT player FROM app_roles WHERE app IN (?1, '*') AND role = ?2 AND player = ?3").bind(meta.id, role, person.id).first();
|
|
55
58
|
return grant ? { ok: true, role, can: declared.can } : { ok: false, status: 403, error: 'this account has no role grant' };
|
|
56
59
|
}
|
|
57
60
|
export async function appRecordsRoute(request, env, meta, url, sub) {
|
|
@@ -69,26 +72,30 @@ export async function appRecordsRoute(request, env, meta, url, sub) {
|
|
|
69
72
|
if (!ID.test(b.player)) return fail(400, 'invalid account');
|
|
70
73
|
const p = await players.get(b.player, env);
|
|
71
74
|
if (!p || p.guest) return fail(400, 'role grants require an existing signed-in account');
|
|
72
|
-
await env.DB.prepare(b.revoke === true ? 'DELETE FROM app_roles WHERE app = ?1 AND role = ?2 AND player = ?3' : 'INSERT OR IGNORE INTO app_roles (app, role, player) VALUES (?1, ?2, ?3)').bind(meta.id, b.role, b.player).run();
|
|
75
|
+
await env.DB.prepare(b.revoke === true ? 'DELETE FROM app_roles WHERE app = ?1 AND role = ?2 AND player = ?3' : 'INSERT OR IGNORE INTO app_roles (app, role, player) VALUES (?1, ?2, ?3)').bind(b.studio === true ? '*' : meta.id, b.role, b.player).run();
|
|
73
76
|
}
|
|
74
77
|
const token = crypto.randomUUID().replaceAll('-', '');
|
|
75
78
|
if (b.rotate === true) await env.DB.prepare('DELETE FROM app_role_links WHERE app = ?1 AND role = ?2').bind(meta.id, b.role).run();
|
|
76
79
|
await env.DB.prepare('INSERT OR IGNORE INTO app_role_links (app, role, token) VALUES (?1, ?2, ?3)').bind(meta.id, b.role, token).run();
|
|
77
80
|
const row = await env.DB.prepare('SELECT token FROM app_role_links WHERE app = ?1 AND role = ?2').bind(meta.id, b.role).first();
|
|
78
|
-
return json({ ok: true, link: `${url.origin}/${meta.id}/open?role=${b.role}&access=${row.token}`, role: b.role });
|
|
81
|
+
return json({ ok: true, link: `${url.origin.replace('127.0.0.1','localhost')}/${meta.id}/open?role=${b.role}&access=${row.token}`, role: b.role });
|
|
79
82
|
}
|
|
80
83
|
const match = /^api\/app\/records\/([a-z][a-z0-9-]{0,39})(?:\/([A-Za-z0-9_-]{1,64}))?$/.exec(sub);
|
|
84
|
+
if(!match && /^api\/app\/records\/[^/]+\//.test(sub))return fail(400,'record id: 1–64 letters, digits, underscore or hyphen');
|
|
81
85
|
if (!match || !Object.hasOwn(meta.records?.collections ?? {}, match[1])) return fail(404, 'unknown collection');
|
|
82
86
|
if (meta.records.persist !== true) return fail(409, 'use netplay state for transient records, or enable records.persist');
|
|
83
87
|
const access = await appAccess(request, env, meta, url);
|
|
84
88
|
if (!access.ok) return fail(access.status, access.error);
|
|
85
89
|
const [, collection, id] = match;
|
|
90
|
+
const session = await localSession(request, env);
|
|
91
|
+
const testScope = (session?.app === meta.id ? session.scope : null) ?? (env.HOMIE_LOCAL_TEST_SECRET ? toolIdentity(request)?.testScope : null);
|
|
92
|
+
const recordApp = testScope ? `${meta.id}:check:${testScope}` : meta.id;
|
|
86
93
|
const op = ({ GET: 'read', POST: 'create', PUT: 'update', DELETE: 'delete' })[request.method];
|
|
87
94
|
if (!op || !access.can.includes(`${op}:${collection}`)) return fail(403, 'role cannot perform this operation');
|
|
88
95
|
if (op !== 'read' && !sameOrigin(request, url) && !(appOriginOf(request) && !meta.roles[access.role].signIn)) return fail(403, 'same origin required');
|
|
89
96
|
if (op === 'read') {
|
|
90
|
-
const rows = id ? [await env.DB.prepare('SELECT id, value, version, updated_at FROM app_records WHERE app = ?1 AND collection = ?2 AND id = ?3').bind(
|
|
91
|
-
: (await env.DB.prepare('SELECT id, value, version, updated_at FROM app_records WHERE app = ?1 AND collection = ?2 ORDER BY updated_at, id LIMIT 1000').bind(
|
|
97
|
+
const rows = id ? [await env.DB.prepare('SELECT id, value, version, updated_at FROM app_records WHERE app = ?1 AND collection = ?2 AND id = ?3').bind(recordApp, collection, id).first()].filter(Boolean)
|
|
98
|
+
: (await env.DB.prepare('SELECT id, value, version, updated_at FROM app_records WHERE app = ?1 AND collection = ?2 ORDER BY updated_at, id LIMIT 1000').bind(recordApp, collection).all()).results;
|
|
92
99
|
return json({ ok: true, records: rows.map((r) => ({ id: r.id, data: JSON.parse(r.value), version: r.version, updatedAt: r.updated_at })) });
|
|
93
100
|
}
|
|
94
101
|
if (!id) return fail(400, 'record id required');
|
|
@@ -96,9 +103,9 @@ export async function appRecordsRoute(request, env, meta, url, sub) {
|
|
|
96
103
|
if (op !== 'delete') { const bad = recordProblem(meta.records.collections[collection], body.data, { create: op === 'create' }); if (bad) return fail(400, bad); }
|
|
97
104
|
if (op !== 'create' && (!Number.isSafeInteger(body.version) || body.version < 1)) return fail(400, 'record version required');
|
|
98
105
|
const stmt = op === 'create'
|
|
99
|
-
? env.DB.prepare('INSERT OR IGNORE INTO app_records (app, collection, id, value, version, updated_at) SELECT ?1, ?2, ?3, ?4, 1, ?5 WHERE (SELECT COUNT(*) FROM app_records WHERE app = ?1 AND collection = ?2) < 1000').bind(
|
|
100
|
-
: op === 'update' ? env.DB.prepare('UPDATE app_records SET value = ?4, version = version + 1, updated_at = ?5 WHERE app = ?1 AND collection = ?2 AND id = ?3 AND version = ?6').bind(
|
|
101
|
-
: env.DB.prepare('DELETE FROM app_records WHERE app = ?1 AND collection = ?2 AND id = ?3 AND version = ?4').bind(
|
|
106
|
+
? env.DB.prepare('INSERT OR IGNORE INTO app_records (app, collection, id, value, version, updated_at) SELECT ?1, ?2, ?3, ?4, 1, ?5 WHERE (SELECT COUNT(*) FROM app_records WHERE app = ?1 AND collection = ?2) < 1000').bind(recordApp, collection, id, JSON.stringify(body.data), Date.now())
|
|
107
|
+
: op === 'update' ? env.DB.prepare('UPDATE app_records SET value = ?4, version = version + 1, updated_at = ?5 WHERE app = ?1 AND collection = ?2 AND id = ?3 AND version = ?6').bind(recordApp, collection, id, JSON.stringify(body.data), Date.now(), body.version)
|
|
108
|
+
: env.DB.prepare('DELETE FROM app_records WHERE app = ?1 AND collection = ?2 AND id = ?3 AND version = ?4').bind(recordApp, collection, id, body.version);
|
|
102
109
|
const result = await stmt.run();
|
|
103
110
|
return result.meta.changes ? json({ ok: true, id, version: op === 'create' ? 1 : body.version + 1 }) : fail(409, 'record changed, already exists, or collection full; reload');
|
|
104
111
|
} catch (error) {
|
package/worker/build-id.mjs
CHANGED
|
@@ -1,2 +1,2 @@
|
|
|
1
1
|
// Generated by lib/build-identity.mjs; identifies the bundled runtime sources.
|
|
2
|
-
export const STUDIO_BUILD = Object.freeze({"version":"0.50.
|
|
2
|
+
export const STUDIO_BUILD = Object.freeze({"version":"0.50.7","sha256":"94d707956333f370dc0a5a65762e08418b6474d19302096c765b96090d9d4845"});
|
|
@@ -17,7 +17,7 @@ export async function readableFeed(env,cat,caller,name) {
|
|
|
17
17
|
if(!app||!app.records?.collections?.[feed.collection])return false;
|
|
18
18
|
if(caller.owner)return true;
|
|
19
19
|
if(launchOf(app,await settingsOf(env,{fresh:true}),env)!=='public')return false;
|
|
20
|
-
return Object.entries(app.roles??{}).some(([role,decl])=>decl.can.includes('read:'+feed.collection)&&(!decl.signIn||caller.roles?.[app.id]?.includes(role)));
|
|
20
|
+
return Object.entries(app.roles??{}).some(([role,decl])=>decl.can.includes('read:'+feed.collection)&&(!decl.signIn||(caller.roles?.[app.id]?.includes(role)||caller.roles?.['*']?.includes(role))));
|
|
21
21
|
}
|
|
22
22
|
// Non-record events have no app read capability; they remain owner-only.
|
|
23
23
|
return caller.owner===true;
|
|
@@ -56,12 +56,14 @@ export async function pushEvents(env,cat,origin) {
|
|
|
56
56
|
}
|
|
57
57
|
|
|
58
58
|
/** One existing Lobby object per OAuth connection: streams never live in a Worker isolate. */
|
|
59
|
+
const streamCaller=(env,connection)=>connection.startsWith('public:')?Promise.resolve({id:null,owner:false,client:null,connection}):currentCaller(env,{connection});
|
|
59
60
|
export class EventStreams {
|
|
60
61
|
constructor(env,ctx){this.env=env;this.ctx=ctx;this.stream=null;this.tail=Promise.resolve();}
|
|
61
|
-
close(stream){stream.cancelled=true;stream.resume?.();try{stream.controller.close();}catch{}if(this.stream===stream)this.stream=null;}
|
|
62
|
+
close(stream){stream.cancelled=true;stream.resume?.();try{stream.controller.close();}catch{}if(this.stream===stream){this.stream=null;this.ctx.waitUntil(this.env.DB.prepare('DELETE FROM mcp_kv WHERE key=?1').bind('tools-stream:'+stream.connection).run().catch(()=>{}));}}
|
|
62
63
|
background(input){this.ctx.waitUntil(this.wake(input).catch(error=>{if(this.stream)this.close(this.stream);console.error('Event stream delivery failed',error);}));}
|
|
63
64
|
async fetch(request) {
|
|
64
65
|
const input=await request.json();
|
|
66
|
+
if(input.op==='tools') {await this.wake(input);return new Response(null,{status:204});}
|
|
65
67
|
if(input.op==='stream') {
|
|
66
68
|
// A reconnect replaces the previous GET, including a half-open socket.
|
|
67
69
|
if(this.stream)this.close(this.stream);
|
|
@@ -73,6 +75,10 @@ export class EventStreams {
|
|
|
73
75
|
const stream={...input,after,name,controller:null,cancelled:false,resume:null};
|
|
74
76
|
const body=new ReadableStream({start:controller=>{stream.controller=controller;controller.enqueue(new TextEncoder().encode(': connected\n\n'));},pull:()=>{const resume=stream.resume;stream.resume=null;resume?.();},cancel:()=>this.close(stream)});
|
|
75
77
|
this.stream=stream;
|
|
78
|
+
const caller=await streamCaller(this.env,connection).catch(()=>null);
|
|
79
|
+
if(!caller){this.close(stream);return new Response('Unauthorized',{status:401});}
|
|
80
|
+
stream.toolSignature=await this.toolSignature(input.tools??[],caller);
|
|
81
|
+
await this.env.DB.prepare('INSERT INTO mcp_kv(key,value) VALUES(?1,?2) ON CONFLICT(key) DO UPDATE SET value=excluded.value').bind('tools-stream:'+connection,JSON.stringify({connection})).run();
|
|
76
82
|
// Replay and later wakes share one ordered pump. Backpressure must not
|
|
77
83
|
// hold up the writer's request or a function handler.
|
|
78
84
|
this.background(input);
|
|
@@ -81,11 +87,16 @@ export class EventStreams {
|
|
|
81
87
|
this.background(input);return new Response(null,{status:204});
|
|
82
88
|
}
|
|
83
89
|
wake(input){const work=this.tail.then(()=>this.deliver(input));this.tail=work.catch(()=>{});return work;}
|
|
90
|
+
async toolSignature(tools,caller) {const visible=[];for(const tool of tools)if(await allowed(tool.audience,caller,this.env))visible.push(tool);return JSON.stringify(visible);}
|
|
84
91
|
async deliver(input) {
|
|
85
92
|
const stream=this.stream;if(!stream)return;
|
|
86
93
|
let caller;
|
|
87
|
-
try{caller=await
|
|
94
|
+
try{caller=await streamCaller(this.env,input.connection);}
|
|
88
95
|
catch{this.close(stream);return;}
|
|
96
|
+
if(input.tools){
|
|
97
|
+
const signature=await this.toolSignature(input.tools,caller);
|
|
98
|
+
if(stream.toolSignature!==signature){stream.toolSignature=signature;stream.controller.enqueue(new TextEncoder().encode('event: message\ndata: '+JSON.stringify({jsonrpc:'2.0',method:'notifications/tools/list_changed'})+'\n\n'));}
|
|
99
|
+
}
|
|
89
100
|
const rows=(await this.env.DB.prepare("SELECT value FROM mcp_kv WHERE key LIKE ?1").bind('event-sub:'+caller.connection+':%').all()).results;
|
|
90
101
|
const subscriptions=[];
|
|
91
102
|
for(const row of rows){const sub=JSON.parse(row.value);if(await readableFeed(this.env,input.cat,caller,sub.name))subscriptions.push(sub);}
|
|
@@ -114,3 +125,19 @@ export class EventStreams {
|
|
|
114
125
|
}
|
|
115
126
|
}
|
|
116
127
|
}
|
|
128
|
+
|
|
129
|
+
/** Refresh active streams whenever this Worker observes a new tool catalogue. */
|
|
130
|
+
export async function pushToolList(env,cat,tools,origin) {
|
|
131
|
+
if(!env.LOBBY)return;
|
|
132
|
+
const publicShape=tools.map(({name,description,inputSchema,audience,kind})=>({name,description,inputSchema,audience,kind}));
|
|
133
|
+
const signature=JSON.stringify(publicShape);
|
|
134
|
+
const previous=await env.DB.prepare("SELECT value FROM mcp_kv WHERE key='tools-catalogue'").first();
|
|
135
|
+
if(previous?.value===signature)return;
|
|
136
|
+
await env.DB.prepare("INSERT INTO mcp_kv(key,value) VALUES('tools-catalogue',?1) ON CONFLICT(key) DO UPDATE SET value=excluded.value").bind(signature).run();
|
|
137
|
+
let after='';
|
|
138
|
+
for(;;){
|
|
139
|
+
const rows=(await env.DB.prepare("SELECT key,value FROM mcp_kv WHERE key LIKE 'tools-stream:%' AND key>?1 ORDER BY key LIMIT 100").bind(after).all()).results;
|
|
140
|
+
for(const row of rows){const connection=JSON.parse(row.value).connection;await eventHub(env,connection).fetch('https://events/__studio_events',{method:'POST',body:JSON.stringify({op:'tools',connection,cat,tools:publicShape,origin})});}
|
|
141
|
+
if(rows.length<100)break;after=rows.at(-1).key;
|
|
142
|
+
}
|
|
143
|
+
}
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
// Cloudflare's five-field UTC cron dialect: Sunday=1, Saturday=7.
|
|
2
|
+
const ranges=[[0,59],[0,23],[1,31],[1,12],[1,7]];
|
|
3
|
+
const aliases=[null,null,null,['JAN','FEB','MAR','APR','MAY','JUN','JUL','AUG','SEP','OCT','NOV','DEC'],['SUN','MON','TUE','WED','THU','FRI','SAT']];
|
|
4
|
+
function field(text,index) {
|
|
5
|
+
const [min,max]=ranges[index],values=new Set(),special=[];
|
|
6
|
+
text=text.toUpperCase();
|
|
7
|
+
if(aliases[index])text=text.replace(/[A-Z]{3}/g,name=>{const n=aliases[index].indexOf(name);if(n<0)throw new Error(`Unknown cron name ${name}`);return String(n+1);});
|
|
8
|
+
for(const part of text.split(',')) {
|
|
9
|
+
if(index===2 && /^(L|LW|(?:[1-9]|[12]\d|3[01])W)$/.test(part) || index===4 && /^[1-7](L|#[1-5])$/.test(part)) {special.push(part);continue;}
|
|
10
|
+
if(!/^(\*|\d+(?:-\d+)?)(?:\/[1-9]\d*)?$/.test(part))throw new Error(`Invalid cron field ${text}`);
|
|
11
|
+
const [base,stepText]=part.split('/'),step=Number(stepText??1);
|
|
12
|
+
const [lo,hi]=base==='*'?[min,max]:base.includes('-')?base.split('-').map(Number):[Number(base),stepText?max:Number(base)];
|
|
13
|
+
if(lo<min||hi>max||lo>hi||!Number.isSafeInteger(step)||step>max-min+1)throw new Error(`Cron field out of range: ${text}`);
|
|
14
|
+
for(let n=lo;n<=hi;n+=step)values.add(n);
|
|
15
|
+
}
|
|
16
|
+
return {values:[...values].sort((a,b)=>a-b),special};
|
|
17
|
+
}
|
|
18
|
+
const lastDay=d=>new Date(Date.UTC(d.getUTCFullYear(),d.getUTCMonth()+1,0)).getUTCDate();
|
|
19
|
+
function weekdayNear(d,day) {
|
|
20
|
+
const last=lastDay(d);if(day>last)return null;
|
|
21
|
+
const weekday=new Date(Date.UTC(d.getUTCFullYear(),d.getUTCMonth(),day)).getUTCDay();
|
|
22
|
+
return weekday===6?(day===1?3:day-1):weekday===0?(day===last?day-2:day+1):day;
|
|
23
|
+
}
|
|
24
|
+
export function parseSchedule(expression) {
|
|
25
|
+
if(typeof expression!=='string'||expression.trim().split(/\s+/).length!==5)throw new Error('Schedule needs five cron fields');
|
|
26
|
+
const parts=expression.trim().split(/\s+/),fields=parts.map(field);
|
|
27
|
+
const dayMatches=d=>{
|
|
28
|
+
const day=d.getUTCDate(),weekday=d.getUTCDay()+1,last=lastDay(d);
|
|
29
|
+
return fields[3].values.includes(d.getUTCMonth()+1) &&
|
|
30
|
+
(fields[2].values.includes(day)||fields[2].special.some(s=>s==='L'?day===last:s==='LW'?day===weekdayNear(d,last):day===weekdayNear(d,Number(s.slice(0,-1))))) &&
|
|
31
|
+
(fields[4].values.includes(weekday)||fields[4].special.some(s=>weekday===Number(s[0])&&(s.endsWith('L')?day+7>last:Math.ceil(day/7)===Number(s.at(-1)))));
|
|
32
|
+
};
|
|
33
|
+
return {parts,fields,dayMatches,matches(time){const d=new Date(time);return dayMatches(d)&&fields[0].values.includes(d.getUTCMinutes())&&fields[1].values.includes(d.getUTCHours());}};
|
|
34
|
+
}
|
|
35
|
+
export function nextDue(expression,after=Date.now()) {
|
|
36
|
+
const cron=typeof expression==='string'?parseSchedule(expression):expression;
|
|
37
|
+
const date=new Date(after);date.setUTCHours(0,0,0,0);
|
|
38
|
+
// A Gregorian calendar repeats after 400 years; this also detects impossible dates.
|
|
39
|
+
for(let days=0;days<=146097;days++,date.setUTCDate(date.getUTCDate()+1)) {
|
|
40
|
+
if(!cron.dayMatches(date))continue;
|
|
41
|
+
for(const hour of cron.fields[1].values)for(const minute of cron.fields[0].values) {
|
|
42
|
+
const time=date.getTime()+hour*3600000+minute*60000;
|
|
43
|
+
if(time>after)return time;
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
return null;
|
|
47
|
+
}
|
|
48
|
+
export function scheduleTrigger(definitions,{eventFeeds=false}={}) {
|
|
49
|
+
if(eventFeeds||definitions.some(f=>f.schedule===undefined))return '* * * * *';
|
|
50
|
+
const schedules=definitions.map(f=>parseSchedule(f.schedule));
|
|
51
|
+
if(!schedules.length)return null;
|
|
52
|
+
if(schedules.every(c=>c.parts.join(' ')===schedules[0].parts.join(' ')))return schedules[0].parts.join(' ');
|
|
53
|
+
return ranges.map(([min,max],i)=>{
|
|
54
|
+
if(schedules.every(c=>c.parts[i]===schedules[0].parts[i]))return schedules[0].parts[i];
|
|
55
|
+
if(i===2||i===4)return '*';
|
|
56
|
+
const values=[...new Set(schedules.flatMap(c=>c.fields[i].values))].sort((a,b)=>a-b);
|
|
57
|
+
return values.length===max-min+1?'*':values.join(',');
|
|
58
|
+
}).join(' ');
|
|
59
|
+
}
|
|
60
|
+
export function scheduleStatus(definitions,crons,now=Date.now()) {
|
|
61
|
+
return definitions.filter(f=>f.schedule!==undefined).map(fn=>{
|
|
62
|
+
try {
|
|
63
|
+
const due=nextDue(fn.schedule,now);
|
|
64
|
+
const runnable=due!==null && crons.some(c=>{try{return parseSchedule(c).matches(due);}catch{return false;}});
|
|
65
|
+
return {function:fn.name,schedule:fn.schedule,nextDue:due===null?null:new Date(due).toISOString(),status:due===null?'Cannot run: schedule has no matching date':runnable?'Configured; awaiting scheduled delivery':'Cannot run: no configured trigger covers the next due time'};
|
|
66
|
+
} catch(error) {return {function:fn.name,schedule:fn.schedule,nextDue:null,status:`Cannot run: ${error.message}`};}
|
|
67
|
+
});
|
|
68
|
+
}
|
|
@@ -84,3 +84,16 @@ WHEN EXISTS (SELECT 1 FROM function_subscriptions WHERE type='order.paid')
|
|
|
84
84
|
'app',NEW.game,'reference',(SELECT value FROM meta WHERE key='shop-reference:' || NEW.id)),NEW.paid_at);
|
|
85
85
|
END;
|
|
86
86
|
`;
|
|
87
|
+
|
|
88
|
+
/** Attempt history is independent of the prunable delivery queue. */
|
|
89
|
+
export const FUNCTIONS_RUNS_MIGRATION_FILE = '0023_function_runs.sql';
|
|
90
|
+
export const FUNCTIONS_RUNS_MIGRATION = `
|
|
91
|
+
CREATE TABLE function_runs (
|
|
92
|
+
seq INTEGER PRIMARY KEY AUTOINCREMENT, id TEXT NOT NULL UNIQUE,
|
|
93
|
+
function TEXT NOT NULL, event TEXT NOT NULL, trigger TEXT NOT NULL,
|
|
94
|
+
started INTEGER NOT NULL, finished INTEGER, outcome TEXT NOT NULL,
|
|
95
|
+
error TEXT, attempt INTEGER NOT NULL
|
|
96
|
+
);
|
|
97
|
+
CREATE INDEX function_runs_function_seq ON function_runs(function,seq);
|
|
98
|
+
CREATE INDEX function_runs_errors_seq ON function_runs(seq) WHERE outcome IN ('failed','interrupted');
|
|
99
|
+
`;
|
package/worker/functions.mjs
CHANGED
|
@@ -1,4 +1,6 @@
|
|
|
1
1
|
import {webhookSubscriptions,webhookFailure,webhookBackoff} from './event-webhooks.mjs';
|
|
2
|
+
import {parseSchedule,scheduleStatus} from './function-schedule.mjs';
|
|
3
|
+
import {redactText} from '../lib/redact.mjs';
|
|
2
4
|
import {feedSubscriptions,pushEvents} from './event-resources.mjs';
|
|
3
5
|
import { services } from './mcp-tools.mjs';
|
|
4
6
|
import { isOwner, sameOrigin } from './office.mjs';
|
|
@@ -63,16 +65,28 @@ export async function runFunctions(env, cat, origin, {definitions, now = Date.no
|
|
|
63
65
|
if(rows.length===100)more=true;
|
|
64
66
|
for (const row of rows) {
|
|
65
67
|
const lease=crypto.randomUUID();
|
|
66
|
-
const
|
|
68
|
+
const started=now();
|
|
69
|
+
const [claimed]=await env.DB.batch([
|
|
70
|
+
env.DB.prepare(`UPDATE function_deliveries SET state='running',lease=?3,due=?4,attempts=attempts+1 WHERE event=?1 AND function=?2 AND state!='complete' AND due<=?5`).bind(row.event,fn.name,lease,started+leaseMs,started),
|
|
71
|
+
env.DB.prepare("UPDATE function_runs SET outcome='interrupted',error='Worker lease expired before completion was recorded' WHERE event=?1 AND function=?2 AND outcome='running' AND EXISTS(SELECT 1 FROM function_deliveries WHERE event=?1 AND function=?2 AND lease=?3)").bind(row.event,fn.name,lease),
|
|
72
|
+
env.DB.prepare("INSERT INTO function_runs(id,function,event,trigger,started,outcome,attempt) SELECT ?1,?2,?3,?4,?5,'running',attempts FROM function_deliveries WHERE event=?3 AND function=?2 AND lease=?1").bind(lease,fn.name,row.event,row.event.startsWith(`schedule:${fn.name}:`)?`schedule: ${JSON.parse(row.data).cron}`:row.type,started),
|
|
73
|
+
]);
|
|
67
74
|
if (!claimed.meta.changes) continue;
|
|
75
|
+
let failure=null;
|
|
68
76
|
try {
|
|
69
|
-
const
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
77
|
+
const stored=JSON.parse(row.data);
|
|
78
|
+
const isolated=env.HOMIE_LOCAL_TEST_SECRET && /^([a-z][a-z0-9-]*):check:([a-zA-Z0-9-]+)$/.exec(stored?.app??'');
|
|
79
|
+
const event=Object.freeze({id:row.event,type:row.type,at:row.at,data:isolated?{...stored,app:isolated[1]}:stored,attempt:row.attempts+1});
|
|
80
|
+
const testScope=isolated?isolated[2]:null;
|
|
81
|
+
await fn.handler(event,services(env,cat,origin,{id:'studio-function',owner:true,client:null,...(testScope?{testScope}:{})},'function:'+fn.name+(testScope?':check:'+testScope:''),fn));
|
|
82
|
+
} catch(error) {failure=fn.webhook?webhookFailure(error):functionError(error,env);}
|
|
83
|
+
const finished=now();
|
|
84
|
+
await env.DB.batch([
|
|
85
|
+
failure===null
|
|
86
|
+
? env.DB.prepare(`UPDATE function_deliveries SET state='complete',completed=?4,error=NULL WHERE event=?1 AND function=?2 AND lease=?3`).bind(row.event,fn.name,lease,finished)
|
|
87
|
+
: env.DB.prepare(`UPDATE function_deliveries SET state='failed',error=?5,due=?4 WHERE event=?1 AND function=?2 AND lease=?3`).bind(row.event,fn.name,lease,finished+(fn.webhook?webhookBackoff(row.attempts+1):leaseMs),fn.webhook?failure:'Function failed; inspect its code and retry'),
|
|
88
|
+
env.DB.prepare("UPDATE function_runs SET outcome=?2,finished=?3,error=?4 WHERE id=?1").bind(lease,failure===null?'complete':'failed',finished,failure),
|
|
89
|
+
]);
|
|
76
90
|
}
|
|
77
91
|
}
|
|
78
92
|
} while(more);
|
|
@@ -82,10 +96,18 @@ export async function runFunctions(env, cat, origin, {definitions, now = Date.no
|
|
|
82
96
|
}
|
|
83
97
|
export async function scheduleFunctions(event,env,cat,origin) {
|
|
84
98
|
const definitions=await syncFunctions(env);
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
99
|
+
const scheduledTime=Math.floor(event.scheduledTime/60000)*60000;
|
|
100
|
+
await env.DB.prepare("INSERT INTO meta(key,value) VALUES('function-last-scheduled',?1) ON CONFLICT(key) DO UPDATE SET value=excluded.value").bind(JSON.stringify({cron:event.cron,scheduledTime})).run();
|
|
101
|
+
for(const fn of definitions) {
|
|
102
|
+
if(fn.schedule===undefined)continue;
|
|
103
|
+
let due=false;
|
|
104
|
+
try {due=parseSchedule(fn.schedule).matches(scheduledTime);} catch {continue;} // An old invalid declaration stays diagnosable via the owner tool.
|
|
105
|
+
if(!due)continue;
|
|
106
|
+
await env.DB.batch([
|
|
107
|
+
env.DB.prepare(`INSERT OR IGNORE INTO studio_events(id,type,data,at) SELECT ?1,?2,?3,?4 WHERE EXISTS(SELECT 1 FROM function_subscriptions WHERE name=?5 AND last_tick<?4)`).bind(`schedule:${fn.name}:${scheduledTime}`,fn.event,JSON.stringify({function:fn.name,cron:fn.schedule,scheduledTime}),scheduledTime,fn.name),
|
|
108
|
+
env.DB.prepare('UPDATE function_subscriptions SET last_tick=MAX(last_tick,?2) WHERE name=?1').bind(fn.name,scheduledTime),
|
|
88
109
|
]);
|
|
110
|
+
}
|
|
89
111
|
await runFunctions(env,cat,origin,{definitions});
|
|
90
112
|
}
|
|
91
113
|
export async function functionsRoute(request,env,cat) {
|
|
@@ -107,3 +129,20 @@ export async function functionsRoute(request,env,cat) {
|
|
|
107
129
|
const rows=(await env.DB.prepare(`SELECT d.*,e.type,e.at FROM function_deliveries d JOIN studio_events e ON e.id=d.event ORDER BY e.at DESC LIMIT 100`).all()).results;
|
|
108
130
|
return new Response(`<!doctype html><meta charset="utf-8"><title>Studio functions</title><h1>Studio functions</h1><p>At-least-once delivery. Use the event ID to deduplicate effects.</p><table><tr><th>Function</th><th>Event ID</th><th>Event</th><th>State</th><th>Attempts</th><th>Error</th><th>Retry</th></tr>${rows.map(r=>`<tr>${[r.function,r.event,r.type,r.state,r.attempts,r.error??''].map(v=>`<td>${esc(v)}</td>`).join('')}<td>${r.state==='failed'?`<form method="post"><input type="hidden" name="retry" value="${esc(r.event)}"><button>Retry</button></form>`:''}</td></tr>`).join('')}</table>`,{headers:{'content-type':'text/html;charset=utf-8','cache-control':'no-store'}});
|
|
109
131
|
}
|
|
132
|
+
|
|
133
|
+
function functionError(error,env) {
|
|
134
|
+
let message=error instanceof Error?error.message:String(error);
|
|
135
|
+
// Remove known binding values as well as recognizable credential formats.
|
|
136
|
+
for(const [key,value] of Object.entries(env))if(typeof value==='string' && value && !['STUDIO_NAME','STUDIO_EVENT_FEEDS','STUDIO_FUNCTION_CRONS','STUDIO_FUNCTION_MANAGED_CRONS'].includes(key))message=message.split(value).join('[redacted]');
|
|
137
|
+
return redactText(message).text;
|
|
138
|
+
}
|
|
139
|
+
export async function functionRuns(env,{name,errors=false,before}={}) {
|
|
140
|
+
const rows=(await env.DB.prepare(`SELECT seq,function,event,trigger,started,finished,outcome,error,attempt FROM function_runs
|
|
141
|
+
WHERE (?1 IS NULL OR function=?1) AND (?2=0 OR outcome IN ('failed','interrupted')) AND (?3 IS NULL OR seq<?3)
|
|
142
|
+
ORDER BY seq DESC LIMIT 101`).bind(name??null,errors?1:0,before??null).all()).results;
|
|
143
|
+
const page=rows.slice(0,100),definitions=await load();
|
|
144
|
+
let crons=[];try {crons=JSON.parse(env.STUDIO_FUNCTION_CRONS??'[]');if(!Array.isArray(crons))crons=[];}catch {}
|
|
145
|
+
const schedules=scheduleStatus(definitions.filter(f=>!name||f.name===name),crons);
|
|
146
|
+
const tick=await env.DB.prepare("SELECT value FROM meta WHERE key='function-last-scheduled'").first();
|
|
147
|
+
return {runs:page.map(r=>({...r,started:new Date(r.started).toISOString(),finished:r.finished===null?null:new Date(r.finished).toISOString()})),next:rows.length>100?page.at(-1).seq:null,schedules,crons,lastScheduled:tick?JSON.parse(tick.value):null,triggerEvidence:'Generated deployment configuration; lastScheduled proves an observed tick. Provider dashboard changes are not queried.'};
|
|
148
|
+
}
|
package/worker/index.mjs
CHANGED
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
import {APP_STORAGE_JS} from './app-frame.mjs';
|
|
2
|
+
import {recordsPage} from './records-page.mjs';
|
|
1
3
|
import { RULES_WIRING_MESSAGE } from './rules-wiring.mjs';
|
|
2
4
|
import { characterStore, characterRequest } from './characters.mjs';
|
|
3
5
|
export { useFunctions } from './functions.mjs';
|
|
@@ -529,8 +531,8 @@ async function gameDocument(request, env, url, game, meta, cat, { agent = null }
|
|
|
529
531
|
...(!agent && !watching && await availableShellShop(env, cat, game) ? { shop: true } : {}),
|
|
530
532
|
};
|
|
531
533
|
let html = await res.text();
|
|
532
|
-
const fallback = meta?.kind === 'app' ? `<script>addEventListener('DOMContentLoaded',function(){if(!document.querySelector('canvas')||document.querySelector('[data-webgl-fallback]'))return;try{var c=document.createElement('canvas');if(c.getContext('webgl2'))return;}catch(e){}var p=document.createElement('p');p.dataset.webglFallback='';p.setAttribute('role','status');p.textContent=${JSON.stringify(meta.words?.fallback ?? 'This device cannot display the 3D scene. Try a browser with WebGL enabled; use any available controls below.').replace(/</g, '\u003c')};p.style.cssText='position:fixed;z-index:2147483647;left:16px;right:16px;
|
|
533
|
-
const head = fallback + `${embedded ? `<script>${EMBED_GAME_JS}</script>` : ''}<script>window.HOMIE_NET=${JSON.stringify(cfg).replace(/</g, '\\u003c')}</script>`;
|
|
534
|
+
const fallback = meta?.kind === 'app' ? `<script>addEventListener('DOMContentLoaded',function(){if(!document.querySelector('canvas')||document.querySelector('[data-webgl-fallback]'))return;try{var c=document.createElement('canvas');if(c.getContext('webgl2'))return;}catch(e){}var p=document.createElement('p');p.dataset.webglFallback='';p.setAttribute('role','status');p.textContent=${JSON.stringify(meta.words?.fallback ?? 'This device cannot display the 3D scene. Try a browser with WebGL enabled; use any available controls below.').replace(/</g, '\u003c')};p.style.cssText='position:fixed;z-index:2147483647;left:16px;right:16px;bottom:16px;padding:12px;background:#fff;color:#222;font:16px system-ui;border-radius:12px';document.body.append(p);});</script>` : '';
|
|
535
|
+
const head = (meta.kind==='app'?`<script>${APP_STORAGE_JS}</script>`:'') + fallback + `${embedded ? `<script>${EMBED_GAME_JS}</script>` : ''}<script>window.HOMIE_NET=${JSON.stringify(cfg).replace(/</g, '\\u003c')}</script>`;
|
|
534
536
|
html = /<head[^>]*>/i.test(html) ? html.replace(/<head([^>]*)>/i, `<head$1>${head}`) : head + html;
|
|
535
537
|
return new Response(html, {
|
|
536
538
|
headers: {
|
|
@@ -610,6 +612,7 @@ export default {
|
|
|
610
612
|
async scheduled(event, env, ctx) {
|
|
611
613
|
const maintenance=(async () => {
|
|
612
614
|
const cat = await catalogue(env, 'https://studio.invalid');
|
|
615
|
+
await notifyTools(env,cat,cat.studio?.url??'https://studio.invalid').catch(error=>console.warn('Tool catalogue refresh:',error.message));
|
|
613
616
|
await reconcileOrders(env, shopOf(cat));
|
|
614
617
|
await (await import('./functions.mjs')).scheduleFunctions(event,env,cat,cat.studio?.url??'https://studio.invalid');
|
|
615
618
|
})();
|
|
@@ -638,6 +641,8 @@ export default {
|
|
|
638
641
|
try { console.error(JSON.stringify({ at: new Date().toISOString(), ev: 'lobby-route-failed', path: url.pathname, error: errorLine(error) })); } catch { /* no console */ }
|
|
639
642
|
res = json({ ok: false, error: 'failed' }, 500);
|
|
640
643
|
} finally {
|
|
644
|
+
// Refresh after the route has populated the catalogue cache.
|
|
645
|
+
if(env.DB&&env.LOBBY&&ctx?.waitUntil)ctx.waitUntil(catalogue(env,url.origin).then(cat=>notifyTools(env,cat,url.origin)).catch(()=>{}));
|
|
641
646
|
// A route can commit a write and then fail. Its outbox still needs delivery.
|
|
642
647
|
if(env.DB) {
|
|
643
648
|
const delivery=dispatch();
|
|
@@ -649,6 +654,11 @@ export default {
|
|
|
649
654
|
},
|
|
650
655
|
};
|
|
651
656
|
|
|
657
|
+
async function notifyTools(env,cat,origin) {
|
|
658
|
+
if(!env.DB||!env.LOBBY)return;
|
|
659
|
+
const [{pushToolList},{builtins}]=await Promise.all([import('./event-resources.mjs'),import('./mcp-tools.mjs')]);
|
|
660
|
+
await pushToolList(env,cat,[...builtins(env,cat,origin),...await studioTools()],origin);
|
|
661
|
+
}
|
|
652
662
|
let studioTools = async () => [];
|
|
653
663
|
/** Studio-authored Worker code, loaded only on the MCP surface. */
|
|
654
664
|
export function useTools(load) { studioTools = load; }
|
|
@@ -920,6 +930,7 @@ async function route(request, env, ctx) {
|
|
|
920
930
|
if (a.status === 401) return Response.redirect(`${url.origin}/account/?next=${encodeURIComponent(url.pathname + url.search)}`, 302);
|
|
921
931
|
return json({ ok: false, error: a.error }, a.status);
|
|
922
932
|
}
|
|
933
|
+
if(!meta.room)return recordsPage(meta,url,cat);
|
|
923
934
|
}
|
|
924
935
|
}
|
|
925
936
|
const lobby = () => env.LOBBY.get(env.LOBBY.idFromName(game));
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
/** Only dev supplies this secret. Neither a loopback URL nor a role parameter grants authority. */
|
|
2
|
+
const local = (request, env) => env.HOMIE_LOCAL_TEST_SECRET && ['localhost','127.0.0.1','[::1]'].includes(new URL(request.url).hostname);
|
|
3
|
+
export async function localCaller(request, env) {
|
|
4
|
+
if (!local(request, env) || request.headers.get('authorization') !== `Bearer ${env.HOMIE_LOCAL_TEST_SECRET}`) return null;
|
|
5
|
+
return {id:'local-test-owner',owner:true,client:null};
|
|
6
|
+
}
|
|
7
|
+
export async function localSession(request, env) {
|
|
8
|
+
if (!local(request, env)) return null;
|
|
9
|
+
const token = /(?:^|;\s*)homie_test=([^;]+)/.exec(request.headers.get('cookie') ?? '')?.[1];
|
|
10
|
+
if (!token) return null;
|
|
11
|
+
try {
|
|
12
|
+
const [data, signature] = token.split('.');
|
|
13
|
+
if (!/^[a-f0-9]{64}$/.test(signature)) return null;
|
|
14
|
+
const key = await crypto.subtle.importKey('raw',new TextEncoder().encode(env.HOMIE_LOCAL_TEST_SECRET),{name:'HMAC',hash:'SHA-256'},false,['verify']);
|
|
15
|
+
if (!await crypto.subtle.verify('HMAC',key,Uint8Array.from(signature.match(/../g),h=>parseInt(h,16)),new TextEncoder().encode(data))) return null;
|
|
16
|
+
const session = JSON.parse(atob(data.replaceAll('-','+').replaceAll('_','/')));
|
|
17
|
+
return /^[a-z][a-z0-9-]*$/.test(session.app) && /^[a-z][a-z0-9-]*$/.test(session.role) && (!session.scope || /^[a-zA-Z0-9-]+$/.test(session.scope)) ? session : null;
|
|
18
|
+
} catch { return null; }
|
|
19
|
+
}
|