@homie-rocks/studio 0.51.8 → 0.53.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (69) hide show
  1. package/CHANGELOG.md +59 -0
  2. package/apps/APPS.md +117 -0
  3. package/apps/apps.ts +68 -1
  4. package/bin/homie-studio.mjs +8 -2
  5. package/docs/shop-migration.md +9 -0
  6. package/docs/toolkit-batch.md +49 -0
  7. package/docs/toolkit-place-patterns.md +6 -0
  8. package/functions/FUNCTIONS.md +42 -1
  9. package/functions/functions.ts +4 -0
  10. package/guides/app/PLACES.md +146 -0
  11. package/guides/app/SKILL.md +3 -0
  12. package/guides/game/SKILL.md +3 -0
  13. package/guides/shop/SKILL.md +27 -12
  14. package/lib/app-needs.mjs +59 -15
  15. package/lib/app-ready.mjs +1 -1
  16. package/lib/build.mjs +8 -2
  17. package/lib/check.mjs +40 -16
  18. package/lib/cloudflare.mjs +2 -2
  19. package/lib/dev-restart.mjs +25 -0
  20. package/lib/dev.mjs +7 -1
  21. package/lib/fork.mjs +47 -0
  22. package/lib/functions-build.mjs +3 -0
  23. package/lib/jobs.mjs +1 -0
  24. package/lib/mcp-tools.mjs +3 -3
  25. package/lib/needs-headless.mjs +4 -3
  26. package/lib/scaffold.mjs +29 -1
  27. package/lib/shop-links.mjs +7 -7
  28. package/lib/shop-mcp.mjs +88 -14
  29. package/lib/stripe-connect.mjs +46 -6
  30. package/lib/template-history.json +84 -0
  31. package/lib/tools-cli.mjs +25 -3
  32. package/lib/upgrade-places.mjs +49 -0
  33. package/lib/upgrade.mjs +2 -1
  34. package/lib/worker-config.mjs +7 -3
  35. package/links/links.d.mts +5 -0
  36. package/links/links.d.ts +2 -0
  37. package/links/links.mjs +2 -0
  38. package/links/time.mjs +20 -0
  39. package/package.json +1 -1
  40. package/rules/rules.ts +1 -1
  41. package/shop/SHOP.md +27 -12
  42. package/shop/shop.ts +3 -3
  43. package/tools/tools.ts +13 -0
  44. package/worker/app-format.mjs +6 -3
  45. package/worker/app-records.mjs +21 -5
  46. package/worker/app-shell.mjs +28 -5
  47. package/worker/app-streams.mjs +57 -0
  48. package/worker/app-tools.mjs +44 -0
  49. package/worker/app-web.mjs +23 -0
  50. package/worker/booking-calendar.mjs +26 -0
  51. package/worker/booking-holds.mjs +100 -0
  52. package/worker/booking-options.mjs +23 -0
  53. package/worker/booking-resources.mjs +55 -0
  54. package/worker/build-id.mjs +1 -1
  55. package/worker/business-events.mjs +31 -0
  56. package/worker/customer-mcp.mjs +1 -1
  57. package/worker/event-resources.mjs +1 -1
  58. package/worker/functions.mjs +5 -0
  59. package/worker/index.mjs +54 -9
  60. package/worker/local-test.mjs +10 -0
  61. package/worker/mcp-events.mjs +3 -3
  62. package/worker/mcp-tools.mjs +23 -2
  63. package/worker/mcp.mjs +3 -3
  64. package/worker/office.mjs +27 -0
  65. package/worker/pages.mjs +4 -4
  66. package/worker/records-page.mjs +3 -3
  67. package/worker/shop-links.mjs +12 -0
  68. package/worker/shop.mjs +20 -5
  69. package/worker/version.mjs +1 -1
package/shop/SHOP.md CHANGED
@@ -122,27 +122,34 @@ calling it; tool argument shapes can change. See https://docs.stripe.com/mcp.
122
122
  3. Append `{ "method": "POST", "path": "…", "params": {…}, "body": <Stripe response> }`
123
123
  for **writes as well as reads**. Keep this setup's observations across batches.
124
124
  Product creation includes its default Price; link creation expands line items.
