@homie-rocks/studio 0.50.6 → 0.51.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 +62 -0
- package/app-starters/giving/README.md +25 -2
- package/app-starters/giving/app.json +16 -49
- package/app-starters/giving/index.html +1 -1
- package/app-starters/giving/src/rules.ts +1 -1
- package/app-starters/giving/src/view.ts +57 -59
- package/apps/APPS.md +70 -2
- package/apps/apps.ts +1 -1
- package/bin/homie-studio.mjs +8 -4
- package/functions/FUNCTIONS.md +39 -2
- package/guides/app/SKILL.md +61 -5
- package/guides/functions/SKILL.md +30 -0
- package/guides/office/SKILL.md +20 -0
- package/guides/servers/SKILL.md +18 -0
- package/guides/shop/SKILL.md +41 -20
- package/lib/app-needs.mjs +31 -11
- package/lib/app-ready.mjs +49 -0
- package/lib/check.mjs +51 -35
- package/lib/dev-stripe-fixture.mjs +37 -0
- package/lib/dev-stripe-sandbox.mjs +80 -0
- package/lib/dev.mjs +19 -17
- package/lib/events-cli.mjs +5 -1
- package/lib/local-test.mjs +18 -0
- package/lib/mcp-tools.mjs +15 -1
- package/lib/office.mjs +1 -1
- package/lib/scaffold.mjs +22 -1
- package/lib/shop-links.mjs +79 -7
- package/lib/shop.mjs +4 -2
- package/lib/stripe-connect.mjs +1 -0
- package/lib/template-history.json +84 -0
- package/lib/tools-cli.mjs +3 -0
- package/package.json +1 -1
- package/shop/SELLING.md +2 -2
- package/shop/SHOP.md +120 -5
- package/shop/shop.ts +29 -0
- package/tools/tools.ts +8 -1
- package/worker/app-format.mjs +8 -1
- package/worker/app-frame.mjs +9 -0
- package/worker/app-records.mjs +39 -10
- package/worker/booking-store.mjs +79 -0
- package/worker/build-id.mjs +1 -1
- package/worker/event-resources.mjs +30 -3
- package/worker/functions.mjs +5 -2
- package/worker/index.mjs +17 -3
- package/worker/local-test.mjs +19 -0
- package/worker/mcp-tools.mjs +19 -8
- package/worker/mcp.mjs +23 -11
- package/worker/records-page.mjs +13 -0
- package/worker/shop-links.mjs +3 -2
- package/worker/shop-page.mjs +27 -5
- package/worker/shop-rules.mjs +2 -1
- package/worker/shop-store.mjs +1 -1
- package/worker/shop-subscriptions.mjs +198 -0
- package/worker/shop.mjs +195 -29
- package/worker/tool-database.mjs +36 -0
- package/worker/version.mjs +1 -1
package/shop/SHOP.md
CHANGED
|
@@ -108,7 +108,7 @@ If the studio is not deployed yet, approval is retained: deploy with `studio_dep
|
|
|
108
108
|
`cloudflare_login` first if needed), then rerun connect. Webhooks require the deployed HTTPS address.
|
|
109
109
|
|
|
110
110
|
**The default is keyless.** Connect syncs one Product and Price for each shop.json item and one
|
|
111
|
-
Payment Link for each paid item. Tip Prices use custom amounts; free orders stay local. It creates
|
|
111
|
+
Payment Link for each paid item, adjustable-quantity menus, and one capacity-limited link per published booking slot. Tip Prices use custom amounts; free orders stay local. It creates
|
|
112
112
|
an account webhook at `<site>/api/shop/hook`, captures its signing secret without displaying it,
|
|
113
113
|
and installs `STRIPE_WEBHOOK_SECRET` and `STRIPE_SHOP_LINKS` through Wrangler stdin. No Stripe
|
|
114
114
|
API key goes into the Worker. Repeat connect after editing shop.json: changed prices get new
|
|
@@ -132,7 +132,7 @@ items; failed refunds restore the financial booking. A lost dispute revokes good
|
|
|
132
132
|
|
|
133
133
|
| Need | Keyless behavior / fuller connection |
|
|
134
134
|
|---|---|
|
|
135
|
-
| Several different items in one payment |
|
|
135
|
+
| Several different items in one payment | A synced menu opens several fixed-price lines; guests choose quantities on Stripe, including zero. Stripe limits Payment Links to 20 lines; larger catalogues use separate menus. Custom amounts and subscriptions keep separate links. |
|
|
136
136
|
| Reserve an optional spending allowance | Requires keyed Checkout; a reusable public link cannot atomically reserve this studio's local cap. No silent weakening of a configured cap. |
|
|
137
137
|
| Expire an abandoned session | Stripe controls the lifetime; deactivating a link prevents new sessions, not existing sessions. Custom expiry/cancellation requires the keyed path. |
|
|
138
138
|
| Refund in one tap / player self-refund | Owner uses Stripe Dashboard or asks their Stripe-connected AI. Signed events update books. Office shows guidance instead of a paid-order refund button. Free refunds remain local. |
|
|
@@ -142,8 +142,7 @@ items; failed refunds restore the financial booking. A lost dispute revokes good
|
|
|
142
142
|
| Managed Payments | Payment Links support managed_payments.enabled; connect uses it when the studio selects Stripe as seller, subject to Stripe eligibility and terms. |
|
|
143
143
|
| Currency display | Payment Links always enable Stripe Adaptive Pricing. Bookkeeping uses integration currency; presentment details describe what the customer saw. |
|
|
144
144
|
|
|
145
|
-
|
|
146
|
-
“The fuller connection adds combined carts, spending limits, automatic recovery and refunds here.”
|
|
145
|
+
A key alone never selects the fuller path. It requires explicit `STRIPE_CHECKOUT_MODE=sessions` as well as a Worker key. Only if requested, explain: “The optional session connection adds exact app-built carts, spending reservations, expiring holds and runtime refunds.”
|
|
147
146
|
The explicit `shop connect --manual` path remains available, including `--live`, but requires the
|
|
148
147
|
owner's existing Dashboard key step; it does not satisfy one approval. Never choose it automatically.
|
|
149
148
|
Running default connect again saves links, removes the API key, and keeps signed-event fulfillment.
|
|
@@ -202,7 +201,7 @@ the full amount toward a cap. The additive schema preserves its original rows an
|
|
|
202
201
|
|
|
203
202
|
## Shop button on the screen
|
|
204
203
|
|
|
205
|
-
An open shop with items for this game or app shows **Shop** above its play/open screen. The shell reserves 52 pixels plus the safe area above the content, so the button never covers its controls. In `game.json` or `app.json`, set `"screen": { "shop": false }` to
|
|
204
|
+
An open shop with items for this game or app shows **Shop** above its play/open screen. The shell reserves 52 pixels plus the safe area above the content, so the button never covers its controls. In `game.json` or `app.json`, set `"screen": { "shop": false }` to disable the experience’s shop (merge with any existing screen settings). Keep it true or omitted when your app uses its own `createShop` buttons; false also refuses their checkout bridge requests. Closed shops, kids policies and an empty selection show no button.
|
|
206
205
|
|
|
207
206
|
```ts
|
|
208
207
|
import { createShop } from '@homie-rocks/studio/shop';
|
|
@@ -218,3 +217,119 @@ These calls open the same shop in games and apps. Inside a player card, purchase
|
|
|
218
217
|
Create the booking/ticket/gift intention first. `shop.buy('full-tune', 1, undefined, { reference: booking.id })` or `shop.checkout({ reference: booking.id })` sends the app's own reference through checkout. Existing boolean returns are unchanged: checkout opened is not paid. The verified durable `order.paid` event carries `data.order`, `data.reference` (null if omitted) and `data.app`. Reopened Payment Links retain the reference. A reference is buyer-supplied correlation, not authorization: verify the record, app, amount and currency, deduplicate by event/order ID, then fulfil it. Keep personal data out of references. Before Stripe is connected, local prices and unpaid requests can be demonstrated; actual payment and recurring collection remain unverified. Test events must be labelled simulations.
|
|
219
218
|
|
|
220
219
|
For referrals, use Stripe Connect to split payments or a Stripe-based affiliate service; Homie builds none.
|
|
220
|
+
|
|
221
|
+
## Keyless menus, paid seats and monthly giving (0.51.0)
|
|
222
|
+
|
|
223
|
+
Use `shop connect` by default. All objects belong to the owner's Stripe account and
|
|
224
|
+
are created through its approved CLI session. Homie has no platform key, OAuth
|
|
225
|
+
backend, payment intermediary or cut. A desktop is needed to sync, not to take money.
|
|
226
|
+
|
|
227
|
+
**A round to table 7:** declare fixed-price drinks in shop.json and run connect.
|
|
228
|
+
|
|
229
|
+
```ts
|
|
230
|
+
shop.add('negroni', 2);
|
|
231
|
+
shop.add('spritz', 1);
|
|
232
|
+
await shop.checkout({ reference: 'table-7' });
|
|
233
|
+
```
|
|
234
|
+
|
|
235
|
+
The default opens the synced Stripe menu containing those items. The guest chooses
|
|
236
|
+
final quantities **on Stripe**, starting at one for each menu line; the local cart's
|
|
237
|
+
quantities are not prefilled. Adjustable quantity minimum is zero (Stripe prevents
|
|
238
|
+
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;
|
|
239
|
+
if selected items span menus, the app receives `menu` and should offer separate
|
|
240
|
+
checkouts. Custom amounts cannot be mixed into this menu; offer a separate tip link.
|
|
241
|
+
Stripe invoice creation is enabled for menus (Stripe's invoice pricing applies).
|
|
242
|
+
Only signed paid invoice lines supply `order.paid.data.lines`, including actual
|
|
243
|
+
item, quantity and amount; no quantity is guessed from the total. Incomplete invoice
|
|
244
|
+
snapshots remain pending for recovery rather than granting a guessed order.
|
|
245
|
+
|
|
246
|
+
The opaque local order ID is `client_reference_id`; its durable reference maps back
|
|
247
|
+
to `table-7` in `order.paid.data.reference` and the buyer's order response. No personal
|
|
248
|
+
data goes in that reference. A return URL is never proof of payment.
|
|
249
|
+
|
|
250
|
+
**Book a tune; the seat is held only when paid:** declare a staff-written `slots`
|
|
251
|
+
collection with integer `capacity` and a server-managed `bookings` collection with
|
|
252
|
+
`"booking":{"slots":"slots","item":"full-tune"}`. Publish slot records, then have
|
|
253
|
+
the owner's AI write the complete manifest to `.studio/booking-slots.json` and run
|
|
254
|
+
`shop connect`, or call local MCP `stripe_bookings_sync` with the same `slots` array:
|
|
255
|
+
|
|
256
|
+
```json
|
|
257
|
+
[{"app":"tune","collection":"bookings","slots":"slots","slot":"sat-0900","item":"full-tune","capacity":1}]
|
|
258
|
+
```
|
|
259
|
+
|
|
260
|
+
Sync after publishing or changing slots. Removed or inactive slots deactivate their
|
|
261
|
+
links. Each link has `restrictions.completed_sessions.limit = capacity`, with one
|
|
262
|
+
fixed service and one seat per checkout. Re-sync preserves Stripe's lifetime completed
|
|
263
|
+
count. Free slots stay local and use separate slot records from paid links. Never increment capacity just to hide a full-link error.
|
|
264
|
+
Refunds do not decrement Stripe's completed-session counter; the owner's AI must
|
|
265
|
+
review availability and resync explicitly. A changed price should use a new slot;
|
|
266
|
+
do not replace a sold link and accidentally reset its capacity counter.
|
|
267
|
+
|
|
268
|
+
```ts
|
|
269
|
+
const result = await shop.book('full-tune', {collection:'bookings',slot:'sat-0900'});
|
|
270
|
+
// result.booking is the tracking record ID.
|
|
271
|
+
```
|
|
272
|
+
|
|
273
|
+
Opening the link creates local `pending` state, best effort. It does **not** block
|
|
274
|
+
other guests. Confirmed payment creates `confirmed`; expiry/failure/refund releases
|
|
275
|
+
the local record. The app must say “Pay to secure your seat,” not promise an exclusive
|
|
276
|
+
unpaid hold. Slot reads expose `record.booking.full`, `available` and `synced` for the
|
|
277
|
+
UI; Stripe's full-link update and verified confirmations make the app show Full.
|
|
278
|
+
Events include `booking.pending`, `.confirmed`, `.released`, `.moved`; free and
|
|
279
|
+
explicit keyed bookings also use `.held`. Before staff moves, the owner's AI syncs **both affected slots** with `active:false`;
|
|
280
|
+
sync deactivates their links and expires open sessions. Wait for signed payment events,
|
|
281
|
+
then staff makes the versioned `{slot:...}` update. Moving into an unpaused paid target
|
|
282
|
+
is refused. Read fresh confirmed booking counts and sync each affected slot with
|
|
283
|
+
`confirmed:<count>` and `active:true`. Sync adjusts the lifetime completion budget to
|
|
284
|
+
`Stripe completed + capacity - confirmed` (initial publication uses capacity exactly).
|
|
285
|
+
This also reopens refunded availability after an explicit review. Never use a stale
|
|
286
|
+
confirmed count or skip pausing: local staff movement cannot mutate Stripe by itself.
|
|
287
|
+
An explicit keyed session connection retains exclusive expiring unpaid holds.
|
|
288
|
+
|
|
289
|
+
**Give $20 monthly:** `shop init --giving --currency CAD` includes fixed suggested
|
|
290
|
+
monthly gifts. Declare a recurring item, then run connect:
|
|
291
|
+
|
|
292
|
+
```json
|
|
293
|
+
{"id":"monthly-gift","kind":"donation","name":"Give $20 monthly","price":2000,"interval":"month","gives":[]}
|
|
294
|
+
```
|
|
295
|
+
|
|
296
|
+
`shop.buy('monthly-gift',1,undefined,{reference:'gift-20'})` opens a recurring Price
|
|
297
|
+
on a Payment Link (subscription mode). The giver chooses among the preset options.
|
|
298
|
+
Arbitrary custom monthly amounts require the explicitly selected keyed connection;
|
|
299
|
+
never silently request it or ask the person to find/paste a key. One-time custom
|
|
300
|
+
amounts remain keyless. `giving.receiptText` is the owner's receipt wording.
|
|
301
|
+
|
|
302
|
+
Signed `subscription.started`, `.renewed`, `.failed`, `.cancelled` events drive
|
|
303
|
+
membership and giving. Membership access ends at the paid invoice line's period end;
|
|
304
|
+
failed invoices cannot extend it and cancellation ends access. Unknown Prices grant
|
|
305
|
+
nothing. Keyless portal configuration supports cancellation, payment-method updates
|
|
306
|
+
and invoice history. `shop connect` enables `login_page.enabled` through the CLI API
|
|
307
|
+
and stores Stripe's no-code login URL; **no Dashboard toggle is required**. Givers
|
|
308
|
+
verify their email with Stripe to manage/cancel. The buyer-owned `/api/shop/portal`
|
|
309
|
+
returns this URL. Plan changes require deliberate portal configuration and fulfilment
|
|
310
|
+
mapping; the optional keyed path retains current-provider reconciliation for upgrades.
|
|
311
|
+
|
|
312
|
+
For offline development, import `startStripeFixture` from the installed toolkit's
|
|
313
|
+
`lib/dev-stripe-fixture.mjs`, start it with `{origin:'http://127.0.0.1:8787'}`, and
|
|
314
|
+
write its `bindings` as local Wrangler `.dev.vars` in a disposable studio. Keep it
|
|
315
|
+
running alongside `homie-studio dev`. The checkout page explicitly says simulation;
|
|
316
|
+
it takes no card and no money. Call `close()` and delete the disposable studio when
|
|
317
|
+
finished. This tests signed webhook plumbing, not Stripe account permissions.
|
|
318
|
+
|
|
319
|
+
The giving starter now reads the actual shop donation options with `shop.catalog()`.
|
|
320
|
+
`shop init --giving --currency CAD` includes $20 monthly; `app new giving --from giving`
|
|
321
|
+
opens Checkout for the selected amount and frequency. A disconnected shop displays
|
|
322
|
+
a disabled preview. No intention record or unverified collected total is created.
|
|
323
|
+
Use the Manage my gifts link for the buyer-owned Stripe portal in the shop.
|
|
324
|
+
|
|
325
|
+
For real sandbox acceptance without copying any API key, import `startKeylessStripeSandbox`
|
|
326
|
+
from `lib/dev-stripe-sandbox.mjs` and pass `{origin, profile, shop, bookingSlots}` with an explicit
|
|
327
|
+
sandbox CLI profile and loopback studio origin. It drives only that profile's
|
|
328
|
+
Stripe CLI, starts `stripe listen --forward-to` with the required events, and
|
|
329
|
+
returns local dev `bindings` and `close()`. Keep bindings private: the webhook
|
|
330
|
+
signing secret is real even in test mode. The keyless helper encodes link configuration for safe .dev.vars transport; do not decode or rewrite that binding. Use the helper’s `links` property for native link IDs when testing. `await helper.sync(manifest)` updates its `links` and `bindings`; save the new bindings and restart dev after a slot sync. `close()` deactivates its sandbox links and stops forwarding. Supply them to the local dev harness
|
|
331
|
+
only, never source control or logs. The local webhook forwarding listener needs the desktop; production uses the signed public webhook directly. No API adapter or key is installed in the keyless Worker. It never exports CLI credentials.
|
|
332
|
+
Use Stripe's published CLI test fixtures/tokens to complete Checkout via API;
|
|
333
|
+
never type card details into a form. Stop on an expired profile and ask the owner
|
|
334
|
+
to approve `stripe login --project-name <profile>` once. Stop the adapter and remove
|
|
335
|
+
dev bindings and disposable studios when finished.
|
package/shop/shop.ts
CHANGED
|
@@ -44,10 +44,19 @@ export interface ShopOptions {
|
|
|
44
44
|
|
|
45
45
|
export interface CartLine { item: string; quantity: number; amount?: number }
|
|
46
46
|
|
|
47
|
+
export interface ShopCatalog {
|
|
48
|
+
open: boolean;
|
|
49
|
+
currency: string;
|
|
50
|
+
currencyScale: number;
|
|
51
|
+
items: Array<{ id: string; kind: string; name: string; interval?: 'month'; price: number | 'choose'; min?: number; max?: number | null; shown: string; way: string }>;
|
|
52
|
+
}
|
|
53
|
+
|
|
47
54
|
export interface Shop {
|
|
48
55
|
/** Resolves once the shell answered (or after `timeoutMs` with no shell: an empty, closed shop). */
|
|
49
56
|
readonly ready: Promise<ShopState>;
|
|
50
57
|
readonly state: ShopState;
|
|
58
|
+
/** Current gift/item options and payment readiness, without exposing account details. */
|
|
59
|
+
catalog(): Promise<ShopCatalog>;
|
|
51
60
|
/** Whether the player owns this entitlement key now ("skin:ember", "badge:supporter"). Synchronous, for a render loop. */
|
|
52
61
|
has(key: string): boolean;
|
|
53
62
|
/** Every key the player owns now. */
|
|
@@ -61,6 +70,8 @@ export interface Shop {
|
|
|
61
70
|
add(item: string, quantity?: number, amount?: number): void;
|
|
62
71
|
cart(): CartLine[];
|
|
63
72
|
clear(): void;
|
|
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 }>;
|
|
64
75
|
checkout(options?: { reference?: string }): Promise<boolean>;
|
|
65
76
|
buy(item: string, quantity?: number, amount?: number, options?: { reference?: string }): Promise<boolean>;
|
|
66
77
|
/** Mark an item used; the studio's refundUsedItems policy decides whether self-service refunds still apply. */
|
|
@@ -121,6 +132,20 @@ export function createShop(opts: ShopOptions = {}): Shop {
|
|
|
121
132
|
let cart: CartLine[] = [];
|
|
122
133
|
const shop: Shop = {
|
|
123
134
|
ready,
|
|
135
|
+
catalog: async () => {
|
|
136
|
+
const result: ShopCatalog = { open: false, currency: 'usd', currencyScale: 100, items: [] };
|
|
137
|
+
let cursor: string | undefined;
|
|
138
|
+
do {
|
|
139
|
+
const m = await send({ op: 'catalog', cursor });
|
|
140
|
+
if (!m.ok) return result;
|
|
141
|
+
result.open = Boolean(m.open);
|
|
142
|
+
result.currency = String(m.currency ?? 'usd');
|
|
143
|
+
result.currencyScale = Number(m.currencyScale) || 100;
|
|
144
|
+
if (Array.isArray(m.items)) result.items.push(...m.items as ShopCatalog['items']);
|
|
145
|
+
cursor = typeof m.nextCursor === 'string' ? m.nextCursor : undefined;
|
|
146
|
+
} while (cursor);
|
|
147
|
+
return result;
|
|
148
|
+
},
|
|
124
149
|
get state() { return { ...state }; },
|
|
125
150
|
has: (key) => owns.includes(String(key)),
|
|
126
151
|
entitlements: () => owns.slice(),
|
|
@@ -129,6 +154,10 @@ export function createShop(opts: ShopOptions = {}): Shop {
|
|
|
129
154
|
add: (item, quantity = 1, amount) => { cart.push({ item, quantity, ...(amount === undefined ? {} : { amount }) }); },
|
|
130
155
|
cart: () => cart.map((line) => ({ ...line })),
|
|
131
156
|
clear: () => { cart = []; },
|
|
157
|
+
book: async (item, booking, quantity = 1) => {
|
|
158
|
+
const r = await send({ op: 'checkout', booking, lines: [{ item, quantity }] });
|
|
159
|
+
return { ok: Boolean(r.ok), ...(typeof r.booking === 'string' ? { booking: r.booking } : {}), ...(typeof r.message === 'string' ? { error: r.message } : {}) };
|
|
160
|
+
},
|
|
132
161
|
checkout: async (options = {}) => { const ok = Boolean((await send({ op: 'checkout', lines: cart, reference: options.reference })).ok); if (ok) cart = []; return ok; },
|
|
133
162
|
buy: async (item, quantity = 1, amount, options = {}) => Boolean((await send({ op: 'checkout', reference: options.reference, lines: [{ item, quantity, ...(amount === undefined ? {} : { amount }) }] })).ok),
|
|
134
163
|
close: () => { void send({ op: 'close' }); },
|
package/tools/tools.ts
CHANGED
|
@@ -7,7 +7,14 @@ export interface ToolContext {
|
|
|
7
7
|
office(path: string, body?: Record<string, unknown>, query?: Record<string, string>): Promise<unknown>;
|
|
8
8
|
shop(): Promise<unknown>;
|
|
9
9
|
rooms: { send(event: {game: string; room: string; event: string; data: unknown}): Promise<unknown> };
|
|
10
|
-
database: {
|
|
10
|
+
database: {
|
|
11
|
+
get(key: string): Promise<unknown>;
|
|
12
|
+
getVersioned(key: string): Promise<{value: unknown; version: number} | null>;
|
|
13
|
+
put(key: string, value: unknown, options?: {version?: number}): Promise<{ok: boolean; version?: number; conflict?: boolean}>;
|
|
14
|
+
increment(key: string, amount?: number): Promise<{value: number; version: number}>;
|
|
15
|
+
append(key: string, value: unknown): Promise<{value: unknown[]; version: number}>;
|
|
16
|
+
list(options?: {prefix?: string; after?: string; limit?: number}): Promise<{items: {key: string; value: unknown; version: number}[]; next: string | null}>;
|
|
17
|
+
};
|
|
11
18
|
fetch: typeof fetch;
|
|
12
19
|
fetchWithSecret(name: string, url: string, init?: RequestInit): Promise<Response>;
|
|
13
20
|
ai(model: string, input: unknown): Promise<unknown>;
|
package/worker/app-format.mjs
CHANGED
|
@@ -18,7 +18,13 @@ export function appProblems(meta) {
|
|
|
18
18
|
if (!object(meta.records) || typeof meta.records.persist !== 'boolean' || !object(meta.records.collections)) bad.push('records declares persist and collections');
|
|
19
19
|
for (const [id, c] of Object.entries(collections)) {
|
|
20
20
|
if (!key.test(id) || !object(c) || !object(c.fields)) { bad.push(`collection ${id}: declare fields`); continue; }
|
|
21
|
-
for (const [field, spec] of Object.entries(c.fields)) if (
|
|
21
|
+
for (const [field, spec] of Object.entries(c.fields)) if (!/^[a-z][a-z0-9_]*$/.test(field) || !object(spec) || !['string', 'number', 'boolean'].includes(spec.type) || (spec.enum && (!Array.isArray(spec.enum) || !spec.enum.length))) bad.push(`collection ${id}.${field}: invalid field: names start with a lowercase letter and use lowercase letters, digits and underscore; declare a string, number or boolean type`);
|
|
22
|
+
}
|
|
23
|
+
for (const [id, c] of Object.entries(collections)) if (c.booking) {
|
|
24
|
+
const spec = c.booking;
|
|
25
|
+
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`);
|
|
26
|
+
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`);
|
|
22
28
|
}
|
|
23
29
|
for (const r of Object.values(roles)) for (const cap of Array.isArray(r?.can) ? r.can : []) if (!Object.hasOwn(meta.records?.collections ?? {}, String(cap).split(':')[1])) bad.push(`unknown collection in ${cap}`);
|
|
24
30
|
for (const [k, v] of Object.entries(meta.words ?? {})) if (!/^[a-z][a-zA-Z0-9-]*$/.test(k) || typeof v !== 'string' || v.length > 500) bad.push(`words.${k}: use a short label`);
|
|
@@ -32,6 +38,7 @@ export function appRole(meta, url) {
|
|
|
32
38
|
}
|
|
33
39
|
export function recordProblem(collection, data, { create = false } = {}) {
|
|
34
40
|
if (!object(data) || JSON.stringify(data).length > 8192) return 'record must be an object below 8 KB';
|
|
41
|
+
for (const k of Object.keys(data)) if (!/^[a-z][a-z0-9_]*$/.test(k)) return `invalid field ${k}: names start with a lowercase letter and use lowercase letters, digits and underscore`;
|
|
35
42
|
for (const k of Object.keys(data)) if (!Object.hasOwn(collection.fields, k)) return `unknown field ${k}`;
|
|
36
43
|
for (const [k, s] of Object.entries(collection.fields)) {
|
|
37
44
|
const v = data[k];
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
/** Opaque frames retain their isolation. Unavailable Web Storage falls back to frame-local memory. */
|
|
2
|
+
export const APP_STORAGE_JS=String.raw`(()=>{
|
|
3
|
+
for(const name of ['localStorage','sessionStorage']) {
|
|
4
|
+
try {const storage=window[name];const probe='homie-storage-probe';storage.setItem(probe,'1');storage.removeItem(probe);continue;}catch{}
|
|
5
|
+
const data=new Map();const methods={getItem:k=>data.get(String(k))??null,setItem:(k,v)=>{data.set(String(k),String(v));},removeItem:k=>{data.delete(String(k));},clear:()=>data.clear(),key:i=>[...data.keys()][Number(i)]??null};
|
|
6
|
+
const storage=new Proxy(methods,{get:(target,key)=>key==='length'?data.size:key in target?target[key]:data.get(String(key)),set:(_,key,value)=>{data.set(String(key),String(value));return true;},deleteProperty:(_,key)=>data.delete(String(key)),ownKeys:()=>[...data.keys()],getOwnPropertyDescriptor:()=>({enumerable:true,configurable:true})});
|
|
7
|
+
Object.defineProperty(window,name,{value:storage,configurable:true});
|
|
8
|
+
}
|
|
9
|
+
})();`;
|
package/worker/app-records.mjs
CHANGED
|
@@ -1,3 +1,6 @@
|
|
|
1
|
+
import { linkConfig } from './shop-links.mjs';
|
|
2
|
+
import {localSession} from './local-test.mjs';
|
|
3
|
+
import { moveBooking } from './booking-store.mjs';
|
|
1
4
|
import { toolIdentity } from './tool-identity.mjs';
|
|
2
5
|
/** Lasting app records in the studio's own D1. Room hosts never authorize writes. */
|
|
3
6
|
import { appRole, recordProblem } from './app-format.mjs';
|
|
@@ -38,12 +41,14 @@ export async function appAccess(request, env, meta, url) {
|
|
|
38
41
|
const declared = Object.hasOwn(meta.roles ?? {}, role) ? meta.roles[role] : null;
|
|
39
42
|
if (!declared) return { ok: false, status: 404, error: 'unknown role' };
|
|
40
43
|
if (!declared.signIn) return { ok: true, role, can: declared.can };
|
|
44
|
+
const session = await localSession(request, env);
|
|
45
|
+
if (session?.app === meta.id && session.role === role) return {ok:true,role,can:declared.can};
|
|
41
46
|
if (!env.DB) return { ok: false, status: 503, error: 'app roles need the studio database' };
|
|
42
47
|
const caller = toolIdentity(request);
|
|
43
48
|
if (caller) {
|
|
44
49
|
if (caller.owner) return { ok: true, role, can: declared.can };
|
|
45
50
|
if (!caller.id) return { ok: false, status: 401, error: 'sign in to this studio' };
|
|
46
|
-
const grant = await env.DB.prepare(
|
|
51
|
+
const grant = await env.DB.prepare("SELECT player FROM app_roles WHERE app IN (?1, '*') AND role = ?2 AND player = ?3").bind(meta.id, role, caller.id).first();
|
|
47
52
|
return grant ? { ok: true, role, can: declared.can } : { ok: false, status: 403, error: 'this account has no role grant' };
|
|
48
53
|
}
|
|
49
54
|
const link = await env.DB.prepare('SELECT token FROM app_role_links WHERE app = ?1 AND role = ?2').bind(meta.id, role).first();
|
|
@@ -51,7 +56,7 @@ export async function appAccess(request, env, meta, url) {
|
|
|
51
56
|
if (await isOwner(request, env)) return { ok: true, role, can: declared.can };
|
|
52
57
|
const person = await players.of(request, env);
|
|
53
58
|
if (!person || person.guest) return { ok: false, status: 401, error: 'sign in to this studio' };
|
|
54
|
-
const grant = await env.DB.prepare(
|
|
59
|
+
const grant = await env.DB.prepare("SELECT player FROM app_roles WHERE app IN (?1, '*') AND role = ?2 AND player = ?3").bind(meta.id, role, person.id).first();
|
|
55
60
|
return grant ? { ok: true, role, can: declared.can } : { ok: false, status: 403, error: 'this account has no role grant' };
|
|
56
61
|
}
|
|
57
62
|
export async function appRecordsRoute(request, env, meta, url, sub) {
|
|
@@ -69,39 +74,63 @@ export async function appRecordsRoute(request, env, meta, url, sub) {
|
|
|
69
74
|
if (!ID.test(b.player)) return fail(400, 'invalid account');
|
|
70
75
|
const p = await players.get(b.player, env);
|
|
71
76
|
if (!p || p.guest) return fail(400, 'role grants require an existing signed-in account');
|
|
72
|
-
await env.DB.prepare(b.revoke === true ? 'DELETE FROM app_roles WHERE app = ?1 AND role = ?2 AND player = ?3' : 'INSERT OR IGNORE INTO app_roles (app, role, player) VALUES (?1, ?2, ?3)').bind(meta.id, b.role, b.player).run();
|
|
77
|
+
await env.DB.prepare(b.revoke === true ? 'DELETE FROM app_roles WHERE app = ?1 AND role = ?2 AND player = ?3' : 'INSERT OR IGNORE INTO app_roles (app, role, player) VALUES (?1, ?2, ?3)').bind(b.studio === true ? '*' : meta.id, b.role, b.player).run();
|
|
73
78
|
}
|
|
74
79
|
const token = crypto.randomUUID().replaceAll('-', '');
|
|
75
80
|
if (b.rotate === true) await env.DB.prepare('DELETE FROM app_role_links WHERE app = ?1 AND role = ?2').bind(meta.id, b.role).run();
|
|
76
81
|
await env.DB.prepare('INSERT OR IGNORE INTO app_role_links (app, role, token) VALUES (?1, ?2, ?3)').bind(meta.id, b.role, token).run();
|
|
77
82
|
const row = await env.DB.prepare('SELECT token FROM app_role_links WHERE app = ?1 AND role = ?2').bind(meta.id, b.role).first();
|
|
78
|
-
return json({ ok: true, link: `${url.origin}/${meta.id}/open?role=${b.role}&access=${row.token}`, role: b.role });
|
|
83
|
+
return json({ ok: true, link: `${url.origin.replace('127.0.0.1','localhost')}/${meta.id}/open?role=${b.role}&access=${row.token}`, role: b.role });
|
|
79
84
|
}
|
|
80
85
|
const match = /^api\/app\/records\/([a-z][a-z0-9-]{0,39})(?:\/([A-Za-z0-9_-]{1,64}))?$/.exec(sub);
|
|
86
|
+
if(!match && /^api\/app\/records\/[^/]+\//.test(sub))return fail(400,'record id: 1–64 letters, digits, underscore or hyphen');
|
|
81
87
|
if (!match || !Object.hasOwn(meta.records?.collections ?? {}, match[1])) return fail(404, 'unknown collection');
|
|
82
88
|
if (meta.records.persist !== true) return fail(409, 'use netplay state for transient records, or enable records.persist');
|
|
83
89
|
const access = await appAccess(request, env, meta, url);
|
|
84
90
|
if (!access.ok) return fail(access.status, access.error);
|
|
85
91
|
const [, collection, id] = match;
|
|
92
|
+
const session = await localSession(request, env);
|
|
93
|
+
const testScope = (session?.app === meta.id ? session.scope : null) ?? (env.HOMIE_LOCAL_TEST_SECRET ? toolIdentity(request)?.testScope : null);
|
|
94
|
+
const recordApp = testScope ? `${meta.id}:check:${testScope}` : meta.id;
|
|
86
95
|
const op = ({ GET: 'read', POST: 'create', PUT: 'update', DELETE: 'delete' })[request.method];
|
|
87
96
|
if (!op || !access.can.includes(`${op}:${collection}`)) return fail(403, 'role cannot perform this operation');
|
|
88
97
|
if (op !== 'read' && !sameOrigin(request, url) && !(appOriginOf(request) && !meta.roles[access.role].signIn)) return fail(403, 'same origin required');
|
|
89
98
|
if (op === 'read') {
|
|
90
|
-
const rows = id ? [await env.DB.prepare('SELECT id, value, version, updated_at FROM app_records WHERE app = ?1 AND collection = ?2 AND id = ?3').bind(
|
|
91
|
-
: (await env.DB.prepare('SELECT id, value, version, updated_at FROM app_records WHERE app = ?1 AND collection = ?2 ORDER BY updated_at, id LIMIT 1000').bind(
|
|
92
|
-
|
|
99
|
+
const rows = id ? [await env.DB.prepare('SELECT id, value, version, updated_at FROM app_records WHERE app = ?1 AND collection = ?2 AND id = ?3').bind(recordApp, collection, id).first()].filter(Boolean)
|
|
100
|
+
: (await env.DB.prepare('SELECT id, value, version, updated_at FROM app_records WHERE app = ?1 AND collection = ?2 ORDER BY updated_at, id LIMIT 1000').bind(recordApp, collection).all()).results;
|
|
101
|
+
const records=[];
|
|
102
|
+
for(const r of rows) {
|
|
103
|
+
const record={id:r.id,data:JSON.parse(r.value),version:r.version,updatedAt:r.updated_at};
|
|
104
|
+
const link=linkConfig(env)?.slots?.find(l=>l.app===meta.id&&l.slots===collection&&l.slot===r.id);
|
|
105
|
+
if(link) {
|
|
106
|
+
const saved=await env.DB.prepare('SELECT value FROM meta WHERE key=?1').bind(`shop-slot-link:${link.id}`).first();
|
|
107
|
+
const observed=saved?JSON.parse(saved.value):null;
|
|
108
|
+
const status=observed && observed.capacity===(link.limit??link.capacity) ? observed : link;
|
|
109
|
+
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();
|
|
110
|
+
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};
|
|
111
|
+
}
|
|
112
|
+
records.push(record);
|
|
113
|
+
}
|
|
114
|
+
return json({ok:true,records});
|
|
93
115
|
}
|
|
94
116
|
if (!id) return fail(400, 'record id required');
|
|
95
117
|
const body = await bodyOf(request);
|
|
118
|
+
if (meta.records.collections[collection].booking) {
|
|
119
|
+
if (op !== 'update' || !meta.roles[access.role].signIn) return fail(403, 'bookings are created and released through checkout');
|
|
120
|
+
if (!Number.isSafeInteger(body.version) || !ID.test(body.data?.slot ?? '') || Object.keys(body.data ?? {}).some(k => k !== 'slot')) return fail(400, 'move requires slot and current version');
|
|
121
|
+
const moved = await moveBooking(env, { app: recordApp, collection, id, slot: body.data.slot, version: body.version });
|
|
122
|
+
return moved ? json({ ok: true, id, version: body.version + 1 }) : fail(409, 'booking changed or is released; reload');
|
|
123
|
+
}
|
|
96
124
|
if (op !== 'delete') { const bad = recordProblem(meta.records.collections[collection], body.data, { create: op === 'create' }); if (bad) return fail(400, bad); }
|
|
97
125
|
if (op !== 'create' && (!Number.isSafeInteger(body.version) || body.version < 1)) return fail(400, 'record version required');
|
|
98
126
|
const stmt = op === 'create'
|
|
99
|
-
? env.DB.prepare('INSERT OR IGNORE INTO app_records (app, collection, id, value, version, updated_at) SELECT ?1, ?2, ?3, ?4, 1, ?5 WHERE (SELECT COUNT(*) FROM app_records WHERE app = ?1 AND collection = ?2) < 1000').bind(
|
|
100
|
-
: op === 'update' ? env.DB.prepare('UPDATE app_records SET value = ?4, version = version + 1, updated_at = ?5 WHERE app = ?1 AND collection = ?2 AND id = ?3 AND version = ?6').bind(
|
|
101
|
-
: env.DB.prepare('DELETE FROM app_records WHERE app = ?1 AND collection = ?2 AND id = ?3 AND version = ?4').bind(
|
|
127
|
+
? env.DB.prepare('INSERT OR IGNORE INTO app_records (app, collection, id, value, version, updated_at) SELECT ?1, ?2, ?3, ?4, 1, ?5 WHERE (SELECT COUNT(*) FROM app_records WHERE app = ?1 AND collection = ?2) < 1000').bind(recordApp, collection, id, JSON.stringify(body.data), Date.now())
|
|
128
|
+
: 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)
|
|
129
|
+
: env.DB.prepare('DELETE FROM app_records WHERE app = ?1 AND collection = ?2 AND id = ?3 AND version = ?4').bind(recordApp, collection, id, body.version);
|
|
102
130
|
const result = await stmt.run();
|
|
103
131
|
return result.meta.changes ? json({ ok: true, id, version: op === 'create' ? 1 : body.version + 1 }) : fail(409, 'record changed, already exists, or collection full; reload');
|
|
104
132
|
} catch (error) {
|
|
133
|
+
if (/booking capacity|capacity below|slot has active/.test(error.message)) return fail(409, error.message);
|
|
105
134
|
if (error instanceof SyntaxError || error.message === 'body too large') return fail(400, error.message);
|
|
106
135
|
return fail(503, 'app database unavailable; apply studio migrations');
|
|
107
136
|
}
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
import { linkConfig, hasStripeKey } from './shop-links.mjs';
|
|
2
|
+
/** Capacity belongs to the studio database. Checkout expiry is a provider fact,
|
|
3
|
+
* never an inference from a browser return or the local clock. */
|
|
4
|
+
export const BOOKINGS_MIGRATION_FILE = '0025_bookings.sql';
|
|
5
|
+
export const BOOKINGS_MIGRATION = `
|
|
6
|
+
CREATE TABLE shop_bookings (
|
|
7
|
+
id TEXT PRIMARY KEY, app TEXT NOT NULL, collection TEXT NOT NULL,
|
|
8
|
+
slots TEXT NOT NULL, slot TEXT NOT NULL, quantity INTEGER NOT NULL CHECK(quantity > 0),
|
|
9
|
+
order_id TEXT NOT NULL UNIQUE, state TEXT NOT NULL DEFAULT 'held'
|
|
10
|
+
CHECK(state IN ('pending','held','confirmed','released')),
|
|
11
|
+
version INTEGER NOT NULL DEFAULT 1, updated_at INTEGER NOT NULL
|
|
12
|
+
);
|
|
13
|
+
CREATE INDEX shop_bookings_capacity ON shop_bookings(app, slots, slot, state);
|
|
14
|
+
CREATE TRIGGER booking_capacity_insert BEFORE INSERT ON shop_bookings WHEN NEW.state != 'pending' 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 state IN ('held','confirmed'))
|
|
19
|
+
);
|
|
20
|
+
END;
|
|
21
|
+
CREATE TRIGGER booking_capacity_move BEFORE UPDATE OF slot ON shop_bookings
|
|
22
|
+
WHEN NEW.state IN ('held','confirmed') BEGIN
|
|
23
|
+
SELECT RAISE(ABORT,'booking capacity unavailable') WHERE NOT EXISTS (
|
|
24
|
+
SELECT 1 FROM app_records s WHERE s.app=NEW.app AND s.collection=NEW.slots AND s.id=NEW.slot
|
|
25
|
+
AND json_type(s.value,'$.capacity')='integer' AND json_extract(s.value,'$.capacity') >= NEW.quantity +
|
|
26
|
+
(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'))
|
|
27
|
+
);
|
|
28
|
+
END;
|
|
29
|
+
CREATE TRIGGER booking_slot_update BEFORE UPDATE OF value ON app_records
|
|
30
|
+
WHEN EXISTS (SELECT 1 FROM shop_bookings WHERE app=OLD.app AND slots=OLD.collection AND slot=OLD.id AND state IN ('held','confirmed')) BEGIN
|
|
31
|
+
SELECT RAISE(ABORT,'capacity below existing bookings') WHERE json_type(NEW.value,'$.capacity') IS NOT 'integer' OR json_extract(NEW.value,'$.capacity') <
|
|
32
|
+
(SELECT SUM(quantity) FROM shop_bookings WHERE app=OLD.app AND slots=OLD.collection AND slot=OLD.id AND state IN ('held','confirmed'))
|
|
33
|
+
;
|
|
34
|
+
END;
|
|
35
|
+
CREATE TRIGGER booking_slot_delete BEFORE DELETE ON app_records
|
|
36
|
+
WHEN EXISTS (SELECT 1 FROM shop_bookings WHERE app=OLD.app AND slots=OLD.collection AND slot=OLD.id AND state IN ('held','confirmed')) BEGIN
|
|
37
|
+
SELECT RAISE(ABORT,'slot has active bookings');
|
|
38
|
+
END;
|
|
39
|
+
CREATE TRIGGER booking_order_state AFTER UPDATE OF status ON shop_orders BEGIN
|
|
40
|
+
UPDATE shop_bookings SET state=CASE WHEN NEW.status='paid' THEN 'confirmed' ELSE 'released' END,
|
|
41
|
+
version=version+1, updated_at=NEW.updated_at
|
|
42
|
+
WHERE order_id=NEW.id AND ((state IN ('pending','held') AND NEW.status IN ('paid','expired','failed')) OR (state IN ('pending','held','confirmed') AND NEW.status IN ('refunded','lost')));
|
|
43
|
+
END;
|
|
44
|
+
${['INSERT', 'UPDATE'].map(op => `CREATE TRIGGER booking_record_${op.toLowerCase()} AFTER ${op} ON shop_bookings BEGIN
|
|
45
|
+
INSERT INTO app_records(app,collection,id,value,version,updated_at)
|
|
46
|
+
VALUES(NEW.app,NEW.collection,NEW.id,json_object('slot',NEW.slot,'quantity',NEW.quantity,'state',NEW.state),NEW.version,NEW.updated_at)
|
|
47
|
+
ON CONFLICT(app,collection,id) DO UPDATE SET value=excluded.value,version=excluded.version,updated_at=excluded.updated_at;
|
|
48
|
+
INSERT OR IGNORE INTO studio_events(id,type,data,at)
|
|
49
|
+
SELECT 'booking:' || NEW.id || ':' || NEW.version,'booking.' || ${op === 'INSERT' ? "NEW.state" : "CASE WHEN OLD.slot!=NEW.slot THEN 'moved' ELSE NEW.state END"},
|
|
50
|
+
json_object('booking',NEW.id,'app',NEW.app,'collection',NEW.collection,'slot',NEW.slot,'state',NEW.state,'quantity',NEW.quantity,'order',NEW.order_id,'version',NEW.version),NEW.updated_at
|
|
51
|
+
WHERE EXISTS(SELECT 1 FROM function_subscriptions WHERE type='booking.' || ${op === 'INSERT' ? "NEW.state" : "CASE WHEN OLD.slot!=NEW.slot THEN 'moved' ELSE NEW.state END"});
|
|
52
|
+
END;`).join('\n')}
|
|
53
|
+
DROP TRIGGER IF EXISTS shop_orders_function_paid;
|
|
54
|
+
CREATE TRIGGER shop_orders_function_paid AFTER UPDATE ON shop_orders
|
|
55
|
+
WHEN EXISTS (SELECT 1 FROM function_subscriptions WHERE type='order.paid')
|
|
56
|
+
AND OLD.paid_at IS NULL AND NEW.paid_at IS NOT NULL
|
|
57
|
+
AND NOT EXISTS (SELECT 1 FROM purchase_orders WHERE id=NEW.id) BEGIN
|
|
58
|
+
INSERT OR IGNORE INTO studio_events(id,type,data,at)
|
|
59
|
+
VALUES('shop_orders:' || NEW.id || ':paid','order.paid',
|
|
60
|
+
json_object('order',NEW.id,'source','shop_orders','amount',NEW.amount,'currency',NEW.currency,'mode',NEW.mode,
|
|
61
|
+
'app',NEW.game,'reference',(SELECT value FROM meta WHERE key='shop-reference:' || NEW.id),
|
|
62
|
+
'lines',json((SELECT json_group_array(json_object('item',item,'quantity',quantity,'amount',amount,'total',total)) FROM shop_order_lines WHERE order_id=NEW.id))),NEW.paid_at);
|
|
63
|
+
END;
|
|
64
|
+
`;
|
|
65
|
+
|
|
66
|
+
export function bookingStatement(env, { id, app, collection, slots, slot, quantity, order, pending = false, now = Date.now() }) {
|
|
67
|
+
return env.DB.prepare(`INSERT INTO shop_bookings(id,app,collection,slots,slot,quantity,order_id,updated_at,state)
|
|
68
|
+
SELECT ?1,?2,?3,?4,?5,?6,?7,?8,?9 WHERE EXISTS(SELECT 1 FROM shop_orders WHERE id=?7)`).bind(id, app, collection, slots, slot, quantity, order, now, pending ? 'pending' : 'held');
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/** Staff movement uses the same version contract as app records. */
|
|
72
|
+
export async function moveBooking(env, { app, collection, id, slot, version }) {
|
|
73
|
+
const target=linkConfig(env)?.slots?.find(l=>l.app===app&&l.collection===collection&&l.slot===slot);
|
|
74
|
+
if(!hasStripeKey(env) && target && !target.paused)throw new Error('booking capacity: ask the owner’s AI to pause the target slot and expire open links before moving; then sync confirmed seat counts');
|
|
75
|
+
const result = await env.DB.prepare(`UPDATE shop_bookings SET slot=?4,version=version+1,updated_at=?6
|
|
76
|
+
WHERE app=?1 AND collection=?2 AND id=?3 AND version=?5 AND state IN ('held','confirmed')`)
|
|
77
|
+
.bind(app, collection, id, slot, version, Date.now()).run();
|
|
78
|
+
return result.meta.changes > 0;
|
|
79
|
+
}
|
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.51.0","sha256":"5a8e0df9f204fbe4978d618b507fe5ae0c5005ef4f106ac4b78dfac5f6c02b7b"});
|
|
@@ -17,7 +17,7 @@ export async function readableFeed(env,cat,caller,name) {
|
|
|
17
17
|
if(!app||!app.records?.collections?.[feed.collection])return false;
|
|
18
18
|
if(caller.owner)return true;
|
|
19
19
|
if(launchOf(app,await settingsOf(env,{fresh:true}),env)!=='public')return false;
|
|
20
|
-
return Object.entries(app.roles??{}).some(([role,decl])=>decl.can.includes('read:'+feed.collection)&&(!decl.signIn||caller.roles?.[app.id]?.includes(role)));
|
|
20
|
+
return Object.entries(app.roles??{}).some(([role,decl])=>decl.can.includes('read:'+feed.collection)&&(!decl.signIn||(caller.roles?.[app.id]?.includes(role)||caller.roles?.['*']?.includes(role))));
|
|
21
21
|
}
|
|
22
22
|
// Non-record events have no app read capability; they remain owner-only.
|
|
23
23
|
return caller.owner===true;
|
|
@@ -56,12 +56,14 @@ export async function pushEvents(env,cat,origin) {
|
|
|
56
56
|
}
|
|
57
57
|
|
|
58
58
|
/** One existing Lobby object per OAuth connection: streams never live in a Worker isolate. */
|
|
59
|
+
const streamCaller=(env,connection)=>connection.startsWith('public:')?Promise.resolve({id:null,owner:false,client:null,connection}):currentCaller(env,{connection});
|
|
59
60
|
export class EventStreams {
|
|
60
61
|
constructor(env,ctx){this.env=env;this.ctx=ctx;this.stream=null;this.tail=Promise.resolve();}
|
|
61
|
-
close(stream){stream.cancelled=true;stream.resume?.();try{stream.controller.close();}catch{}if(this.stream===stream)this.stream=null;}
|
|
62
|
+
close(stream){stream.cancelled=true;stream.resume?.();try{stream.controller.close();}catch{}if(this.stream===stream){this.stream=null;this.ctx.waitUntil(this.env.DB.prepare('DELETE FROM mcp_kv WHERE key=?1').bind('tools-stream:'+stream.connection).run().catch(()=>{}));}}
|
|
62
63
|
background(input){this.ctx.waitUntil(this.wake(input).catch(error=>{if(this.stream)this.close(this.stream);console.error('Event stream delivery failed',error);}));}
|
|
63
64
|
async fetch(request) {
|
|
64
65
|
const input=await request.json();
|
|
66
|
+
if(input.op==='tools') {await this.wake(input);return new Response(null,{status:204});}
|
|
65
67
|
if(input.op==='stream') {
|
|
66
68
|
// A reconnect replaces the previous GET, including a half-open socket.
|
|
67
69
|
if(this.stream)this.close(this.stream);
|
|
@@ -73,6 +75,10 @@ export class EventStreams {
|
|
|
73
75
|
const stream={...input,after,name,controller:null,cancelled:false,resume:null};
|
|
74
76
|
const body=new ReadableStream({start:controller=>{stream.controller=controller;controller.enqueue(new TextEncoder().encode(': connected\n\n'));},pull:()=>{const resume=stream.resume;stream.resume=null;resume?.();},cancel:()=>this.close(stream)});
|
|
75
77
|
this.stream=stream;
|
|
78
|
+
const caller=await streamCaller(this.env,connection).catch(()=>null);
|
|
79
|
+
if(!caller){this.close(stream);return new Response('Unauthorized',{status:401});}
|
|
80
|
+
stream.toolSignature=await this.toolSignature(input.tools??[],caller);
|
|
81
|
+
await this.env.DB.prepare('INSERT INTO mcp_kv(key,value) VALUES(?1,?2) ON CONFLICT(key) DO UPDATE SET value=excluded.value').bind('tools-stream:'+connection,JSON.stringify({connection})).run();
|
|
76
82
|
// Replay and later wakes share one ordered pump. Backpressure must not
|
|
77
83
|
// hold up the writer's request or a function handler.
|
|
78
84
|
this.background(input);
|
|
@@ -81,11 +87,16 @@ export class EventStreams {
|
|
|
81
87
|
this.background(input);return new Response(null,{status:204});
|
|
82
88
|
}
|
|
83
89
|
wake(input){const work=this.tail.then(()=>this.deliver(input));this.tail=work.catch(()=>{});return work;}
|
|
90
|
+
async toolSignature(tools,caller) {const visible=[];for(const tool of tools)if(await allowed(tool.audience,caller,this.env))visible.push(tool);return JSON.stringify(visible);}
|
|
84
91
|
async deliver(input) {
|
|
85
92
|
const stream=this.stream;if(!stream)return;
|
|
86
93
|
let caller;
|
|
87
|
-
try{caller=await
|
|
94
|
+
try{caller=await streamCaller(this.env,input.connection);}
|
|
88
95
|
catch{this.close(stream);return;}
|
|
96
|
+
if(input.tools){
|
|
97
|
+
const signature=await this.toolSignature(input.tools,caller);
|
|
98
|
+
if(stream.toolSignature!==signature){stream.toolSignature=signature;stream.controller.enqueue(new TextEncoder().encode('event: message\ndata: '+JSON.stringify({jsonrpc:'2.0',method:'notifications/tools/list_changed'})+'\n\n'));}
|
|
99
|
+
}
|
|
89
100
|
const rows=(await this.env.DB.prepare("SELECT value FROM mcp_kv WHERE key LIKE ?1").bind('event-sub:'+caller.connection+':%').all()).results;
|
|
90
101
|
const subscriptions=[];
|
|
91
102
|
for(const row of rows){const sub=JSON.parse(row.value);if(await readableFeed(this.env,input.cat,caller,sub.name))subscriptions.push(sub);}
|
|
@@ -114,3 +125,19 @@ export class EventStreams {
|
|
|
114
125
|
}
|
|
115
126
|
}
|
|
116
127
|
}
|
|
128
|
+
|
|
129
|
+
/** Refresh active streams whenever this Worker observes a new tool catalogue. */
|
|
130
|
+
export async function pushToolList(env,cat,tools,origin) {
|
|
131
|
+
if(!env.LOBBY)return;
|
|
132
|
+
const publicShape=tools.map(({name,description,inputSchema,audience,kind})=>({name,description,inputSchema,audience,kind}));
|
|
133
|
+
const signature=JSON.stringify(publicShape);
|
|
134
|
+
const previous=await env.DB.prepare("SELECT value FROM mcp_kv WHERE key='tools-catalogue'").first();
|
|
135
|
+
if(previous?.value===signature)return;
|
|
136
|
+
await env.DB.prepare("INSERT INTO mcp_kv(key,value) VALUES('tools-catalogue',?1) ON CONFLICT(key) DO UPDATE SET value=excluded.value").bind(signature).run();
|
|
137
|
+
let after='';
|
|
138
|
+
for(;;){
|
|
139
|
+
const rows=(await env.DB.prepare("SELECT key,value FROM mcp_kv WHERE key LIKE 'tools-stream:%' AND key>?1 ORDER BY key LIMIT 100").bind(after).all()).results;
|
|
140
|
+
for(const row of rows){const connection=JSON.parse(row.value).connection;await eventHub(env,connection).fetch('https://events/__studio_events',{method:'POST',body:JSON.stringify({op:'tools',connection,cat,tools:publicShape,origin})});}
|
|
141
|
+
if(rows.length<100)break;after=rows.at(-1).key;
|
|
142
|
+
}
|
|
143
|
+
}
|
package/worker/functions.mjs
CHANGED
|
@@ -74,8 +74,11 @@ export async function runFunctions(env, cat, origin, {definitions, now = Date.no
|
|
|
74
74
|
if (!claimed.meta.changes) continue;
|
|
75
75
|
let failure=null;
|
|
76
76
|
try {
|
|
77
|
-
const
|
|
78
|
-
|
|
77
|
+
const stored=JSON.parse(row.data);
|
|
78
|
+
const isolated=env.HOMIE_LOCAL_TEST_SECRET && /^([a-z][a-z0-9-]*):check:([a-zA-Z0-9-]+)$/.exec(stored?.app??'');
|
|
79
|
+
const event=Object.freeze({id:row.event,type:row.type,at:row.at,data:isolated?{...stored,app:isolated[1]}:stored,attempt:row.attempts+1});
|
|
80
|
+
const testScope=isolated?isolated[2]:null;
|
|
81
|
+
await fn.handler(event,services(env,cat,origin,{id:'studio-function',owner:true,client:null,...(testScope?{testScope}:{})},'function:'+fn.name+(testScope?':check:'+testScope:''),fn));
|
|
79
82
|
} catch(error) {failure=fn.webhook?webhookFailure(error):functionError(error,env);}
|
|
80
83
|
const finished=now();
|
|
81
84
|
await env.DB.batch([
|