@homie-rocks/studio 0.52.0 → 0.54.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 +57 -0
- package/apps/apps.ts +17 -0
- package/bin/homie-studio.mjs +9 -3
- package/docs/shop-migration.md +9 -0
- package/docs/toolkit-batch.md +49 -0
- package/functions/FUNCTIONS.md +15 -1
- package/guides/app/PLACES.md +23 -0
- package/guides/app/SKILL.md +2 -2
- package/guides/shop/SKILL.md +6 -0
- package/lib/app-needs.mjs +27 -11
- package/lib/app-ready.mjs +1 -1
- package/lib/build.mjs +4 -3
- package/lib/check.mjs +47 -15
- package/lib/dev.mjs +9 -1
- package/lib/fork.mjs +47 -0
- package/lib/jobs.mjs +1 -0
- package/lib/mcp-tools.mjs +1 -1
- package/lib/needs-headless.mjs +4 -3
- package/lib/scaffold.mjs +27 -1
- package/lib/shop-links.mjs +39 -21
- package/lib/shop-mcp.mjs +13 -1
- package/lib/stripe-connect.mjs +37 -2
- package/lib/template-history.json +84 -0
- package/lib/tools-cli.mjs +25 -3
- package/lib/worker-config.mjs +1 -0
- package/package.json +1 -1
- package/rules/rules.ts +1 -1
- package/rules/view.ts +1 -1
- package/shop/SHOP.md +13 -9
- package/shop/shop.ts +3 -3
- package/tools/tools.ts +3 -2
- package/worker/app-format.mjs +6 -3
- package/worker/app-records.mjs +20 -6
- package/worker/app-shell.mjs +10 -5
- package/worker/app-streams.mjs +26 -9
- package/worker/app-tools.mjs +2 -1
- package/worker/app-web.mjs +23 -0
- package/worker/booking-calendar.mjs +26 -0
- package/worker/booking-holds.mjs +18 -5
- package/worker/booking-options.mjs +23 -0
- package/worker/booking-resources.mjs +55 -0
- package/worker/build-id.mjs +1 -1
- package/worker/chat-store.mjs +5 -4
- package/worker/customer-mcp.mjs +1 -1
- package/worker/event-resources.mjs +6 -1
- package/worker/index.mjs +74 -27
- package/worker/local-test.mjs +10 -0
- package/worker/mcp-events.mjs +2 -2
- package/worker/mcp-tools.mjs +20 -2
- package/worker/office.mjs +108 -6
- package/worker/operational-schema.mjs +30 -0
- package/worker/pages.mjs +7 -1
- package/worker/records-page.mjs +2 -2
- package/worker/request-db.mjs +53 -0
- package/worker/servers.mjs +5 -4
- package/worker/shop-links.mjs +1 -1
- package/worker/shop-page.mjs +1 -0
- package/worker/shop-rules.mjs +3 -2
- package/worker/shop-store.mjs +4 -3
- package/worker/shop.mjs +68 -8
- package/worker/version.mjs +1 -1
package/shop/SHOP.md
CHANGED
|
@@ -305,7 +305,7 @@ await shop.checkout({ reference: 'table-7' });
|
|
|
305
305
|
The default opens the synced Stripe menu containing those items. The guest chooses
|
|
306
306
|
final quantities **on Stripe**, starting at one for each menu line; the local cart's
|
|
307
307
|
quantities are not prefilled. Adjustable quantity minimum is zero (Stripe prevents
|
|
308
|
-
removing the last item). Payment Links support at most 20 lines. Keyless sync splits catalogues into menus of ten to fit signed invoice snapshots without authenticated pagination. Split larger menus;
|
|
308
|
+
removing the last item); maximum is the item’s `maxQuantity` from shop.json, default 99 (a positive integer up to 999999). Payment Links support at most 20 lines. Keyless sync splits catalogues into menus of ten to fit signed invoice snapshots without authenticated pagination. Split larger menus;
|
|
309
309
|
if selected items span menus, the app receives `menu` and should offer separate
|
|
310
310
|
checkouts. Custom amounts cannot be mixed into this menu; offer a separate tip link.
|
|
311
311
|
Stripe invoice creation is enabled for menus (Stripe's invoice pricing applies).
|
|
@@ -329,8 +329,7 @@ the owner's AI write the complete manifest to `.studio/booking-slots.json` and r
|
|
|
329
329
|
|
|
330
330
|
Sync after publishing or changing slots. Removed or inactive slots deactivate their
|
|
331
331
|
links. Each link has `restrictions.completed_sessions.limit = capacity`, with one
|
|
332
|
-
fixed service and
|
|
333
|
-
count. Free slots stay local and use separate slot records from paid links. Never increment capacity just to hide a full-link error.
|
|
332
|
+
fixed service and an adjustable group quantity up to the published remaining seats. Stripe counts completed sessions, not seats: supply the reviewed `confirmed` seat total when resyncing. The database confirms the paid quantity atomically; a capacity race becomes a visible review order rather than an overbooking. Re-sync preserves Stripe's lifetime completed count. Free slots stay local and use separate slot records from paid links. Never increment capacity just to hide a full-link error.
|
|
334
333
|
Refunds do not decrement Stripe's completed-session counter; the owner's AI must
|
|
335
334
|
review availability and resync explicitly. A changed price should use a new slot;
|
|
336
335
|
do not replace a sold link and accidentally reset its capacity counter.
|
|
@@ -441,15 +440,20 @@ stored preset or an empty amount; keep the expected total visible on the phone.
|
|
|
441
440
|
|
|
442
441
|
After a signed paid session matches the frozen link, revision, mode, currency and
|
|
443
442
|
order reference, `order.paid` contains the **saved basket lines**, `amount` (actual
|
|
444
|
-
payment), `basketTotal` (expected total), and `settlement` (`paid`
|
|
443
|
+
payment), `basketTotal` (expected total), and `settlement` (`paid`).
|
|
445
444
|
Line `amount` is the saved extended amount (unit price × quantity); line `total`
|
|
446
445
|
is its proportionally allocated share of the actual aggregate payment for refunds.
|
|
447
|
-
A
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
repeated delivery of the same session never creates another round.
|
|
446
|
+
A payment that differs from the saved basket total emits `payment.review`, retains
|
|
447
|
+
the actual payment, and shows the customer a staff-review notice. It never emits
|
|
448
|
+
`order.paid` or grants entitlements. Staff reconcile or refund the difference.
|
|
449
|
+
Exact payments emit `order.paid`; deduplicate fulfillment by order ID.
|
|
452
450
|
|
|
453
451
|
The Stripe heading is the connected Stripe account's business name. A sandbox
|
|
454
452
|
account named “Homie sandbox” shows that name; a real pub's account shows the pub's
|
|
455
453
|
name. The basket's product name and description use the pub's own wording.
|
|
454
|
+
|
|
455
|
+
### Payments requiring review
|
|
456
|
+
|
|
457
|
+
A signed paid deposit or basket that cannot match its saved order is retained as a `review` order, with the payment ID and actual total, a customer notice and a durable `payment.review` event. Staff reconcile fulfillment or refund in Stripe. Group payments that exceed remaining capacity follow the same path without granting seats. Repeated events do not duplicate the review order.
|
|
458
|
+
|
|
459
|
+
Full connect saves `.studio/stripe-mcp.json` and public purchase configuration for the build’s JSON asset, just like plugin verification. Rebuild/deploy before retiring old links. `STRIPE_CHECKOUT_MODE` stays in public Wrangler vars; only the webhook signing secret and private purchase signing keys use secrets.
|
package/shop/shop.ts
CHANGED
|
@@ -71,7 +71,7 @@ export interface Shop {
|
|
|
71
71
|
cart(): CartLine[];
|
|
72
72
|
clear(): void;
|
|
73
73
|
/** Opens Checkout: keyless bookings are pending until paid; explicit session checkout holds capacity. The returned ID tracks the public booking record. */
|
|
74
|
-
book(item: string, booking: { collection: string; slot: string; hold?: string; token?: string }, quantity?: number): Promise<{ ok: boolean; booking?: string; error?: string }>;
|
|
74
|
+
book(item: string, booking: { collection: string; slot: string; hold?: string; token?: string }, quantity?: number, options?: { reference?: string }): Promise<{ ok: boolean; booking?: string; error?: string }>;
|
|
75
75
|
checkout(options?: { reference?: string }): Promise<boolean>;
|
|
76
76
|
buy(item: string, quantity?: number, amount?: number, options?: { reference?: string }): Promise<boolean>;
|
|
77
77
|
/** Mark an item used; the studio's refundUsedItems policy decides whether self-service refunds still apply. */
|
|
@@ -154,8 +154,8 @@ export function createShop(opts: ShopOptions = {}): Shop {
|
|
|
154
154
|
add: (item, quantity = 1, amount) => { cart.push({ item, quantity, ...(amount === undefined ? {} : { amount }) }); },
|
|
155
155
|
cart: () => cart.map((line) => ({ ...line })),
|
|
156
156
|
clear: () => { cart = []; },
|
|
157
|
-
book: async (item, booking, quantity = 1) => {
|
|
158
|
-
const r = await send({ op: 'checkout', booking, lines: [{ item, quantity }] });
|
|
157
|
+
book: async (item, booking, quantity = 1, options = {}) => {
|
|
158
|
+
const r = await send({ op: 'checkout', booking, reference: options.reference, lines: [{ item, quantity }] });
|
|
159
159
|
return { ok: Boolean(r.ok), ...(typeof r.booking === 'string' ? { booking: r.booking } : {}), ...(typeof r.message === 'string' ? { error: r.message } : {}) };
|
|
160
160
|
},
|
|
161
161
|
checkout: async (options = {}) => { const ok = Boolean((await send({ op: 'checkout', lines: cart, reference: options.reference })).ok); if (ok) cart = []; return ok; },
|
package/tools/tools.ts
CHANGED
|
@@ -7,7 +7,8 @@ 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
|
bookings: {
|
|
10
|
-
|
|
10
|
+
calendar(args: {app:string;startDate:string;endDate:string;timeZone?:string;time?:string;days?:number;durationMinutes?:number;capacity?:number;weekdays?:number[];resources?:string[];options?:unknown[];prefix?:string}): Array<{id:string;start:string;end:string;capacity:number;date:string;timeZone:string}>;
|
|
11
|
+
hold(args: {app: string; collection: string; slot?: string; time?: string; option?: string; resource?: string; preferredResource?: string; addons?: string[] | Record<string,number>; addOns?: string[] | Record<string,number>; quantity?: number; id: string; token?: string; expiresAt: number}): Promise<BookingHold>;
|
|
11
12
|
get(args: {id: string; token?: string}): Promise<BookingHold>;
|
|
12
13
|
confirm(args: {id: string; token?: string; version: number}): Promise<BookingHold>;
|
|
13
14
|
release(args: {id: string; token?: string; version: number}): Promise<BookingHold>;
|
|
@@ -47,4 +48,4 @@ export interface StudioTool<Input = Record<string, unknown>> {
|
|
|
47
48
|
}
|
|
48
49
|
export function defineTool<Input = Record<string, unknown>>(tool: StudioTool<Input>): StudioTool<Input> { return tool; }
|
|
49
50
|
|
|
50
|
-
export interface BookingHold { id: string; slot: string; quantity: number; state: string; version: number; expiresAt: number | null; paid: boolean }
|
|
51
|
+
export interface BookingHold { allocations?: Array<{resource:string;collection:string;start:number;end:number;units:number}>; choices?: {option:string|null;resource:string|null;addons:Record<string,number>}; id: string; slot: string; quantity: number; state: string; version: number; expiresAt: number | null; paid: boolean }
|
package/worker/app-format.mjs
CHANGED
|
@@ -6,6 +6,7 @@ const object = (x) => x && typeof x === 'object' && !Array.isArray(x);
|
|
|
6
6
|
export function appProblems(meta) {
|
|
7
7
|
const bad = [];
|
|
8
8
|
if (!object(meta)) return ['app.json must be an object'];
|
|
9
|
+
if(meta.web!==undefined && (!object(meta.web)||!object(meta.web.manifest)||meta.web.serviceWorker!==undefined&&(typeof meta.web.serviceWorker!=='string'||meta.web.serviceWorker.includes('..')||!/^[-\w/]+\.js$/.test(meta.web.serviceWorker))))bad.push('web needs a manifest object and optional relative serviceWorker .js path');
|
|
9
10
|
const roles = object(meta.roles) ? meta.roles : {};
|
|
10
11
|
const collections = object(meta.records?.collections) ? meta.records.collections : {};
|
|
11
12
|
if (!object(meta.roles) || !Object.keys(meta.roles).length) bad.push('roles must declare at least one role');
|
|
@@ -18,13 +19,15 @@ export function appProblems(meta) {
|
|
|
18
19
|
if (!object(meta.records) || typeof meta.records.persist !== 'boolean' || !object(meta.records.collections)) bad.push('records declares persist and collections');
|
|
19
20
|
for (const [id, c] of Object.entries(collections)) {
|
|
20
21
|
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 (!/^[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
|
|
22
|
+
for (const [field, spec] of Object.entries(c.fields)) if (!/^[a-z][a-z0-9_]*$/.test(field) || !object(spec) || !['string', 'number', 'boolean', 'array', 'object'].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, boolean, array or object type`);
|
|
22
23
|
}
|
|
23
24
|
for (const [id, c] of Object.entries(collections)) if (c.booking) {
|
|
24
25
|
const spec = c.booking;
|
|
25
26
|
if (!object(spec) || !key.test(spec.slots ?? '') || !collections[spec.slots] || typeof spec.item !== 'string' || !spec.item.trim()) bad.push(`collection ${id}: booking needs slots collection and shop item`);
|
|
27
|
+
if(spec.resources!==undefined&&(!key.test(spec.resources)||!collections[spec.resources]))bad.push(`collection ${id}: booking needs a declared resources collection`);
|
|
28
|
+
if(spec.holdMinutes!==undefined&&(!Number.isSafeInteger(spec.holdMinutes)||spec.holdMinutes<1||spec.holdMinutes>1440))bad.push(`collection ${id}: holdMinutes is 1–1440`);
|
|
26
29
|
if (!meta.records.persist) bad.push(`collection ${id}: bookings need persistent records`);
|
|
27
|
-
for (const role of Object.values(roles)) if (!role.signIn && (role.can ?? []).some(cap => ['create','update','delete'].some(op => cap === `${op}:${id}` || cap === `${op}:${spec.slots}`))) bad.push(`collection ${id}: booking and
|
|
30
|
+
for (const role of Object.values(roles)) if (!role.signIn && (role.can ?? []).some(cap => ['create','update','delete'].some(op => cap === `${op}:${id}` || cap === `${op}:${spec.slots}` || spec.resources&&cap === `${op}:${spec.resources}`))) bad.push(`collection ${id}: booking, slot and resource writes require a signed-in staff role`);
|
|
28
31
|
}
|
|
29
32
|
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}`);
|
|
30
33
|
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`);
|
|
@@ -43,7 +46,7 @@ export function recordProblem(collection, data, { create = false } = {}) {
|
|
|
43
46
|
for (const [k, s] of Object.entries(collection.fields)) {
|
|
44
47
|
const v = data[k];
|
|
45
48
|
if (v === undefined) { if (s.required) return `missing ${k}`; continue; }
|
|
46
|
-
if (typeof v !== s.type || (s.type === 'number' && !Number.isFinite(v)) || (typeof v === 'string' && v.length > (s.maxLength ?? 256)) || (s.enum && !s.enum.includes(v)) || (create && s.initial !== undefined && v !== s.initial)) return `invalid ${k}`;
|
|
49
|
+
if ((s.type==='array'?!Array.isArray(v):s.type==='object'?!object(v):typeof v !== s.type) || (s.type === 'number' && !Number.isFinite(v)) || (typeof v === 'string' && v.length > (s.maxLength ?? 256)) || (s.enum && !s.enum.includes(v)) || (create && s.initial !== undefined && v !== s.initial)) return `invalid ${k}`;
|
|
47
50
|
}
|
|
48
51
|
return null;
|
|
49
52
|
}
|
package/worker/app-records.mjs
CHANGED
|
@@ -4,9 +4,9 @@ import { moveBooking } from './booking-store.mjs';
|
|
|
4
4
|
import { toolIdentity } from './tool-identity.mjs';
|
|
5
5
|
/** Lasting app records in the studio's own D1. Room hosts never authorize writes. */
|
|
6
6
|
import { appRole, recordProblem } from './app-format.mjs';
|
|
7
|
-
import { isOwner, sameOrigin } from './office.mjs';
|
|
7
|
+
import { appAssetTicket, isOwner, sameOrigin, launchOf, settingsOf } from './office.mjs';
|
|
8
8
|
import { players } from './players.mjs';
|
|
9
|
-
import { ownerAllowed } from './stats.mjs';
|
|
9
|
+
import { cookieValues, ownerAllowed } from './stats.mjs';
|
|
10
10
|
import { appOriginOf } from './standalone.mjs';
|
|
11
11
|
export const APPS_MIGRATION_FILE = '0014_studio_apps.sql';
|
|
12
12
|
export const APPS_MIGRATION = `CREATE TABLE IF NOT EXISTS app_records (
|
|
@@ -47,11 +47,13 @@ export async function appAccess(request, env, meta, url) {
|
|
|
47
47
|
const caller = toolIdentity(request);
|
|
48
48
|
if (caller) {
|
|
49
49
|
if (caller.owner) return { ok: true, role, can: declared.can };
|
|
50
|
-
if(env.HOMIE_LOCAL_TEST_SECRET && caller.localTest && caller.roles?.[meta.id]?.includes(role))return {ok:true,role,can:declared.can};
|
|
50
|
+
if((env.HOMIE_LOCAL_TEST_SECRET || caller.remoteTest) && caller.localTest && caller.roles?.[meta.id]?.includes(role))return {ok:true,role,can:declared.can};
|
|
51
51
|
if (!caller.id) return { ok: false, status: 401, error: 'sign in to this studio' };
|
|
52
52
|
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();
|
|
53
53
|
return grant ? { ok: true, role, can: declared.can } : { ok: false, status: 403, error: 'this account has no role grant' };
|
|
54
54
|
}
|
|
55
|
+
const handoff=cookieValues(request,`studio_app_${meta.id}_${role}`)[0];
|
|
56
|
+
if(handoff&&await appAssetTicket(env,meta.id,handoff,{meta,purpose:'handoff',expectedRole:role,launch:launchOf(meta,await settingsOf(env),env)}))return {ok:true,role,can:declared.can};
|
|
55
57
|
const link = await env.DB.prepare('SELECT token FROM app_role_links WHERE app = ?1 AND role = ?2').bind(meta.id, role).first();
|
|
56
58
|
if (!link || link.token !== url.searchParams.get('access')) return { ok: false, status: 404, error: 'private role link required' };
|
|
57
59
|
if (await isOwner(request, env)) return { ok: true, role, can: declared.can };
|
|
@@ -91,7 +93,7 @@ export async function appRecordsRoute(request, env, meta, url, sub) {
|
|
|
91
93
|
if (!access.ok) return fail(access.status, access.error);
|
|
92
94
|
const [, collection, id] = match;
|
|
93
95
|
const session = await localSession(request, env);
|
|
94
|
-
const testScope = (session?.app === meta.id ? session.scope : null) ?? (env.HOMIE_LOCAL_TEST_SECRET ? toolIdentity(request)?.testScope : null);
|
|
96
|
+
const testScope = (session?.app === meta.id ? session.scope : null) ?? (env.HOMIE_LOCAL_TEST_SECRET || toolIdentity(request)?.remoteTest ? toolIdentity(request)?.testScope : null);
|
|
95
97
|
const recordApp = testScope ? `${meta.id}:check:${testScope}` : meta.id;
|
|
96
98
|
const op = ({ GET: 'read', POST: 'create', PUT: 'update', DELETE: 'delete' })[request.method];
|
|
97
99
|
if (!op || !access.can.includes(`${op}:${collection}`)) return fail(403, 'role cannot perform this operation');
|
|
@@ -118,7 +120,7 @@ export async function appRecordsRoute(request, env, meta, url, sub) {
|
|
|
118
120
|
}
|
|
119
121
|
return json({ok:true,records});
|
|
120
122
|
}
|
|
121
|
-
if (!id) return fail(400,
|
|
123
|
+
if (!id) return fail(400, `Put the record id in the URL: /${meta.id}/api/app/records/${collection}/<id> (1–64 letters, digits, underscore or hyphen).`);
|
|
122
124
|
const body = await bodyOf(request);
|
|
123
125
|
if (meta.records.collections[collection].booking) {
|
|
124
126
|
if (op !== 'update' || !meta.roles[access.role].signIn) return fail(403, 'bookings are created and released through checkout');
|
|
@@ -132,7 +134,19 @@ export async function appRecordsRoute(request, env, meta, url, sub) {
|
|
|
132
134
|
? env.DB.prepare('INSERT OR IGNORE INTO app_records (app, collection, id, value, version, updated_at) VALUES (?1, ?2, ?3, ?4, 1, ?5)').bind(recordApp, collection, id, JSON.stringify(body.data), Date.now())
|
|
133
135
|
: 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)
|
|
134
136
|
: 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);
|
|
135
|
-
const
|
|
137
|
+
const bookingCollections=Object.entries(meta.records.collections).filter(([,c])=>c.booking?.slots===collection);
|
|
138
|
+
const writes=[stmt];
|
|
139
|
+
if(!testScope&&op!=='delete')for(const [bookings,c] of bookingCollections) {
|
|
140
|
+
const link=linkConfig(env)?.slots?.find(l=>l.app===meta.id&&l.slots===collection&&l.slot===id);
|
|
141
|
+
if(link&&link.capacity===body.data?.capacity)continue;
|
|
142
|
+
const expected=op==='create'?1:body.version+1;
|
|
143
|
+
writes.push(env.DB.prepare(`INSERT OR IGNORE INTO studio_events(id,type,data,at)
|
|
144
|
+
SELECT ?1,'booking.slots_need_payment_links',json_object('app',?2,'slots',json_array(json_object('app',?2,'collection',?3,'slots',?4,'slot',?5,'item',?6,'capacity',json_extract(value,'$.capacity')))),?7
|
|
145
|
+
FROM app_records WHERE app=?8 AND collection=?4 AND id=?5 AND version=?9
|
|
146
|
+
AND EXISTS(SELECT 1 FROM function_subscriptions WHERE type='booking.slots_need_payment_links')`)
|
|
147
|
+
.bind(`slot-links:${recordApp}:${collection}:${id}:${expected}`,meta.id,bookings,collection,id,c.booking.item,Date.now(),recordApp,expected));
|
|
148
|
+
}
|
|
149
|
+
const result=writes.length>1?(await env.DB.batch(writes))[0]:await stmt.run();
|
|
136
150
|
return result.meta.changes ? json({ ok: true, id, version: op === 'create' ? 1 : body.version + 1 }) : fail(409, 'record changed or already exists; reload');
|
|
137
151
|
} catch (error) {
|
|
138
152
|
if (/booking capacity|capacity below|slot has active/.test(error.message)) return fail(409, error.message);
|
package/worker/app-shell.mjs
CHANGED
|
@@ -2,9 +2,14 @@
|
|
|
2
2
|
export const APP_SHELL_JS = String.raw`(function () {
|
|
3
3
|
var boot = window.__HOMIE_PLAY || window.__HOMIE_APP || window.__HOMIE_WATCH;
|
|
4
4
|
if (!boot || boot.kind !== 'app') return;
|
|
5
|
-
var frame = document.querySelector('iframe.game') || document.querySelector('iframe');
|
|
5
|
+
var frame = boot.direct ? {contentWindow:window} : document.querySelector('iframe.game') || document.querySelector('iframe');
|
|
6
6
|
var params = new URLSearchParams(location.search);
|
|
7
|
-
//
|
|
7
|
+
// Authorized shell responses have already set the HttpOnly role handoff.
|
|
8
|
+
if(params.has('access')) {
|
|
9
|
+
params.delete('access');
|
|
10
|
+
history.replaceState(null,'',location.pathname+(params.size?'?'+params:'')+(location.hash||''));
|
|
11
|
+
}
|
|
12
|
+
// Share short-lived reads across same-origin screens with the same role and browser cookies.
|
|
8
13
|
// No records are persisted. Writes always reach the Worker and invalidate all views.
|
|
9
14
|
var recent = new Map(), failures = new Map(), pending = new Map();
|
|
10
15
|
var channel; try { channel = new BroadcastChannel('homie-app-records-v1'); } catch (_) {}
|
|
@@ -38,11 +43,11 @@ export const APP_SHELL_JS = String.raw`(function () {
|
|
|
38
43
|
var live, collections=new Set(), presence=0, clock=null;
|
|
39
44
|
function query() {
|
|
40
45
|
var q=new URLSearchParams();
|
|
41
|
-
['role','surface','
|
|
46
|
+
['role','surface','room'].forEach(function(k){var v=params.get(k);if(v)q.set(k,v);});
|
|
42
47
|
if(boot.screen&&!q.has('role'))q.set('surface','wall');
|
|
43
48
|
return q;
|
|
44
49
|
}
|
|
45
|
-
function context() {return {app:boot.game,origin:location.origin,url:location.origin+'/'+boot.game+'/open',presence:presence,now:clock};}
|
|
50
|
+
function context() {var room=new URLSearchParams(location.search).get('room');return {app:boot.game,origin:location.origin,url:location.origin+'/'+boot.game+'/open'+(room&&/^[A-Za-z0-9_-]{1,32}$/.test(room)?'?room='+encodeURIComponent(room):''),presence:presence,now:clock};}
|
|
46
51
|
function sendContext(){frame?.contentWindow?.postMessage({type:'homie-app-context',context:context()},'*');}
|
|
47
52
|
function connect(){
|
|
48
53
|
live?.close();
|
|
@@ -63,7 +68,7 @@ export const APP_SHELL_JS = String.raw`(function () {
|
|
|
63
68
|
if (!frame || e.source !== frame.contentWindow || !m || !['homie-app','homie-app-tool'].includes(m.type) || !/^[a-f0-9]{32}$/.test(m.id || '')) return;
|
|
64
69
|
if (m.type==='homie-app' && (!/^[a-z][a-z0-9-]{0,39}$/.test(m.collection || '') || !['GET','POST','PUT','DELETE'].includes(m.method) || (m.record !== undefined && !/^[A-Za-z0-9_-]{1,64}$/.test(m.record)))) return;
|
|
65
70
|
var q = new URLSearchParams();
|
|
66
|
-
['role','surface'
|
|
71
|
+
['role','surface'].forEach(function (k) { var v = params.get(k); if (v) q.set(k, v); });
|
|
67
72
|
if (boot.screen && !q.has('role')) q.set('surface', 'wall');
|
|
68
73
|
var path = (boot.site || '') + '/' + boot.game + '/api/app/records/' + m.collection + (m.record ? '/' + m.record : '') + '?' + q;
|
|
69
74
|
if(m.type==='homie-app-tool')path=(boot.site||'')+'/'+boot.game+'/api/app/tools?'+q;
|
package/worker/app-streams.mjs
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import {databaseBinding} from './request-db.mjs';
|
|
1
2
|
import {appAccess} from './app-records.mjs';
|
|
2
3
|
import {localSession} from './local-test.mjs';
|
|
3
4
|
import {settingsOf,launchOf,accessOf} from './office.mjs';
|
|
@@ -10,15 +11,28 @@ END;`;}).join('\n')}
|
|
|
10
11
|
`;
|
|
11
12
|
const frame=value=>new TextEncoder().encode('data: '+JSON.stringify(value)+'\n\n');
|
|
12
13
|
export const appHub=(env,app)=>env.LOBBY.get(env.LOBBY.idFromName('@app-records/'+app));
|
|
14
|
+
const pushedRevisions=new WeakMap();
|
|
13
15
|
export async function pushAppRecords(env,cat){
|
|
14
|
-
|
|
15
|
-
|
|
16
|
+
const apps=(cat.games??[]).filter(g=>g.kind==='app');
|
|
17
|
+
if(!env.LOBBY||!env.DB||!apps.length)return;
|
|
18
|
+
const rows=(await env.DB.prepare(`SELECT app,collection,revision FROM app_record_revisions
|
|
19
|
+
UNION ALL SELECT a.app,'*',SUM(COALESCE(r.revision,0)) FROM app_tool_namespaces a LEFT JOIN tool_data_revisions r ON r.namespace=a.namespace GROUP BY a.app`).all()).results;
|
|
20
|
+
const signatures=new Map();for(const row of rows){const app=row.app.split(':check:')[0];signatures.set(app,(signatures.get(app)??'')+JSON.stringify(row));}
|
|
21
|
+
const binding=databaseBinding(env.DB),previous=pushedRevisions.get(binding)??new Map();
|
|
22
|
+
for(const meta of apps){
|
|
23
|
+
const signature=signatures.get(meta.id)??'';
|
|
24
|
+
if((previous.get(meta.id)??'')===signature)continue;
|
|
25
|
+
const response=await appHub(env,meta.id).fetch('https://app/__app_records',{method:'POST',body:JSON.stringify({op:'wake',meta})});
|
|
26
|
+
if(!response.ok)throw new Error('App notification wake failed');
|
|
27
|
+
previous.set(meta.id,signature);
|
|
28
|
+
}
|
|
29
|
+
pushedRevisions.set(binding,previous);
|
|
16
30
|
}
|
|
17
31
|
/** Collection invalidations, never record bodies or identifiers. Reads recheck current authorization. */
|
|
18
32
|
export class AppRecordStreams {
|
|
19
33
|
constructor(env,ctx){this.env=env;this.ctx=ctx;this.streams=new Set();}
|
|
20
34
|
dispose(){for(const s of [...this.streams])this.close(s);}
|
|
21
|
-
close(s){if(!this.streams.delete(s))return;clearInterval(s.timer);try{s.controller.close();}catch{}this.presence();}
|
|
35
|
+
close(s){if(!this.streams.delete(s))return;clearInterval(s.timer);clearTimeout(s.reconnect);try{s.controller.close();}catch{}this.presence();}
|
|
22
36
|
count(s){return [...this.streams].filter(x=>x.app===s.app&&x.room===s.room).length;}
|
|
23
37
|
send(s,data){if(s.controller.desiredSize<=0){this.close(s);return;}try{s.controller.enqueue(frame(data));}catch{this.close(s);}}
|
|
24
38
|
presence(){for(const s of this.streams)this.send(s,{presence:this.count(s)});}
|
|
@@ -28,7 +42,7 @@ export class AppRecordStreams {
|
|
|
28
42
|
const launch=launchOf(s.meta,await settingsOf(this.env,{fresh:true}),this.env);
|
|
29
43
|
return launch==='public'||(await accessOf(s.request,this.env,s.meta.id,launch)).ok;
|
|
30
44
|
}
|
|
31
|
-
async revisions(s){return (await this.env.DB.prepare(
|
|
45
|
+
async revisions(s){return (await this.env.DB.prepare("SELECT collection,revision FROM app_record_revisions WHERE app=?1 UNION ALL SELECT '*',COALESCE(SUM(r.revision),0) FROM app_tool_namespaces a JOIN tool_data_revisions r ON r.namespace=a.namespace WHERE a.app=?1").bind(s.app).all()).results;}
|
|
32
46
|
async open({meta,url,headers,collections=[]}){
|
|
33
47
|
if(!Array.isArray(collections)||collections.some(c=>typeof c!=='string'||!Object.hasOwn(meta.records?.collections??{},c)))return new Response('unknown collection',{status:400});
|
|
34
48
|
const s={meta,url:new URL(url),request:new Request(url,{headers}),collections,seen:new Map()};
|
|
@@ -40,17 +54,20 @@ export class AppRecordStreams {
|
|
|
40
54
|
const body=new ReadableStream({start:controller=>{s.controller=controller;},cancel:()=>this.close(s)});
|
|
41
55
|
this.streams.add(s);this.send(s,{collections:s.collections,presence:this.count(s),reset:true});
|
|
42
56
|
for(const other of this.streams)if(other!==s)this.send(other,{presence:this.count(other)});
|
|
57
|
+
// A fresh HTTP response renews the role cookie; SSE frames cannot set cookies.
|
|
58
|
+
s.reconnect=setTimeout(()=>this.close(s),240000);
|
|
43
59
|
// Recovery after a missed wake; this is not the screen's refresh clock.
|
|
44
|
-
s.timer=setInterval(()=>this.ctx.waitUntil(this.pump(s)),15000);
|
|
60
|
+
s.timer=setInterval(()=>this.ctx.waitUntil(this.pump(s,true)),15000);
|
|
45
61
|
return new Response(body,{headers:{'content-type':'text/event-stream','cache-control':'no-store'}});
|
|
46
62
|
}
|
|
47
|
-
async pump(s){
|
|
63
|
+
async pump(s,recheck=false){
|
|
48
64
|
if(!this.streams.has(s))return;if(s.busy){s.dirty=true;return;}s.busy=true;
|
|
49
65
|
try{
|
|
66
|
+
const changed=new Set();
|
|
67
|
+
for(const row of await this.revisions(s)){if(s.seen.get(row.collection)!==row.revision){if(row.collection==='*')for(const c of s.collections)changed.add(c);else if(s.collections.includes(row.collection))changed.add(row.collection);}s.seen.set(row.collection,row.revision);}
|
|
68
|
+
if(!changed.size&&!recheck)return;
|
|
50
69
|
if(!await this.authorized(s)){this.close(s);return;}
|
|
51
|
-
|
|
52
|
-
for(const row of await this.revisions(s)){if(s.collections.includes(row.collection)&&s.seen.get(row.collection)!==row.revision)changed.push(row.collection);s.seen.set(row.collection,row.revision);}
|
|
53
|
-
this.send(s,{collections:changed,presence:this.count(s)});
|
|
70
|
+
this.send(s,{collections:[...changed],presence:this.count(s)});
|
|
54
71
|
}catch{this.close(s);}finally{s.busy=false;if(s.dirty){s.dirty=false;await this.pump(s);}}
|
|
55
72
|
}
|
|
56
73
|
async wake(meta){for(const s of this.streams)if(meta)s.meta=meta;await Promise.all([...this.streams].map(s=>this.pump(s)));return new Response('ok');}
|
package/worker/app-tools.mjs
CHANGED
|
@@ -14,7 +14,7 @@ export async function appCaller(request,env,meta,url){
|
|
|
14
14
|
const access=await appAccess(request,env,meta,url);
|
|
15
15
|
if(!access.ok)throw Object.assign(new Error(access.error),{status:access.status});
|
|
16
16
|
const session=await localSession(request,env);
|
|
17
|
-
if(session?.app===meta.id)return {id:`local-test-${session.app}-${session.role}`,owner
|
|
17
|
+
if(session?.app===meta.id)return {id:`local-test-${session.app}-${session.role}`,owner:!session.remoteTest&&session.role==='owner',client:null,localTest:true,...(session.remoteTest?{remoteTest:true}:{}),roles:{[meta.id]:[session.role]},testApp:meta.id,testScope:session.scope};
|
|
18
18
|
const person=await players.of(request,env);
|
|
19
19
|
const owner=await isOwner(request,env);
|
|
20
20
|
const roles={};
|
|
@@ -37,6 +37,7 @@ export async function appToolsRoute(request,env,meta,url,cat,definitions){
|
|
|
37
37
|
if(tool.price)return fail(402,'Use MCP to approve payment for this tool');
|
|
38
38
|
if(!await rateLimit(env,caller.id??`public:${request.headers.get('cf-connecting-ip')??'local'}`,callsPerMinute(cat)))return fail(429,'Rate limit reached');
|
|
39
39
|
const namespace=(tool.namespace??tool.name)+(caller.testScope?':check:'+caller.testScope:'');
|
|
40
|
+
await env.DB.prepare('INSERT OR IGNORE INTO app_tool_namespaces(app,namespace) VALUES(?1,?2)').bind(meta.id+(caller.testScope?':check:'+caller.testScope:''),namespace).run();
|
|
40
41
|
const data=await invokeAppTool(tool,body.input??{},services(env,cat,url.origin,caller,namespace,tool));
|
|
41
42
|
outcome='ok';return Response.json({ok:true,data});
|
|
42
43
|
}catch(error){return fail(error.status??(error instanceof SyntaxError?400:500),error.status||error.name==='StudioOperationError'?error.message:'Tool request failed');}
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
import {APP_SHELL_JS} from './app-shell.mjs';
|
|
2
|
+
/** Explicit same-origin, public app surface for installation. */
|
|
3
|
+
export async function appWeb(request,env,meta,url,sub,document) {
|
|
4
|
+
if(!meta.web?.manifest)return null;
|
|
5
|
+
const base=`/${meta.id}/`;
|
|
6
|
+
if(sub==='manifest.webmanifest')return Response.json({...meta.web.manifest,id:base,start_url:base+'install',scope:base,display:meta.web.manifest.display??'standalone'},{headers:{'content-type':'application/manifest+json'}});
|
|
7
|
+
if(sub==='service-worker.js') {
|
|
8
|
+
const file=meta.web.serviceWorker;
|
|
9
|
+
if(typeof file!=='string'||file.includes('..')||!/^[-\w/]+\.js$/.test(file))return new Response('not found',{status:404});
|
|
10
|
+
const res=await env.ASSETS.fetch(new Request(url.origin+`/games/${meta.id}/${file}`));
|
|
11
|
+
return new Response(res.body,{status:res.status,headers:{'content-type':'text/javascript','service-worker-allowed':base,'cache-control':'no-cache'}});
|
|
12
|
+
}
|
|
13
|
+
if(sub!=='install')return null;
|
|
14
|
+
const role=meta.surfaces?.phone;
|
|
15
|
+
if(meta.room||meta.roles?.[role]?.signIn!==false)return new Response('Install requires a public records-only app',{status:409});
|
|
16
|
+
const response=await document(),headers=new Headers(response.headers);
|
|
17
|
+
if(!response.ok)return response;
|
|
18
|
+
headers.set('content-security-policy',"frame-ancestors 'self'");
|
|
19
|
+
const boot=JSON.stringify({kind:'app',direct:true,game:meta.id}).replace(/</g,'\\u003c');
|
|
20
|
+
const tags=`<base href="${base}__game/"><link rel="manifest" href="${base}manifest.webmanifest"><script>window.__HOMIE_APP=${boot};</script><script>${APP_SHELL_JS}</script>${meta.web.serviceWorker?`<script>if('serviceWorker' in navigator)navigator.serviceWorker.register('${base}service-worker.js',{scope:'${base}'}).catch(console.error);</script>`:''}`;
|
|
21
|
+
const html=await response.text();
|
|
22
|
+
return new Response(/<head[^>]*>/i.test(html)?html.replace(/<head[^>]*>/i,m=>m+tags):tags+html,{headers});
|
|
23
|
+
}
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
const dayMs=86400000;
|
|
2
|
+
const date=value=>{if(!/^\d{4}-\d{2}-\d{2}$/.test(value??''))throw new Error('Use a calendar date YYYY-MM-DD');const stamp=Date.parse(value+'T00:00:00Z');if(!Number.isFinite(stamp)||new Date(stamp).toISOString().slice(0,10)!==value)throw new Error('Invalid calendar date');return stamp;};
|
|
3
|
+
/** Resolve wall time, refusing DST gaps and ambiguous times instead of silently moving a booking. */
|
|
4
|
+
function instant(day,time,format) {
|
|
5
|
+
const wanted=day+'T'+time,base=Date.parse(wanted+':00Z');
|
|
6
|
+
const parts=stamp=>Object.fromEntries(format.formatToParts(stamp).map(p=>[p.type,p.value]));
|
|
7
|
+
const local=stamp=>{const p=parts(stamp);return `${p.year}-${p.month}-${p.day}T${p.hour}:${p.minute}`;};
|
|
8
|
+
const offsets=new Set([-dayMs,0,dayMs].map(delta=>{const p=parts(base+delta);return Date.UTC(+p.year,+p.month-1,+p.day,+p.hour,+p.minute)-(base+delta);}));
|
|
9
|
+
const matches=[...offsets].map(offset=>base-offset).filter(stamp=>local(stamp)===wanted);
|
|
10
|
+
if(matches.length!==1)throw new Error(`${wanted} does not exist or is ambiguous in this time zone; choose another time or publish an explicit offset timestamp`);
|
|
11
|
+
return matches[0];
|
|
12
|
+
}
|
|
13
|
+
/** Inclusive season dates; each returned slot may span several local calendar days. */
|
|
14
|
+
export function calendarSlots({startDate,endDate,timeZone,time='09:00',days,durationMinutes=60,capacity=1,weekdays,resources,options,prefix='slot'}) {
|
|
15
|
+
const start=date(startDate),end=date(endDate);
|
|
16
|
+
if(end<start||end-start>3660*dayMs||!/^([01]\d|2[0-3]):[0-5]\d$/.test(time)||!Number.isSafeInteger(capacity)||capacity<1||days!==undefined&&(!Number.isSafeInteger(days)||days<1)||!Number.isSafeInteger(durationMinutes)||durationMinutes<1)throw new Error('Invalid calendar range, time, duration or capacity');
|
|
17
|
+
if(weekdays&&(!Array.isArray(weekdays)||weekdays.some(n=>!Number.isInteger(n)||n<0||n>6)))throw new Error('weekdays uses 0 (Sunday) through 6 (Saturday)');
|
|
18
|
+
if(typeof timeZone!=='string'||!timeZone)throw new Error('Declare the place timeZone');
|
|
19
|
+
const format=new Intl.DateTimeFormat('en-CA',{timeZone,year:'numeric',month:'2-digit',day:'2-digit',hour:'2-digit',minute:'2-digit',hourCycle:'h23'}),slots=[];
|
|
20
|
+
for(let at=start;at<=end;at+=dayMs) {
|
|
21
|
+
if(weekdays&&!weekdays.includes(new Date(at).getUTCDay()))continue;
|
|
22
|
+
const day=new Date(at).toISOString().slice(0,10),from=instant(day,time,format),to=days?instant(new Date(at+days*dayMs).toISOString().slice(0,10),time,format):from+durationMinutes*60000;
|
|
23
|
+
slots.push({id:`${prefix}-${day}-${time.replace(':','')}`,date:day,timeZone,start:new Date(from).toISOString(),end:new Date(to).toISOString(),capacity,...(resources?{resources}:{}),...(options?{options}:{})});
|
|
24
|
+
}
|
|
25
|
+
return slots;
|
|
26
|
+
}
|
package/worker/booking-holds.mjs
CHANGED
|
@@ -1,3 +1,6 @@
|
|
|
1
|
+
import {calendarSlots} from './booking-calendar.mjs';
|
|
2
|
+
import {allocateResources} from './booking-resources.mjs';
|
|
3
|
+
import {normalizeBookingArgs} from './booking-options.mjs';
|
|
1
4
|
import {BOOKINGS_MIGRATION} from './booking-store.mjs';
|
|
2
5
|
import {linkConfig,hasStripeKey} from './shop-links.mjs';
|
|
3
6
|
export const BOOKING_HOLDS_MIGRATION_FILE='0029_booking_holds.sql';
|
|
@@ -44,7 +47,7 @@ export async function ownedHold(env,caller,{id,token}){
|
|
|
44
47
|
}
|
|
45
48
|
/** A hold is the very row checkout later confirms. Secrets never enter its public projection. */
|
|
46
49
|
export function bookingHolds(env,caller,cat){
|
|
47
|
-
const result=r=>({id:r.id,slot:r.slot,quantity:r.quantity,state:r.state,version:r.version,expiresAt:r.hold_until,paid:r.paid_at!==null});
|
|
50
|
+
const result=r=>({id:r.id,slot:r.slot,quantity:r.quantity,state:r.state,version:r.version,expiresAt:r.hold_until,paid:r.paid_at!==null,...(r.allocation&&r.allocation!=='[]'?{allocations:JSON.parse(r.allocation)}:{}),...(r.choices&&r.choices!=='{}'?{choices:JSON.parse(r.choices)}:{})});
|
|
48
51
|
const change=async(args,state)=>{
|
|
49
52
|
const row=await ownedHold(env,caller,args);
|
|
50
53
|
if(row.order_id!=='hold:'+row.id)throw refusal('Checkout owns this booking; reconcile payment before release');
|
|
@@ -53,7 +56,10 @@ export function bookingHolds(env,caller,cat){
|
|
|
53
56
|
if(!changed)throw refusal('Booking changed or expired; reload',409);return result(changed);
|
|
54
57
|
};
|
|
55
58
|
return Object.freeze({
|
|
56
|
-
|
|
59
|
+
calendar({app,...options}){const meta=cat.games.find(g=>g.id===app&&g.kind==='app');if(!meta)throw refusal('Unknown app');return calendarSlots({timeZone:meta.timeZone,...options});},
|
|
60
|
+
async hold(input){
|
|
61
|
+
const args=normalizeBookingArgs(input);
|
|
62
|
+
let {app,collection,slot,quantity=1,id,token,expiresAt}=args;
|
|
57
63
|
const meta=cat.games.find(g=>g.id===app&&g.kind==='app'),spec=meta?.records?.collections?.[collection]?.booking;
|
|
58
64
|
if(!spec||!valid(id)||!valid(slot)||!Number.isSafeInteger(quantity)||quantity<1)throw refusal('Choose a declared slot, positive quantity and stable booking id');
|
|
59
65
|
if(!Number.isSafeInteger(expiresAt)||expiresAt<=Date.now())throw refusal('Hold needs a future expiry');
|
|
@@ -62,11 +68,18 @@ export function bookingHolds(env,caller,cat){
|
|
|
62
68
|
if(!hasStripeKey(env)&&linkConfig(env)?.slots?.some(l=>l.app===app&&l.slots===spec.slots&&l.slot===slot))throw refusal('Keyless Stripe inventory cannot share local holds; configure session checkout first');
|
|
63
69
|
const scope=caller.testScope?`${app}:check:${caller.testScope}`:app;
|
|
64
70
|
id=await hash(JSON.stringify([scope,collection,id]));
|
|
71
|
+
const choices={option:args.option??null,resource:args.resource??null,addons:args.addons??{}};
|
|
72
|
+
if(await env.DB.prepare('SELECT id FROM shop_bookings WHERE id=?1').bind(id).first()) {
|
|
73
|
+
const previous=await ownedHold(env,caller,{id,token});
|
|
74
|
+
if(previous.app!==scope||previous.collection!==collection||previous.slot!==slot||previous.quantity!==quantity||JSON.stringify({...choices,...JSON.parse(previous.choices??'{}')})!==JSON.stringify(choices))throw refusal('Booking id reused with different request',409);
|
|
75
|
+
return result(previous);
|
|
76
|
+
}
|
|
77
|
+
let allocation;try{allocation=await allocateResources(env,scope,spec,slot,args);}catch(error){throw refusal(error.message,409);}
|
|
65
78
|
const secret=token?await hash(token):null;
|
|
66
|
-
try {await env.DB.prepare(`INSERT INTO shop_bookings(id,app,collection,slots,slot,quantity,order_id,state,updated_at,actor,token_hash,hold_until)
|
|
67
|
-
SELECT ?1,?2,?3,?4,?5,?6,'hold:'||?1,'held',?7,?8,?9,?10 WHERE NOT EXISTS(SELECT 1 FROM shop_bookings WHERE id=?1)`).bind(id,scope,collection,spec.slots,slot,quantity,Date.now(),caller.id??null,secret,expiresAt).run();}catch(error){if(/booking capacity unavailable/.test(error.message))throw refusal('That slot is
|
|
79
|
+
try {await env.DB.prepare(`INSERT INTO shop_bookings(id,app,collection,slots,slot,quantity,order_id,state,updated_at,actor,token_hash,hold_until,allocation,choices)
|
|
80
|
+
SELECT ?1,?2,?3,?4,?5,?6,'hold:'||?1,'held',?7,?8,?9,?10,?11,?12 WHERE NOT EXISTS(SELECT 1 FROM shop_bookings WHERE id=?1)`).bind(id,scope,collection,spec.slots,slot,quantity,Date.now(),caller.id??null,secret,expiresAt,JSON.stringify(allocation),JSON.stringify(choices)).run();}catch(error){if(/booking capacity unavailable|resource capacity unavailable/.test(error.message))throw refusal('That slot or resource capacity is unavailable. No place has been taken.',409);throw error;}
|
|
68
81
|
const row=await ownedHold(env,caller,{id,token});
|
|
69
|
-
if(row.app!==scope||row.collection!==collection||row.slot!==slot||row.quantity!==quantity)throw refusal('Booking id reused with different request',409);
|
|
82
|
+
if(row.app!==scope||row.collection!==collection||row.slot!==slot||row.quantity!==quantity||JSON.stringify(JSON.parse(row.choices??'{}'))!==JSON.stringify(choices))throw refusal('Booking id reused with different request',409);
|
|
70
83
|
return result(row);
|
|
71
84
|
},
|
|
72
85
|
async get(args){return result(await ownedHold(env,caller,args));},
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
/** Shared by trusted booking tools and phone flows. Aliases never silently win. */
|
|
2
|
+
export function normalizeBookingArgs(input) {
|
|
3
|
+
const args={...input};
|
|
4
|
+
for(const [canonical,aliases] of [['slot',['time']],['resource',['preferredResource','preferred_resource']],['addons',['addOns','add-ons']]]) {
|
|
5
|
+
for(const alias of aliases)if(args[alias]!==undefined) {
|
|
6
|
+
if(args[canonical]!==undefined&&JSON.stringify(args[canonical])!==JSON.stringify(args[alias]))throw Object.assign(new Error(`conflicting ${canonical}/${alias}`),{name:'StudioOperationError',status:400});
|
|
7
|
+
args[canonical]=args[alias];delete args[alias];
|
|
8
|
+
}
|
|
9
|
+
}
|
|
10
|
+
if(args.addons!==undefined) {
|
|
11
|
+
const list=Array.isArray(args.addons)?args.addons.map(id=>[id,1]):Object.entries(args.addons??{}),addons={};
|
|
12
|
+
for(const [id,count] of list) {
|
|
13
|
+
if(!/^[a-z][a-z0-9-]{0,39}$/.test(id)||!Number.isSafeInteger(count)||count<1||count>100)throw new Error('Invalid add-on id or quantity');
|
|
14
|
+
addons[id]=(addons[id]??0)+count;
|
|
15
|
+
}
|
|
16
|
+
args.addons=Object.fromEntries(Object.entries(addons).sort(([a],[b])=>a.localeCompare(b)));
|
|
17
|
+
}
|
|
18
|
+
return args;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
const text={type:'string',maxLength:64};
|
|
22
|
+
const addons={oneOf:[{type:'array',maxItems:100,items:text},{type:'object',additionalProperties:{type:'integer',minimum:1,maximum:100}}]};
|
|
23
|
+
export const bookingInputSchema={type:'object',properties:{app:text,collection:text,slot:text,time:text,option:text,resource:text,preferredResource:text,preferred_resource:text,addons,addOns:addons,'add-ons':addons,quantity:{type:'integer',minimum:1},id:text,token:{type:'string',pattern:'^[a-f0-9]{32,128}$'},expiresAt:{type:'integer'}},required:['app','collection','id','expiresAt'],additionalProperties:false};
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
export const BOOKING_RESOURCES_MIGRATION_FILE='0030_booking_resources.sql';
|
|
2
|
+
const capacityCheck=`
|
|
3
|
+
SELECT RAISE(ABORT,'resource capacity unavailable') WHERE EXISTS (
|
|
4
|
+
WITH wanted AS (SELECT json_extract(value,'$.resource') resource,json_extract(value,'$.collection') collection,json_extract(value,'$.start') start,json_extract(value,'$.end') end,json_extract(value,'$.units') units FROM json_each(NEW.allocation)),
|
|
5
|
+
existing AS (SELECT json_extract(a.value,'$.resource') resource,json_extract(a.value,'$.collection') collection,json_extract(a.value,'$.start') start,json_extract(a.value,'$.end') end,json_extract(a.value,'$.units') units FROM shop_bookings b,json_each(b.allocation) a WHERE b.app=NEW.app AND b.id!=NEW.id AND b.state IN ('held','confirmed')),
|
|
6
|
+
points AS (SELECT resource,collection,start point FROM wanted UNION SELECT resource,collection,start FROM existing)
|
|
7
|
+
SELECT 1 FROM points p WHERE EXISTS(SELECT 1 FROM wanted w WHERE w.resource=p.resource AND w.collection=p.collection AND w.start<=p.point AND w.end>p.point)
|
|
8
|
+
AND (COALESCE((SELECT SUM(units) FROM wanted w WHERE w.resource=p.resource AND w.collection=p.collection AND w.start<=p.point AND w.end>p.point),0)+COALESCE((SELECT SUM(units) FROM existing e WHERE e.resource=p.resource AND e.collection=p.collection AND e.start<=p.point AND e.end>p.point),0))>
|
|
9
|
+
COALESCE((SELECT json_extract(value,'$.capacity') FROM app_records WHERE app=NEW.app AND collection=p.collection AND id=p.resource),0));`;
|
|
10
|
+
export const BOOKING_RESOURCES_MIGRATION=`ALTER TABLE shop_bookings ADD COLUMN allocation TEXT NOT NULL DEFAULT '[]';
|
|
11
|
+
ALTER TABLE shop_bookings ADD COLUMN choices TEXT NOT NULL DEFAULT '{}';
|
|
12
|
+
${['INSERT','UPDATE OF state,allocation'].map((op,i)=>`CREATE TRIGGER booking_resource_${i} BEFORE ${op} ON shop_bookings WHEN NEW.state IN ('held','confirmed') BEGIN ${capacityCheck} END;`).join('\n')}
|
|
13
|
+
CREATE TRIGGER booking_resource_move BEFORE UPDATE OF slot,quantity ON shop_bookings WHEN (OLD.slot!=NEW.slot OR OLD.quantity!=NEW.quantity) AND OLD.allocation!='[]' AND OLD.state IN ('held','confirmed') BEGIN SELECT RAISE(ABORT,'resource booking move requires release and a new allocation'); END;
|
|
14
|
+
CREATE TRIGGER booking_resource_edit BEFORE UPDATE OF value ON app_records WHEN EXISTS(SELECT 1 FROM shop_bookings b,json_each(b.allocation) a WHERE b.app=OLD.app AND b.state IN ('held','confirmed') AND json_extract(a.value,'$.resource')=OLD.id AND json_extract(a.value,'$.collection')=OLD.collection) AND NEW.value!=OLD.value BEGIN SELECT RAISE(ABORT,'resource has active bookings; release or finish them before editing'); END;
|
|
15
|
+
CREATE TRIGGER booking_resource_delete BEFORE DELETE ON app_records WHEN EXISTS(SELECT 1 FROM shop_bookings b,json_each(b.allocation) a WHERE b.app=OLD.app AND b.state IN ('held','confirmed') AND json_extract(a.value,'$.resource')=OLD.id AND json_extract(a.value,'$.collection')=OLD.collection) BEGIN SELECT RAISE(ABORT,'resource has active bookings'); END;
|
|
16
|
+
`;
|
|
17
|
+
export async function allocateResources(env,app,spec,slot,args) {
|
|
18
|
+
const row=await env.DB.prepare('SELECT value FROM app_records WHERE app=?1 AND collection=?2 AND id=?3').bind(app,spec.slots,slot).first();
|
|
19
|
+
if(!row)throw new Error('Slot unavailable');
|
|
20
|
+
const data=JSON.parse(row.value),option=args.option===undefined?null:data.options?.find(o=>o.id===args.option);
|
|
21
|
+
if(args.option!==undefined&&!option)throw new Error('Choose a declared option');
|
|
22
|
+
if(data.requireOption&&!option)throw new Error('Choose an option');
|
|
23
|
+
const parts=option?.components??(data.resources?[{resources:data.resources,skills:data.skills,attributes:data.attributes}]:[]);
|
|
24
|
+
const components=[...parts];
|
|
25
|
+
for(const [id,count] of Object.entries(args.addons??{})) {
|
|
26
|
+
const addon=data.addons?.find(a=>a.id===id);if(!addon)throw new Error('Unknown add-on');
|
|
27
|
+
for(const component of addon.components??[])components.push({...component,units:(component.units??1)*count});
|
|
28
|
+
}
|
|
29
|
+
if(!components.length){if(args.resource)throw new Error('This slot has no declared resources');return [];}
|
|
30
|
+
if(!spec.resources)throw new Error('Declare booking.resources collection');
|
|
31
|
+
const start=Date.parse(data.start),end=Date.parse(data.end);
|
|
32
|
+
if(!Number.isFinite(start)||!Number.isFinite(end)||end<=start)throw new Error('Resource slot needs start and end timestamps');
|
|
33
|
+
if(components.length>32)throw new Error('A package supports at most 32 resource components');
|
|
34
|
+
const active=(await env.DB.prepare("SELECT allocation FROM shop_bookings WHERE app=?1 AND state IN ('held','confirmed')").bind(app).all()).results.flatMap(r=>JSON.parse(r.allocation));
|
|
35
|
+
const allocations=[];
|
|
36
|
+
for(const [index,c] of components.entries()) {
|
|
37
|
+
if(!Array.isArray(c.resources)||!c.resources.length||c.resources.length>64)throw new Error('Declare candidate resources');
|
|
38
|
+
const candidates=index===0&&args.resource?[args.resource]:c.resources;
|
|
39
|
+
if(candidates.some(id=>!c.resources.includes(id)))throw new Error('Preferred resource is unavailable for this option');
|
|
40
|
+
const from=start+(c.offsetMinutes??0)*60000,to=c.durationMinutes?from+c.durationMinutes*60000:end,units=(c.units??1)*(args.quantity??1);
|
|
41
|
+
if(!Number.isSafeInteger(from)||!Number.isSafeInteger(to)||from<start||to>end||to<=from||!Number.isSafeInteger(units)||units<1)throw new Error('Invalid package resource interval or units');
|
|
42
|
+
let selected,qualified=false;
|
|
43
|
+
for(const resource of candidates) {
|
|
44
|
+
const record=await env.DB.prepare('SELECT value FROM app_records WHERE app=?1 AND collection=?2 AND id=?3').bind(app,spec.resources,resource).first();
|
|
45
|
+
if(!record)continue;const r=JSON.parse(record.value);
|
|
46
|
+
if(!Number.isSafeInteger(r.capacity)||r.capacity<1||(c.skills??[]).some(skill=>!r.skills?.includes(skill))||Object.entries(c.attributes??{}).some(([k,v])=>r.attributes?.[k]!==v))continue;
|
|
47
|
+
qualified=true;const overlapping=[...active,...allocations].filter(a=>a.resource===resource&&a.collection===spec.resources&&a.start<to&&a.end>from);
|
|
48
|
+
if([from,...overlapping.map(a=>a.start).filter(t=>t>=from)].some(at=>units+overlapping.filter(a=>a.start<=at&&a.end>at).reduce((n,a)=>n+a.units,0)>r.capacity))continue;
|
|
49
|
+
selected={resource,collection:spec.resources,start:from,end:to,units};break;
|
|
50
|
+
}
|
|
51
|
+
if(!selected)throw new Error(qualified?'Resource capacity unavailable for these dates':'No resource matches the required skills or attributes');
|
|
52
|
+
allocations.push(selected);
|
|
53
|
+
}
|
|
54
|
+
return allocations;
|
|
55
|
+
}
|
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.
|
|
2
|
+
export const STUDIO_BUILD = Object.freeze({"version":"0.54.0","sha256":"4f65918ccb0f52120d8d0fa6cf761f2243b4636fdf10ccef395479318d603370"});
|
package/worker/chat-store.mjs
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import {databaseBinding, ORIGINAL_DB} from './request-db.mjs';
|
|
1
2
|
/**
|
|
2
3
|
* ROOM CHAT IN THE STUDIO'S OWN D1 (@homie-rocks/studio 0.23.0, NETPLAY.md section 19): the owner's chat rules per game
|
|
3
4
|
* and per server, and the reports players make. Never the chat itself: what people say lives a few minutes in the
|
|
@@ -51,8 +52,8 @@ const cache = new WeakMap();
|
|
|
51
52
|
/** Every chat rule row (D1), read at most every 5 s per Worker instance; empty before migration 0007. */
|
|
52
53
|
export async function chatRowsOf(env, { fresh = false } = {}) {
|
|
53
54
|
if (!env?.DB || env.HOMIE_PREVIEW === '1') return new Map();
|
|
54
|
-
const hit = cache.get(env.DB);
|
|
55
|
-
if (!fresh && hit && Date.now() - hit.at < 5000) return hit.map;
|
|
55
|
+
const hit = cache.get(databaseBinding(env.DB));
|
|
56
|
+
if (!env.DB[ORIGINAL_DB] && !fresh && hit && Date.now() - hit.at < 5000) return hit.map;
|
|
56
57
|
let map = new Map();
|
|
57
58
|
try {
|
|
58
59
|
const { results } = await env.DB.prepare('SELECT game, server, rules, updated_at FROM chat_rules LIMIT 1000').all();
|
|
@@ -62,10 +63,10 @@ export async function chatRowsOf(env, { fresh = false } = {}) {
|
|
|
62
63
|
if (rules && typeof rules === 'object') map.set(`${r.game}/${r.server ?? ''}`, { ...rules, at: Number(r.updated_at) || 0 });
|
|
63
64
|
}
|
|
64
65
|
} catch { map = new Map(); }
|
|
65
|
-
cache.set(env.DB, { at: Date.now(), map });
|
|
66
|
+
cache.set(databaseBinding(env.DB), { at: Date.now(), map });
|
|
66
67
|
return map;
|
|
67
68
|
}
|
|
68
|
-
export const forgetChat = (env) => { if (env?.DB) cache.delete(env.DB); };
|
|
69
|
+
export const forgetChat = (env) => { if (env?.DB) cache.delete(databaseBinding(env.DB)); };
|
|
69
70
|
|
|
70
71
|
/**
|
|
71
72
|
* A room's chat rules: the game's game.json "chat", the owner's rules for the game, then for this server (a server's
|
package/worker/customer-mcp.mjs
CHANGED
|
@@ -26,7 +26,7 @@ export function customerTools(env,cat,origin) {
|
|
|
26
26
|
return [
|
|
27
27
|
{name:'studio_catalogue',description:'Browse the public shop, paid parts and app goods. Prices use the studio’s currency minor units.',audience:'public',inputSchema:object(),handler:async(_args,ctx)=>({shop:await customerShop(env,cat,origin,ctx.caller,'/api/shop'),resources:await listOffers(env,origin)})},
|
|
28
28
|
{name:'studio_cart',description:'Quote a cart for agent payment. Retain the returned private account cookies to use guest purchases. Approve this immutable offerVersion before purchase.',audience:'public',inputSchema:cartSchema,handler:(args,ctx)=>quoteCart(env,cat,origin,ctx.caller,args,ctx.request)},
|
|
29
|
-
{name:'studio_checkout',description:'Open the studio’s Stripe checkout for a person to approve. Uses the same cart, account, order and refund as the website.',audience:'public',inputSchema:cartSchema,handler:(args,ctx)=>customerShop(env,cat,origin,ctx.caller,'/api/shop/buy',args)},
|
|
29
|
+
{name:'studio_checkout',description:'Open the studio’s Stripe checkout for a person to approve. Uses the same cart, account, order and refund as the website.',audience:'public',inputSchema:{...cartSchema,properties:{...cartSchema.properties,reference:str,booking:object({collection:str,slot:str,hold:str,token:str},['collection','slot'])}},handler:(args,ctx)=>customerShop(env,cat,origin,ctx.caller,'/api/shop/buy',args)},
|
|
30
30
|
{name:'studio_purchase',description:'Buy an approved offer with the MCP payment binding. Generate private random 32-byte hex buyer and claim values; retain the claim for retries. For x402 use the returned HTTP resource address.',audience:'public',inputSchema:{...purchaseSchema,properties:{...purchaseSchema.properties,checkout:{type:'boolean'}}},protocol:true,handler:async(args,ctx)=>{if(args.checkout&&args.kind==='cart'){const resource=await resourceKind('cart').get(env,args.resource,args.version);if(!resource)throw new Error('Cart unavailable');const result=await customerShop(env,cat,origin,{...ctx.caller,id:resource.player},'/api/shop/buy',{lines:resource.lines.map(l=>({item:l.item.id,quantity:l.quantity,amount:l.amount}))});return {content:[{type:'text',text:JSON.stringify(result)}],...(result.ok?{}:{isError:true})};}return buy(env,cat,origin,args,ctx.paymentExtra);}},
|
|
31
31
|
{name:'studio_customer_refund',description:'Request a refund for your shop order under the studio refund policy.',audience:'signed-in',inputSchema:object({order:str,line:str},['order']),handler:(args,ctx)=>customerShop(env,cat,origin,ctx.caller,'/api/shop/refund',args)},
|
|
32
32
|
{name:'studio_purchase_refund',description:'Request a resource or service refund with the original private purchase claim.',audience:'public',inputSchema:object({claim:{type:'string',pattern:'^[a-f0-9]{64}$'}},['claim']),handler:async({claim})=>{const url=new URL('/api/purchases/refund',origin);const response=await purchaseRoutes(new Request(url,{method:'POST',headers:{authorization:'Bearer '+claim,'content-type':'application/json'},body:'{}'}),env,url,cat);return response.json();}},
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import {databaseBinding} from './request-db.mjs';
|
|
1
2
|
import {McpEventStreams} from './mcp-event-streams.mjs';
|
|
2
3
|
import {listEvents} from './mcp-events.mjs';
|
|
3
4
|
import {notification,sseFrame} from './mcp-events-wire.mjs';
|
|
@@ -141,13 +142,16 @@ export class EventStreams {
|
|
|
141
142
|
}
|
|
142
143
|
}
|
|
143
144
|
|
|
145
|
+
const toolCatalogues=new WeakMap();
|
|
144
146
|
/** Refresh active streams whenever this Worker observes a new tool catalogue. */
|
|
145
147
|
export async function pushToolList(env,cat,tools,origin) {
|
|
146
148
|
if(!env.LOBBY)return;
|
|
147
149
|
const publicShape=tools.map(({name,description,inputSchema,audience,kind})=>({name,description,inputSchema,audience,kind}));
|
|
148
150
|
const signature=JSON.stringify([publicShape,eventFeeds(env)]);
|
|
151
|
+
const binding=databaseBinding(env.DB);
|
|
152
|
+
if(toolCatalogues.get(binding)===signature)return;
|
|
149
153
|
const previous=await env.DB.prepare("SELECT value FROM mcp_kv WHERE key='tools-catalogue'").first();
|
|
150
|
-
if(previous?.value===signature)return;
|
|
154
|
+
if(previous?.value===signature){toolCatalogues.set(binding,signature);return;}
|
|
151
155
|
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();
|
|
152
156
|
let after='';
|
|
153
157
|
for(;;){
|
|
@@ -155,4 +159,5 @@ export async function pushToolList(env,cat,tools,origin) {
|
|
|
155
159
|
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})});}
|
|
156
160
|
if(rows.length<100)break;after=rows.at(-1).key;
|
|
157
161
|
}
|
|
162
|
+
toolCatalogues.set(binding,signature);
|
|
158
163
|
}
|