125
- Product and Price Search use studio/origin metadata. Existing links are fetched by
126
- IDs from the verified configuration; Stripe has no Payment Link metadata search.
127
- Keep `.studio/stripe-mcp.json` when moving the studio to another computer. Without
128
- it, repeat creates use their idempotency keys; outside Stripe's idempotency retention
129
- window, recover the studio's existing link IDs before setup to avoid duplicates.
130
- Follow search `next_page` cursors. Search is eventually consistent: write responses
131
- are the immediate evidence. On an uncertain write, retry its exact idempotency key;
132
- never discard successful responses or invent IDs. Start a new read set when checking
133
- later external edits, changing accounts, or changing mode.
125
+ Product and Price Search use studio/origin metadata. Payment Links use paginated
126
+ list reads with `expand[]=data.line_items`, then filter studio/origin metadata locally;
127
+ Stripe has no Payment Link metadata search. There is no per-link read loop.
128
+ The planner saves public observations in `.studio/stripe-mcp-observations.json` and
129
+ reuses them on the next call; send only new responses. Keep that file and
130
+ `.studio/stripe-mcp.json` when moving computers. Changed shop/slot settings refresh
131
+ mutable links while retaining product/price discovery. Account, mode and origin
132
+ changes start separate reads. Pass `refresh: true` for Dashboard changes or a fresh
133
+ verification before webhook installation (CLI: include it in `--have` input).
134
+ Follow search `next_page` and list `starting_after` cursors. On an uncertain write,
135
+ retry its exact idempotency key; never discard successful responses or invent IDs.
134
136
  4. Call `stripe_shop_plan` with `verify: true` using those observations (CLI
135
137
  `homie-studio shop verify --have plan-input.json`). Verification checks real fields,
136
138
  line items, menus, slot limits and portal settings. It saves compact public Worker
137
139
  configuration and provenance to `.studio/stripe-mcp.json`, never API response history.
138
- Deploy installs those links. Checkout then reports only `webhook-secret` missing;
140
+ Build writes public links to `site/dist/_site/stripe-shop-links.json`; deploy ships
141
+ that asset, with only its path in Wrangler vars. Large catalogues never use a text
142
+ binding. Deploy after verifying, including when setup began on an already-live site. Checkout then reports only `webhook-secret` missing;
139
143
  it cannot open until signed payment events can confirm orders. Changing shop.json,
140
144
  the slot manifest or origin requires verification again. Local test configuration
141
145
  goes to `.dev.vars`.
142
146
  5. For a deployed HTTPS studio, run `stripe_webhook` (CLI `homie-studio shop webhook`).
143
147
  Follow `studio_job`. This uses Stripe CLI only for private webhook installation,
144
- checks the same account as the MCP, and installs the signing secret and verified
145
- links through Wrangler stdin. The CLI needs one separate browser approval if its
148
+ checks the same account as the MCP, and installs only the signing secret through
149
+ Wrangler stdin. If Stripe CLI is missing, the tool installs the official
150
+ `@stripe/cli@latest` npm package automatically, checks the executable, then continues.
151
+ An installation failure reports npm permissions/PATH and a retry command. The CLI
152
+ needs one separate browser approval if its
146
153
  studio profile is not already connected. MCP approval cannot be reused or exported.
147
154
  Cloudflare authorization and an existing deployment are prerequisites.
148
155
  6. After installation, perform the returned `retire` operations through Stripe MCP to
@@ -153,6 +160,14 @@ calling it; tool argument shapes can change. See https://docs.stripe.com/mcp.
153
160
  endpoint's delivery result and the app's verified records. Do not type a card into
154
161
  a page for an automated proof; use Stripe's documented test PaymentMethods/fixtures.
155
162
 
163
+ Upgrades accept 0.51.6 verifications without a source fingerprint only when their
164
+ saved content fingerprint still matches the current shop, slots, site and mode.
165
+ Actual changes name the affected settings and require verification. An unfinished
166
+ setup still builds and deploys with checkout closed and one next step: finish
167
+ `stripe_shop_plan` with `verify: true`. Missing/unreadable assets also close checkout;
168
+ old link secrets cannot reopen it. Cached observations never renew the verification
169
+ time; use fresh reads if the private webhook tool requests them.
170
+
156
171
  Never create a webhook through model-visible Stripe MCP: its response exposes the signing secret.
157
172
 
158
173
  **Why the separate approval (checked 2026-10-14).** Stripe's
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 }, 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
@@ -6,6 +6,15 @@ export interface ToolContext {
6
6
  records(args: { app: string; role?: string; collection: string; id?: string; operation?: 'read' | 'create' | 'update' | 'delete'; data?: Record<string, unknown>; version?: number }): Promise<unknown>;
7
7
  office(path: string, body?: Record<string, unknown>, query?: Record<string, string>): Promise<unknown>;
8
8
  shop(): Promise<unknown>;
9
+ bookings: {
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>;
12
+ get(args: {id: string; token?: string}): Promise<BookingHold>;
13
+ confirm(args: {id: string; token?: string; version: number}): Promise<BookingHold>;
14
+ release(args: {id: string; token?: string; version: number}): Promise<BookingHold>;
15
+ releaseExpired(args: {app: string; collection: string; now?: number}): Promise<{released: number}>;
16
+ };
17
+ events: { publish(event: {feed: string; id: string; data: unknown}): Promise<{id: string}> };
9
18
  rooms: { send(event: {game: string; room: string; event: string; data: unknown}): Promise<unknown> };
10
19
  database: {
11
20
  get(key: string): Promise<unknown>;
@@ -32,7 +41,11 @@ export interface StudioTool<Input = Record<string, unknown>> {
32
41
  namespace?: string;
33
42
  /** A named HMAC secret delegates this one tool as an existing account. */
34
43
  webhook?: { secret: string; person: string };
44
+ /** Named business feeds this trusted definition may publish. */
45
+ publish?: string[];
35
46
  events?: Record<string, {game: string; schema: Record<string, unknown>; audience?: Audience}>;
36
47
  handler(input: Input, context: ToolContext): unknown | Promise<unknown>;
37
48
  }
38
49
  export function defineTool<Input = Record<string, unknown>>(tool: StudioTool<Input>): StudioTool<Input> { return tool; }
50
+
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 }
@@ -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 boolean type`);
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 slot writes require a signed-in staff role`);
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
  }
@@ -47,7 +47,7 @@ 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' };
@@ -91,7 +91,7 @@ export async function appRecordsRoute(request, env, meta, url, sub) {
91
91
  if (!access.ok) return fail(access.status, access.error);
92
92
  const [, collection, id] = match;
93
93
  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);
94
+ const testScope = (session?.app === meta.id ? session.scope : null) ?? (env.HOMIE_LOCAL_TEST_SECRET || toolIdentity(request)?.remoteTest ? toolIdentity(request)?.testScope : null);
95
95
  const recordApp = testScope ? `${meta.id}:check:${testScope}` : meta.id;
96
96
  const op = ({ GET: 'read', POST: 'create', PUT: 'update', DELETE: 'delete' })[request.method];
97
97
  if (!op || !access.can.includes(`${op}:${collection}`)) return fail(403, 'role cannot perform this operation');
@@ -103,18 +103,22 @@ export async function appRecordsRoute(request, env, meta, url, sub) {
103
103
  for(const r of rows) {
104
104
  const record={id:r.id,data:JSON.parse(r.value),version:r.version,updatedAt:r.updated_at};
105
105
  const link=linkConfig(env)?.slots?.find(l=>l.app===meta.id&&l.slots===collection&&l.slot===r.id);
106
+ if(Object.values(meta.records.collections).some(c=>c.booking?.slots===collection)) {
107
+ const count=await env.DB.prepare("SELECT COALESCE(SUM(quantity),0) n FROM shop_bookings WHERE app=?1 AND slots=?2 AND slot=?3 AND state IN ('held','confirmed')").bind(recordApp,collection,r.id).first();
108
+ record.booking={full:count.n>=record.data.capacity,available:Math.max(0,record.data.capacity-count.n),synced:true};
109
+ }
106
110
  if(link) {
107
111
  const saved=await env.DB.prepare('SELECT value FROM meta WHERE key=?1').bind(`shop-slot-link:${link.id}`).first();
108
112
  const observed=saved?JSON.parse(saved.value):null;
109
113
  const status=observed && observed.capacity===(link.limit??link.capacity) ? observed : link;
110
- const count=await env.DB.prepare("SELECT COALESCE(SUM(quantity),0) n FROM shop_bookings WHERE app=?1 AND slots=?2 AND slot=?3 AND state='confirmed'").bind(recordApp,collection,r.id).first();
114
+ const count=await env.DB.prepare("SELECT COALESCE(SUM(quantity),0) n FROM shop_bookings WHERE app=?1 AND slots=?2 AND slot=?3 AND state IN ('held','confirmed')").bind(recordApp,collection,r.id).first();
111
115
  record.booking={full:!status.active||status.count>=(link.limit??link.capacity)||count.n>=link.capacity,available:status.active?Math.max(0,Math.min(link.capacity-count.n,(link.limit??link.capacity)-status.count)):0,synced:link.capacity===record.data.capacity};
112
116
  }
113
117
  records.push(record);
114
118
  }
115
119
  return json({ok:true,records});
116
120
  }
117
- if (!id) return fail(400, 'record id required');
121
+ 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).`);
118
122
  const body = await bodyOf(request);
119
123
  if (meta.records.collections[collection].booking) {
120
124
  if (op !== 'update' || !meta.roles[access.role].signIn) return fail(403, 'bookings are created and released through checkout');
@@ -128,7 +132,19 @@ export async function appRecordsRoute(request, env, meta, url, sub) {
128
132
  ? 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())
129
133
  : 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)
130
134
  : 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);
131
- const result = await stmt.run();
135
+ const bookingCollections=Object.entries(meta.records.collections).filter(([,c])=>c.booking?.slots===collection);
136
+ const writes=[stmt];
137
+ if(!testScope&&op!=='delete')for(const [bookings,c] of bookingCollections) {
138
+ const link=linkConfig(env)?.slots?.find(l=>l.app===meta.id&&l.slots===collection&&l.slot===id);
139
+ if(link&&link.capacity===body.data?.capacity)continue;
140
+ const expected=op==='create'?1:body.version+1;
141
+ writes.push(env.DB.prepare(`INSERT OR IGNORE INTO studio_events(id,type,data,at)
142
+ 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
143
+ FROM app_records WHERE app=?8 AND collection=?4 AND id=?5 AND version=?9
144
+ AND EXISTS(SELECT 1 FROM function_subscriptions WHERE type='booking.slots_need_payment_links')`)
145
+ .bind(`slot-links:${recordApp}:${collection}:${id}:${expected}`,meta.id,bookings,collection,id,c.booking.item,Date.now(),recordApp,expected));
146
+ }
147
+ const result=writes.length>1?(await env.DB.batch(writes))[0]:await stmt.run();
132
148
  return result.meta.changes ? json({ ok: true, id, version: op === 'create' ? 1 : body.version + 1 }) : fail(409, 'record changed or already exists; reload');
133
149
  } catch (error) {
134
150
  if (/booking capacity|capacity below|slot has active/.test(error.message)) return fail(409, error.message);
@@ -2,7 +2,7 @@
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
  // Share short-lived reads across same-origin screens with the exact same role/access.
8
8
  // No records are persisted. Writes always reach the Worker and invalidate all views.
@@ -13,14 +13,14 @@ export const APP_SHELL_JS = String.raw`(function () {
13
13
  function share(m) { accept(m); if (channel) channel.postMessage(m); }
14
14
  function locked(run) { return typeof navigator !== 'undefined' && navigator.locks ? navigator.locks.request('homie-app-records-v1', run) : run(); }
15
15
  async function request(path, m) {
16
- var read = m.method === 'GET';
16
+ var read = m.type!=='homie-app-tool' && m.method === 'GET';
17
17
  if (read && pending.has(path)) return pending.get(path);
18
18
  var job = locked(async function() {
19
19
  var cached = recent.get(path);
20
20
  if (read && cached && cached.until > Date.now()) return cached.result;
21
21
  var result;
22
22
  try {
23
- var res = await fetch(path, { method: m.method, headers: { 'content-type': 'application/json' }, body: read ? undefined : JSON.stringify({ data: m.data, version: m.version }), signal: AbortSignal.timeout(8000) });
23
+ var res = await fetch(path, { method: m.type==='homie-app-tool'?'POST':m.method, headers: { 'content-type': 'application/json' }, body: m.type==='homie-app-tool'?JSON.stringify({name:m.name,input:m.input}):read ? undefined : JSON.stringify({ data: m.data, version: m.version }), signal: AbortSignal.timeout(8000) });
24
24
  result = await res.json();
25
25
  if (res.status === 429 || res.status >= 500) throw new Error('reconnect');
26
26
  failures.delete(path);
@@ -35,15 +35,38 @@ export const APP_SHELL_JS = String.raw`(function () {
35
35
  if (read) { pending.set(path, job); job.finally(function() { pending.delete(path); }); }
36
36
  return job;
37
37
  }
38
+ var live, collections=new Set(), presence=0, clock=null;
39
+ function query() {
40
+ var q=new URLSearchParams();
41
+ ['role','surface','access','room'].forEach(function(k){var v=params.get(k);if(v)q.set(k,v);});
42
+ if(boot.screen&&!q.has('role'))q.set('surface','wall');
43
+ return q;
44
+ }
45
+ function context() {return {app:boot.game,origin:location.origin,url:location.origin+'/'+boot.game+'/open',presence:presence,now:clock};}
46
+ function sendContext(){frame?.contentWindow?.postMessage({type:'homie-app-context',context:context()},'*');}
47
+ function connect(){
48
+ live?.close();
49
+ var q=query();collections.forEach(function(c){q.append('collection',c);});
50
+ live=new EventSource((boot.site||'')+'/'+boot.game+'/api/app/live?'+q);
51
+ live.onmessage=function(e){var value=JSON.parse(e.data);if(presence!==value.presence){presence=value.presence;sendContext();}if(value.collections?.length){share({invalidate:true});Promise.allSettled([...pending.values()]).then(function(){share({invalidate:true});frame?.contentWindow?.postMessage({type:'homie-app-changed',collections:value.collections},'*');});}};
52
+ }
53
+ window.homieApp=Object.freeze({get context(){return context();},setTestClock:function(value){if(!boot.test)throw new Error('Local proof session required');if(value!==null&&!Number.isFinite(value))throw new Error('Invalid clock');clock=value;sendContext();}});
54
+ addEventListener('pagehide',function(){live?.close();});
38
55
  addEventListener('message', async function (e) {
39
56
  var m = e.data;
57
+ if(frame&&e.source===frame.contentWindow&&m?.type==='homie-app-context'){sendContext();return;}
58
+ if(frame&&e.source===frame.contentWindow&&m?.type==='homie-app-subscribe'){
59
+ if(!Array.isArray(m.collections)||m.collections.some(c=>!/^[a-z][a-z0-9-]{0,39}$/.test(c)))return;
60
+ m.collections.forEach(c=>collections.add(c));connect();return;
61
+ }
40
62
  if (frame && e.source === frame.contentWindow && m?.type === 'homie-app-ticket' && /^[a-f0-9]{32}$/.test(m.ticket)) { params.set('ticket', m.ticket); history.replaceState(null, '', location.pathname + '?' + params); return; }
41
- if (!frame || e.source !== frame.contentWindow || !m || m.type !== 'homie-app' || !/^[a-f0-9]{32}$/.test(m.id || '')) return;
42
- if (!/^[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;
63
+ if (!frame || e.source !== frame.contentWindow || !m || !['homie-app','homie-app-tool'].includes(m.type) || !/^[a-f0-9]{32}$/.test(m.id || '')) return;
64
+ 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;
43
65
  var q = new URLSearchParams();
44
66
  ['role','surface','access'].forEach(function (k) { var v = params.get(k); if (v) q.set(k, v); });
45
67
  if (boot.screen && !q.has('role')) q.set('surface', 'wall');
46
68
  var path = (boot.site || '') + '/' + boot.game + '/api/app/records/' + m.collection + (m.record ? '/' + m.record : '') + '?' + q;
69
+ if(m.type==='homie-app-tool')path=(boot.site||'')+'/'+boot.game+'/api/app/tools?'+q;
47
70
  var result = await request(path, m);
48
71
  e.source.postMessage({ type: 'homie-app-result', id: m.id, result: result }, '*');
49
72
  });
@@ -0,0 +1,57 @@
1
+ import {appAccess} from './app-records.mjs';
2
+ import {localSession} from './local-test.mjs';
3
+ import {settingsOf,launchOf,accessOf} from './office.mjs';
4
+ export const APP_LIVE_MIGRATION_FILE='0027_app_live.sql';
5
+ export const APP_LIVE_MIGRATION=`
6
+ CREATE TABLE app_record_revisions(app TEXT NOT NULL,collection TEXT NOT NULL,revision INTEGER NOT NULL,PRIMARY KEY(app,collection));
7
+ ${['INSERT','UPDATE','DELETE'].map(op=>{const row=op==='DELETE'?'OLD':'NEW';return `CREATE TRIGGER app_records_live_${op.toLowerCase()} AFTER ${op} ON app_records BEGIN
8
+ INSERT INTO app_record_revisions VALUES(${row}.app,${row}.collection,1) ON CONFLICT(app,collection) DO UPDATE SET revision=revision+1;
9
+ END;`;}).join('\n')}
10
+ `;
11
+ const frame=value=>new TextEncoder().encode('data: '+JSON.stringify(value)+'\n\n');
12
+ export const appHub=(env,app)=>env.LOBBY.get(env.LOBBY.idFromName('@app-records/'+app));
13
+ export async function pushAppRecords(env,cat){
14
+ if(!env.LOBBY||!env.DB)return;
15
+ await Promise.all((cat.games??[]).filter(g=>g.kind==='app').map(meta=>appHub(env,meta.id).fetch('https://app/__app_records',{method:'POST',body:JSON.stringify({op:'wake',meta})}).catch(error=>console.error('App notification wake failed',error))));
16
+ }
17
+ /** Collection invalidations, never record bodies or identifiers. Reads recheck current authorization. */
18
+ export class AppRecordStreams {
19
+ constructor(env,ctx){this.env=env;this.ctx=ctx;this.streams=new Set();}
20
+ 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();}
22
+ count(s){return [...this.streams].filter(x=>x.app===s.app&&x.room===s.room).length;}
23
+ send(s,data){if(s.controller.desiredSize<=0){this.close(s);return;}try{s.controller.enqueue(frame(data));}catch{this.close(s);}}
24
+ presence(){for(const s of this.streams)this.send(s,{presence:this.count(s)});}
25
+ async authorized(s){
26
+ const access=await appAccess(s.request,this.env,s.meta,s.url);
27
+ if(!access.ok||s.collections.some(c=>!access.can.includes('read:'+c)))return false;
28
+ const launch=launchOf(s.meta,await settingsOf(this.env,{fresh:true}),this.env);
29
+ return launch==='public'||(await accessOf(s.request,this.env,s.meta.id,launch)).ok;
30
+ }
31
+ async revisions(s){return (await this.env.DB.prepare('SELECT collection,revision FROM app_record_revisions WHERE app=?1').bind(s.app).all()).results;}
32
+ async open({meta,url,headers,collections=[]}){
33
+ if(!Array.isArray(collections)||collections.some(c=>typeof c!=='string'||!Object.hasOwn(meta.records?.collections??{},c)))return new Response('unknown collection',{status:400});
34
+ const s={meta,url:new URL(url),request:new Request(url,{headers}),collections,seen:new Map()};
35
+ if(!await this.authorized(s))return new Response('role cannot read collections',{status:403});
36
+ const session=await localSession(s.request,this.env);
37
+ s.app=session?.app===meta.id&&session.scope?`${meta.id}:check:${session.scope}`:meta.id;
38
+ s.room=s.url.searchParams.get('room')??'main';
39
+ for(const row of await this.revisions(s))s.seen.set(row.collection,row.revision);
40
+ const body=new ReadableStream({start:controller=>{s.controller=controller;},cancel:()=>this.close(s)});
41
+ this.streams.add(s);this.send(s,{collections:s.collections,presence:this.count(s),reset:true});
42
+ for(const other of this.streams)if(other!==s)this.send(other,{presence:this.count(other)});
43
+ // Recovery after a missed wake; this is not the screen's refresh clock.
44
+ s.timer=setInterval(()=>this.ctx.waitUntil(this.pump(s)),15000);
45
+ return new Response(body,{headers:{'content-type':'text/event-stream','cache-control':'no-store'}});
46
+ }
47
+ async pump(s){
48
+ if(!this.streams.has(s))return;if(s.busy){s.dirty=true;return;}s.busy=true;
49
+ try{
50
+ if(!await this.authorized(s)){this.close(s);return;}
51
+ const changed=[];
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)});
54
+ }catch{this.close(s);}finally{s.busy=false;if(s.dirty){s.dirty=false;await this.pump(s);}}
55
+ }
56
+ 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');}
57
+ }
@@ -0,0 +1,44 @@
1
+ import {CfWorkerJsonSchemaValidator} from '@modelcontextprotocol/server/validators/cf-worker';
2
+ import {allowed,services} from './mcp-tools.mjs';
3
+ import {appAccess} from './app-records.mjs';
4
+ import {localSession} from './local-test.mjs';
5
+ import {players} from './players.mjs';
6
+ import {isOwner,sameOrigin} from './office.mjs';
7
+ import {audit,callsPerMinute,rateLimit} from './mcp-store.mjs';
8
+ const validator=new CfWorkerJsonSchemaValidator();
9
+ export async function invokeAppTool(tool,input,context){
10
+ if(!validator.getValidator(tool.inputSchema)(input).valid)throw Object.assign(new Error('Input does not match the tool schema'),{status:400});
11
+ return tool.handler(input,context);
12
+ }
13
+ export async function appCaller(request,env,meta,url){
14
+ const access=await appAccess(request,env,meta,url);
15
+ if(!access.ok)throw Object.assign(new Error(access.error),{status:access.status});
16
+ const session=await localSession(request,env);
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
+ const person=await players.of(request,env);
19
+ const owner=await isOwner(request,env);
20
+ const roles={};
21
+ if(person&&!person.guest){for(const row of (await env.DB.prepare('SELECT app,role FROM app_roles WHERE player=?1').bind(person.id).all()).results)(roles[row.app]??=[]).push(row.role);}
22
+ return {id:owner?(person?.id??'browser-owner'):person&&!person.guest?person.id:null,owner,client:null,roles};
23
+ }
24
+ /** Same-origin shell transport; body fields never supply an identity or a role. */
25
+ export async function appToolsRoute(request,env,meta,url,cat,definitions){
26
+ const fail=(status,error)=>Response.json({ok:false,error},{status});
27
+ if(request.method!=='POST')return fail(405,'POST required');
28
+ if(!sameOrigin(request,url))return fail(403,'same origin required');
29
+ let caller,tool,outcome='error';
30
+ try{
31
+ caller=await appCaller(request,env,meta,url);
32
+ const reader=request.body?.getReader();let size=0,text='';const decoder=new TextDecoder();
33
+ if(reader)for(;;){const {done,value}=await reader.read();if(done)break;size+=value.byteLength;if(size>12288){await reader.cancel();return fail(413,'body too large');}text+=decoder.decode(value,{stream:true});}
34
+ const body=JSON.parse(text+decoder.decode());
35
+ tool=definitions.find(t=>t.name===body.name&&(!t.kind||t.kind==='tool'));
36
+ if(!tool||!await allowed(tool.audience,caller,env)){outcome='denied';return fail(403,'Permission denied or unknown tool');}
37
+ if(tool.price)return fail(402,'Use MCP to approve payment for this tool');
38
+ if(!await rateLimit(env,caller.id??`public:${request.headers.get('cf-connecting-ip')??'local'}`,callsPerMinute(cat)))return fail(429,'Rate limit reached');
39
+ const namespace=(tool.namespace??tool.name)+(caller.testScope?':check:'+caller.testScope:'');
40
+ const data=await invokeAppTool(tool,body.input??{},services(env,cat,url.origin,caller,namespace,tool));
41
+ outcome='ok';return Response.json({ok:true,data});
42
+ }catch(error){return fail(error.status??(error instanceof SyntaxError?400:500),error.status||error.name==='StudioOperationError'?error.message:'Tool request failed');}
43
+ finally{if(caller)await audit(env,caller,tool?.name??'unknown-tool',outcome);}
44
+ }
@@ -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
+ }
@@ -0,0 +1,100 @@
1
+ import {calendarSlots} from './booking-calendar.mjs';
2
+ import {allocateResources} from './booking-resources.mjs';
3
+ import {normalizeBookingArgs} from './booking-options.mjs';
4
+ import {BOOKINGS_MIGRATION} from './booking-store.mjs';
5
+ import {linkConfig,hasStripeKey} from './shop-links.mjs';
6
+ export const BOOKING_HOLDS_MIGRATION_FILE='0029_booking_holds.sql';
7
+ export const BOOKING_HOLDS_MIGRATION=`
8
+ ALTER TABLE shop_bookings ADD COLUMN paid_at INTEGER;
9
+ ALTER TABLE shop_bookings ADD COLUMN actor TEXT;
10
+ ALTER TABLE shop_bookings ADD COLUMN token_hash TEXT;
11
+ ALTER TABLE shop_bookings ADD COLUMN hold_until INTEGER;
12
+ DROP TRIGGER booking_capacity_move;
13
+ CREATE TRIGGER booking_capacity_move BEFORE UPDATE OF slot,quantity,state ON shop_bookings
14
+ WHEN NEW.state IN ('held','confirmed') BEGIN
15
+ SELECT RAISE(ABORT,'booking capacity unavailable') WHERE NOT EXISTS (
16
+ SELECT 1 FROM app_records s WHERE s.app=NEW.app AND s.collection=NEW.slots AND s.id=NEW.slot
17
+ AND json_type(s.value,'$.capacity')='integer' AND json_extract(s.value,'$.capacity') >= NEW.quantity +
18
+ (SELECT COALESCE(SUM(quantity),0) FROM shop_bookings WHERE app=NEW.app AND slots=NEW.slots AND slot=NEW.slot AND id!=NEW.id AND state IN ('held','confirmed')));
19
+ END;
20
+ CREATE TRIGGER booking_hold_claim BEFORE UPDATE OF order_id ON shop_bookings BEGIN
21
+ SELECT RAISE(ABORT,'booking hold changed or expired') WHERE NEW.order_id IS NULL OR OLD.order_id!='hold:'||OLD.id OR OLD.state NOT IN ('held','confirmed') OR (OLD.state='held' AND OLD.hold_until<=NEW.updated_at);
22
+ END;
23
+ DROP TRIGGER booking_order_state;
24
+ CREATE TRIGGER booking_order_state AFTER UPDATE OF status,paid_at ON shop_orders
25
+ WHEN NEW.status!=OLD.status OR (OLD.paid_at IS NULL AND NEW.paid_at IS NOT NULL) BEGIN
26
+ UPDATE shop_bookings SET state=CASE WHEN NEW.status='paid' THEN 'confirmed' ELSE 'released' END,
27
+ paid_at=COALESCE(NEW.paid_at,paid_at),version=version+1,updated_at=NEW.updated_at
28
+ WHERE order_id=NEW.id AND ((state IN ('pending','held','confirmed') AND NEW.status='paid')
29
+ OR (state IN ('pending','held') AND NEW.status IN ('expired','failed'))
30
+ OR (state IN ('pending','held','confirmed') AND NEW.status IN ('refunded','lost')));
31
+ END;
32
+ ${['insert','update'].map(op=>{
33
+ const trigger=BOOKINGS_MIGRATION.match(new RegExp('CREATE TRIGGER booking_record_'+op+'[\\s\\S]*?END;'))[0];
34
+ return 'DROP TRIGGER booking_record_'+op+';\n'+trigger.replace("'state',NEW.state)","'state',NEW.state,'paid',json(CASE WHEN NEW.paid_at IS NULL THEN 'false' ELSE 'true' END))");
35
+ }).join('\n')}
36
+ UPDATE shop_bookings SET paid_at=(SELECT paid_at FROM shop_orders WHERE id=order_id),version=version+1 WHERE EXISTS(SELECT 1 FROM shop_orders WHERE id=order_id AND paid_at IS NOT NULL);
37
+ `;
38
+ const refusal=(message,status=400)=>Object.assign(new Error(message),{name:'StudioOperationError',status});
39
+ const valid=v=>typeof v==='string'&&/^[A-Za-z0-9_-]{1,64}$/.test(v);
40
+ const hash=async token=>Array.from(new Uint8Array(await crypto.subtle.digest('SHA-256',new TextEncoder().encode(token))),b=>b.toString(16).padStart(2,'0')).join('');
41
+ export async function ownedHold(env,caller,{id,token}){
42
+ if(!valid(id))throw refusal('Invalid booking id');
43
+ const row=await env.DB.prepare('SELECT * FROM shop_bookings WHERE id=?1').bind(id).first();
44
+ if(!row||!(caller.owner||row.actor&&row.actor===caller.id||row.token_hash&&typeof token==='string'&&await hash(token)===row.token_hash))throw refusal('Booking owner required',403);
45
+ if(caller.testScope&&!row.app.endsWith(':check:'+caller.testScope))throw refusal('Booking owner required',403);
46
+ return row;
47
+ }
48
+ /** A hold is the very row checkout later confirms. Secrets never enter its public projection. */
49
+ export function bookingHolds(env,caller,cat){
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)}:{})});
51
+ const change=async(args,state)=>{
52
+ const row=await ownedHold(env,caller,args);
53
+ if(row.order_id!=='hold:'+row.id)throw refusal('Checkout owns this booking; reconcile payment before release');
54
+ if(!Number.isSafeInteger(args.version)||args.version<1)throw refusal('Booking version required');
55
+ const changed=await env.DB.prepare(`UPDATE shop_bookings SET state=?2,version=version+1,updated_at=?3 WHERE id=?1 AND version=?4 AND state IN ('held','confirmed') AND (?2='released' OR hold_until>?3) RETURNING *`).bind(row.id,state,Date.now(),args.version).first();
56
+ if(!changed)throw refusal('Booking changed or expired; reload',409);return result(changed);
57
+ };
58
+ return Object.freeze({
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;
63
+ const meta=cat.games.find(g=>g.id===app&&g.kind==='app'),spec=meta?.records?.collections?.[collection]?.booking;
64
+ if(!spec||!valid(id)||!valid(slot)||!Number.isSafeInteger(quantity)||quantity<1)throw refusal('Choose a declared slot, positive quantity and stable booking id');
65
+ if(!Number.isSafeInteger(expiresAt)||expiresAt<=Date.now())throw refusal('Hold needs a future expiry');
66
+ if(token!==undefined&&!(typeof token==='string'&&/^[a-f0-9]{32,128}$/.test(token)))throw refusal('Invalid private booking token');
67
+ if(!caller.id&&!token)throw refusal('Booking owner or private token required');
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');
69
+ const scope=caller.testScope?`${app}:check:${caller.testScope}`:app;
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);}
78
+ const secret=token?await hash(token):null;
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;}
81
+ const row=await ownedHold(env,caller,{id,token});
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);
83
+ return result(row);
84
+ },
85
+ async get(args){return result(await ownedHold(env,caller,args));},
86
+ confirm:args=>change(args,'confirmed'),release:args=>change(args,'released'),
87
+ async releaseExpired({app,collection,now=Date.now()}){
88
+ if(!caller.owner)throw refusal('Owner or scheduled function required');
89
+ if(!Number.isSafeInteger(now))throw refusal('Invalid release time');
90
+ const scope=caller.testScope?`${app}:check:${caller.testScope}`:app;
91
+ const r=await env.DB.prepare("UPDATE shop_bookings SET state='released',version=version+1,updated_at=?3 WHERE app=?1 AND collection=?2 AND order_id='hold:'||id AND state='held' AND hold_until<=?3").bind(scope,collection,now).run();return {released:r.meta.changes};
92
+ }
93
+ });
94
+ }
95
+ /** Used in the same transaction as the order reservation. A second checkout cannot claim the hold. */
96
+ export function claimHoldStatement(env,{hold,app,collection,slots,slot,quantity,order,now=Date.now()}){
97
+ return env.DB.prepare(`UPDATE shop_bookings SET order_id=CASE WHEN app=?4 AND collection=?5 AND slots=?6 AND slot=?7 AND quantity=?8 THEN ?2 ELSE NULL END,version=version+1,updated_at=?3
98
+ WHERE id=?1
99
+ AND EXISTS(SELECT 1 FROM shop_orders WHERE id=?2)`).bind(hold,order,now,app,collection,slots,slot,quantity);
100
+ }
@@ -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};