@homie-rocks/studio 0.50.7 → 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 CHANGED
@@ -17,6 +17,48 @@ To bring a studio up to date, tell Claude: "Upgrade my studio to the newest Homi
17
17
  `npx -y @homie-rocks/studio@latest upgrade`, which shows what's new since the version your studio pins (from this
18
18
  file) and what the upgrade would change, and changes nothing until you agree.
19
19
 
20
+ ## [0.51.0] - 2026-10-14
21
+
22
+ **Plugin 0.51.0** · Take the money through your own Stripe, keyless by default.
23
+
24
+ One Stripe approval enables mixed menus, paid capacity bookings and monthly giving through the owner’s own account.
25
+
26
+ ### Added
27
+
28
+ - Mixed-item Stripe menus with adjustable quantities, signed paid invoice lines
29
+ and app references such as table 7 on `order.paid`.
30
+ - Booking collections with pending/confirmed/released records and versioned staff
31
+ moves. Each published slot has its own Stripe Payment Link with a completed-session
32
+ capacity limit; the owner's AI syncs slots with `stripe_bookings_sync`.
33
+ - Recurring Payment Links for monthly giving and memberships, paid invoice periods,
34
+ subscription lifecycle events and an API-configured no-code customer portal login.
35
+ - Real sandbox acceptance for menus, two guests with the last-seat link open,
36
+ full-link deactivation, monthly payment, portal, and clock-driven renewal/failure.
37
+
38
+ ### Changed
39
+
40
+ - The one-approval CLI connection remains keyless. Homie has no platform credential,
41
+ backend or cut in the payment path. A key alone never selects session checkout;
42
+ `STRIPE_CHECKOUT_MODE=sessions` explicitly opts into the fuller connection.
43
+ - Keyless unpaid bookings are pending, not exclusive holds. Payment secures the seat.
44
+ The optional keyed path retains exclusive expiring holds and exact app-built carts.
45
+ - The giving starter opens real Checkout for suggested one-time/monthly gifts and
46
+ disables payment before connection. It never counts intentions as gifts.
47
+
48
+ ### Fixed
49
+
50
+ - Records-only app shells include the shop bridge without needing a multiplayer room.
51
+ - Signed menu invoices and Checkout events tolerate either arrival order, and paid
52
+ membership access never extends on a failed renewal or revives after cancellation.
53
+
54
+ ### Upgrade notes
55
+
56
+ Existing studios keep their pinned version until they opt in. Apply booking and
57
+ subscription migrations when adding those capabilities. Run connect after changing
58
+ shop items; the owner's AI syncs published slot manifests after schedule changes.
59
+ Stripe's Payment Link and invoice snapshot limits split large catalogues into menus;
60
+ custom amounts and monthly presets use separate links. No studio catalogue cap is added.
61
+
20
62
  ## [0.50.7] - 2026-10-14
21
63
 
22
64
  **Plugin 0.50.7** · Prove your needs on every screen, with fixes from studio testers.
@@ -1,5 +1,28 @@
1
1
  # Together we can
2
2
 
3
- Edit app.json words and starter settings. Phone: /<id>/open; wall: /<id>/tv. A site/pages/<id>/index.html replaces only the landing. Records are public; use references, never private contact details. Staff updates require an account grant.
3
+ This starter opens real Checkout for one-time or monthly gifts from your studio's
4
+ shop. Run `shop init --giving --currency CAD`, then connect the organisation's
5
+ Stripe. The default gift options include $20 monthly. Existing studios and apps
6
+ are unchanged; generate this starter to opt in.
4
7
 
5
- Giving totals are public intentions, not received money. The gift link opens the studio shop. Use `shop init --giving --currency CAD` and connect the organisation’s Stripe account. Monthly renewals are managed in Stripe.
8
+ The phone reads the actual shop's donation options and availability. Without
9
+ Stripe, the options are a disabled preview (`screen.shopPreview: true`). A chosen-amount item (`price: 'choose'`)
10
+ shows an amount field using the item's minimum and maximum. See `shop/SHOP.md`
11
+ for the keyless Payment Links setup and “give $20 monthly” example.
12
+
13
+ Checkout opening never increments a total or creates an intention record. Stripe's
14
+ verified payment supplies `order.paid` and `subscription.started`; subsequent
15
+ invoices supply `subscription.renewed` or `subscription.failed`. Connect these
16
+ events to your own public progress display if desired. The starter deliberately
17
+ shows no collected total until you implement that verified projection.
18
+
19
+ The Manage my gifts link opens the studio shop, where the giver can open their
20
+ own Stripe customer portal. Connect creates the no-code portal login through Stripe's API; no Dashboard toggle is needed.
21
+ Set `giving.receiptText` in shop.json for your organisation's receipt wording.
22
+ Edit app.json name, blurb and colour for your cause. Phone: /<id>/open; wall:
23
+ /<id>/tv. Capture and check both surfaces before sharing.
24
+
25
+ Add the cause’s actual needs to its CODEX.md before running `check`. This starter
26
+ has no pretend shared-record action for the checker to click. For a disconnected
27
+ preview, assert `[data-action]:disabled` on the phone; verify paid flows with the
28
+ Checkout harness and signed events once the keyless connection is available.
@@ -1,29 +1,20 @@
1
1
  {
2
2
  "id": "giving",
3
3
  "name": "Together we can",
4
- "blurb": "Support our goal with a one-time or monthly gift. Intentions below are not payments.",
4
+ "blurb": "Support our work with a one-time or monthly gift, paid securely through Stripe.",
5
5
  "entry": "src/view.ts",
6
6
  "roles": {
7
7
  "customer": {
8
8
  "signIn": false,
9
- "can": [
10
- "read:entries",
11
- "create:entries"
12
- ]
9
+ "can": []
13
10
  },
14
11
  "wall": {
15
12
  "signIn": false,
16
- "can": [
17
- "read:entries"
18
- ]
13
+ "can": []
19
14
  },
20
15
  "staff": {
21
16
  "signIn": true,
22
- "can": [
23
- "read:entries",
24
- "update:entries",
25
- "delete:entries"
26
- ]
17
+ "can": []
27
18
  }
28
19
  },
29
20
  "surfaces": {
@@ -37,14 +28,14 @@
37
28
  "join": "Scan to join",
38
29
  "one": "person",
39
30
  "many": "people",
40
- "action": "Record my intention",
41
- "total": "Giving intentions",
31
+ "action": "Give on Stripe",
32
+ "total": "Your gift",
42
33
  "choice": "Choose an option",
43
34
  "monthly": "Gift frequency",
44
35
  "once": "One time",
45
36
  "month": "Monthly",
46
37
  "give": "Give securely on Stripe",
47
- "goal": "Intention goal \u00b7 not payments received",
38
+ "goal": "Our goal",
48
39
  "prompt": "Choose what works for you.",
49
40
  "finished": "All three answers are in. Thank you!",
50
41
  "complete": "Mark complete",
@@ -54,33 +45,6 @@
54
45
  "live": "Together \u00b7 live",
55
46
  "reconnecting": "Reconnecting\u2026"
56
47
  },
57
- "records": {
58
- "persist": true,
59
- "collections": {
60
- "entries": {
61
- "fields": {
62
- "choice": {
63
- "type": "string",
64
- "required": true,
65
- "maxLength": 100
66
- },
67
- "status": {
68
- "type": "string",
69
- "required": true,
70
- "maxLength": 60
71
- },
72
- "amount": {
73
- "type": "number",
74
- "required": true
75
- },
76
- "monthly": {
77
- "type": "boolean",
78
- "required": true
79
- }
80
- }
81
- }
82
- }
83
- },
84
48
  "parts": [],
85
49
  "netplay": {
86
50
  "v": 1,
@@ -100,7 +64,8 @@
100
64
  "watch": "overview",
101
65
  "screen": {
102
66
  "join": "bottom-right",
103
- "share": "top-right"
67
+ "share": "top-right",
68
+ "shopPreview": true
104
69
  },
105
70
  "needs": {
106
71
  "three": "0.185.1",
@@ -108,12 +73,9 @@
108
73
  "@homie-rocks/geom": "0.1.1",
109
74
  "@types/three": "0.185.1"
110
75
  },
111
- "check": {
112
- "action": "[data-action]",
113
- "observe": "[data-count]"
114
- },
76
+ "check": {},
115
77
  "landing": {
116
- "pitch": "Support our goal with a one-time or monthly gift. Intentions below are not payments."
78
+ "pitch": "Support our work with a one-time or monthly gift, paid securely through Stripe."
117
79
  },
118
80
  "room": {
119
81
  "host": "server"
@@ -126,6 +88,7 @@
126
88
  "kind": "giving",
127
89
  "choices": [
128
90
  "10",
91
+ "20",
129
92
  "25",
130
93
  "50"
131
94
  ],
@@ -158,5 +121,9 @@
158
121
  ]
159
122
  }
160
123
  ]
124
+ },
125
+ "records": {
126
+ "persist": false,
127
+ "collections": {}
161
128
  }
162
129
  }
@@ -1,3 +1,3 @@
1
1
  <!doctype html><html lang="en"><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>Live together</title><style>
2
- *{box-sizing:border-box}body{margin:0;background:var(--tone,#183b55);color:#fff7df;font-family:system-ui,sans-serif}main{min-height:100svh;padding:clamp(24px,5vw,64px);display:grid;grid-template-columns:1.1fr 1fr;gap:40px;align-items:center}h1{font-size:clamp(44px,6vw,88px);line-height:1;letter-spacing:-.05em;margin:20px 0}p{line-height:1.6;max-width:40em}#connection{font-size:12px;text-transform:uppercase;letter-spacing:.18em}.panel{background:#fff7df;color:#20352e;border-radius:28px;padding:28px;box-shadow:0 30px 80px #0003}#count{font-size:80px;font-weight:800;line-height:1}ul{padding:0;list-style:none}li{padding:12px 0;border-bottom:1px solid #20352e22}button,select,a{font:inherit;min-height:48px;border-radius:12px}button,select{padding:10px 16px;width:100%;margin-top:8px}button{background:var(--tone);color:#fff7df;border:0;cursor:pointer}button:disabled{opacity:.5}a{display:inline-flex;align-items:center;color:inherit}label{display:block;margin-top:14px}select{background:white;color:#20352e;border:1px solid #20352e66}progress{width:100%;height:22px;accent-color:var(--tone)}#status{min-height:24px}#amount{font-size:40px;font-weight:700}button:focus-visible,select:focus-visible,a:focus-visible{outline:3px solid #df9325;outline-offset:4px}.wall #controls{display:none}#giving-link[hidden],[hidden]{display:none!important}@media(max-width:650px){main{display:block;padding:72px 22px 24px}h1{font-size:46px}.panel{margin-top:24px;padding:22px}#count{font-size:58px}li{font-size:14px}}@media(max-height:520px) and (min-width:651px){main{padding:65px 25px 20px;gap:22px}h1{font-size:38px}.panel{padding:18px}}
2
+ *{box-sizing:border-box}body{margin:0;background:var(--tone,#183b55);color:#fff7df;font-family:system-ui,sans-serif}main{min-height:100svh;padding:clamp(24px,5vw,64px);display:grid;grid-template-columns:1.1fr 1fr;gap:40px;align-items:center}h1{font-size:clamp(44px,6vw,88px);line-height:1;letter-spacing:-.05em;margin:20px 0}p{line-height:1.6;max-width:40em}#connection{font-size:12px;text-transform:uppercase;letter-spacing:.18em}.panel{background:#fff7df;color:#20352e;border-radius:28px;padding:28px;box-shadow:0 30px 80px #0003}#count{font-size:80px;font-weight:800;line-height:1}ul{padding:0;list-style:none}li{padding:12px 0;border-bottom:1px solid #20352e22}button,select,input,a{font:inherit;min-height:48px;border-radius:12px}button,select,input{padding:10px 16px;width:100%;margin-top:8px}button{background:var(--tone);color:#fff7df;border:0;cursor:pointer}button:disabled{opacity:.5}a{display:inline-flex;align-items:center;color:inherit}label{display:block;margin-top:14px}select,input{background:white;color:#20352e;border:1px solid #20352e66}progress{width:100%;height:22px;accent-color:var(--tone)}#status{min-height:24px}#amount{font-size:40px;font-weight:700}button:focus-visible,select:focus-visible,a:focus-visible{outline:3px solid #df9325;outline-offset:4px}.wall #controls{display:none}#giving-link[hidden],[hidden]{display:none!important}@media(max-width:650px){main{display:block;padding:72px 22px 24px}h1{font-size:46px}.panel{margin-top:24px;padding:22px}#count{font-size:clamp(28px,9vw,46px)}li{font-size:14px}}@media(max-height:520px) and (min-width:651px){main{padding:65px 25px 20px;gap:22px}h1{font-size:38px}.panel{padding:18px}}
3
3
  </style><main><header><span id="connection"></span><h1></h1><p id="intro"></p><div id="goal"><p id="goal-label"></p><div><span id="amount"></span> / <span id="target"></span></div><progress></progress></div><ul id="entries" aria-live="polite"></ul></header><section class="panel"><div id="label"></div><div id="count" data-count>0</div><div id="controls"><p id="prompt"></p><label><span id="choice-label"></span><select id="choice"></select></label><label id="frequency"><span id="frequency-label"></span><select id="monthly"><option id="once" value="once"></option><option id="month" value="month"></option></select></label><button data-action></button><a id="giving-link" href="/shop/" target="_blank" rel="noopener"></a></div><p id="status" role="status"></p></section></main><script type="module" src="./assets/main.js"></script></html>
@@ -15,7 +15,7 @@ export default defineRules({
15
15
  },
16
16
  room: {
17
17
  rounds: { seconds: 0, breakSeconds: 0 }, bots: { keep: 0 },
18
- join() { return { kind: 'participant', at: { x: 0, y: 0 } }; },
18
+ join() { return { kind: 'participant', at: { x: 0, y: 0, z: 0 } }; },
19
19
  on: { refresh(world) { world.shared.revision += 1; world.emit('refresh', { x: 0, y: 0 }, {}); } },
20
20
  },
21
21
  });
@@ -1,74 +1,72 @@
1
1
  import app from '../app.json';
2
2
  import { openRoom } from '@homie-rocks/studio/rules/view';
3
3
  import type rules from './rules';
4
- import { createAppRecords, type AppRecord } from '@homie-rocks/studio/apps';
5
- import { randomId } from '@homie-rocks/studio/links';
6
- type Entry = { choice: string; status: string; amount: number; monthly: boolean };
4
+ import { createShop, type ShopCatalog } from '@homie-rocks/studio/shop';
7
5
  const room = openRoom<typeof rules>();
8
- const net = room.net;
9
- const role = net.params.role ?? 'customer';
10
- const words: Record<string, string> = app.words;
11
- const config = app.starter;
6
+ const shop = createShop();
12
7
  const $ = <T extends HTMLElement = HTMLElement>(s: string) => document.querySelector<T>(s)!;
13
- const records = createAppRecords<Entry>({ collection: 'entries' });
14
- let rows: AppRecord<Entry>[] = [], busy = false, ready = false, question = 0;
15
8
  const action = $<HTMLButtonElement>('[data-action]');
16
- document.body.className = role;
17
- document.body.style.setProperty('--tone', config.color);
18
- $('h1').textContent = app.name; $('#intro').textContent = app.blurb;
19
- $('#label').textContent = words.total; action.textContent = words.action;
20
- $('#choice-label').textContent = words.choice;
21
- $('#frequency-label').textContent = words.monthly;
22
- $('#once').textContent = words.once; $('#month').textContent = words.month;
23
- $('#giving-link').textContent = words.give;
24
- $('#giving-link').hidden = config.kind !== 'giving';
25
- $('#frequency').hidden = config.kind !== 'giving';
26
- $('#goal').hidden = config.kind !== 'giving';
27
- $('#controls').hidden = role === 'wall';
28
- const select = $<HTMLSelectElement>('#choice');
29
- function choices() {
30
- select.replaceChildren();
31
- const list = config.kind === 'quiz' ? config.questions[question]?.choices ?? [] : config.choices;
32
- for (const value of list) { const option = document.createElement('option'); option.value = value; option.textContent = value; select.append(option); }
33
- $('#prompt').textContent = config.kind === 'quiz' ? config.questions[question]?.text ?? words.finished : words.prompt;
34
- action.hidden = config.kind === 'quiz' && question >= config.questions.length;
35
- }
36
- choices();
9
+ const choice = $<HTMLSelectElement>('#choice');
10
+ const frequency = $<HTMLSelectElement>('#monthly');
11
+ let catalog: ShopCatalog | undefined, busy = false;
12
+ const wall = room.net.params.role === 'wall';
13
+ document.body.className = room.net.params.role ?? 'customer';
14
+ document.body.style.setProperty('--tone', app.starter.color);
15
+ $('h1').textContent = app.name;
16
+ $('#intro').textContent = app.blurb;
17
+ $('#label').textContent = wall ? 'Give with us' : 'Your gift';
18
+ $('#choice-label').textContent = 'Choose a gift';
19
+ $('#frequency-label').textContent = 'Gift frequency';
20
+ $('#once').textContent = 'One time'; $('#month').textContent = 'Monthly';
21
+ $('#giving-link').textContent = 'Manage my gifts';
22
+ $('#goal').hidden = true;
23
+ $('#entries').hidden = true;
24
+ $('#prompt').textContent = 'Choose what works for you. Payments go directly to our Stripe account.';
25
+ const amountLabel = document.createElement('label'); amountLabel.textContent = 'Your amount';
26
+ const amount = document.createElement('input'); amount.type = 'number'; amount.inputMode = 'decimal'; amountLabel.append(amount);
27
+ choice.parentElement!.after(amountLabel);
28
+ function selected() { return catalog?.items.find(i => i.id === choice.value); }
37
29
  function paint() {
38
- $('[data-count]').textContent = String(rows.length);
39
- const amount = rows.reduce((n, r) => n + r.data.amount, 0);
40
- $('#amount').textContent = new Intl.NumberFormat(undefined, { style: 'currency', currency: config.currency }).format(amount);
41
- $('#target').textContent = new Intl.NumberFormat(undefined, { style: 'currency', currency: config.currency }).format(config.goal);
42
- $<HTMLProgressElement>('progress').max = config.goal; $<HTMLProgressElement>('progress').value = amount;
43
- $('#goal-label').textContent = words.goal;
44
- action.disabled = busy || !ready;
45
- $('#entries').replaceChildren();
46
- for (const row of rows.slice(-6).reverse()) {
47
- const li = document.createElement('li');
48
- li.textContent = `${row.data.choice} · ${row.data.status}${row.data.monthly ? ' · ' + words.month : ''}`;
49
- if (role === 'staff' && row.data.status !== words.done) {
50
- const button = document.createElement('button'); button.textContent = words.complete;
51
- button.onclick = () => { void records.update(row, { ...row.data, status: words.done }).then(refresh).catch(e => { $('#status').textContent = e.message; }); }; li.append(button);
52
- }
53
- $('#entries').append(li);
30
+ const item = selected();
31
+ amountLabel.hidden = item?.price !== 'choose';
32
+ action.disabled = busy || !catalog?.open || !item || item.way === 'closed';
33
+ action.textContent = frequency.value === 'month' ? 'Give monthly on Stripe' : 'Give on Stripe';
34
+ $('[data-count]').textContent = wall ? 'Thank you' : item?.shown ?? 'Preview';
35
+ if (item?.price === 'choose') {
36
+ const scale = catalog!.currencyScale;
37
+ amount.min = String((item.min ?? 0) / scale);
38
+ amount.max = item.max == null ? '' : String(item.max / scale);
39
+ amount.step = String(1 / scale);
54
40
  }
55
41
  }
56
- async function refresh() {
57
- try { rows = await records.list(); ready = true; paint(); net.playable(); }
58
- catch (error) { $('#status').textContent = (error as Error).message; }
42
+ function options() {
43
+ choice.replaceChildren();
44
+ for (const item of catalog?.items ?? []) {
45
+ if (item.kind !== 'donation' || Boolean(item.interval) !== (frequency.value === 'month')) continue;
46
+ const option = document.createElement('option'); option.value = item.id; option.textContent = `${item.name} · ${item.shown}`; choice.append(option);
47
+ }
48
+ paint();
59
49
  }
50
+ choice.onchange = paint; frequency.onchange = options;
60
51
  action.onclick = async () => {
52
+ const item = selected(); if (!item || !catalog?.open) return;
53
+ const value = item.price === 'choose' ? Math.round(Number(amount.value) * catalog.currencyScale) : undefined;
54
+ if (item.price === 'choose' && (!amount.value || !Number.isSafeInteger(value) || value! < (item.min ?? 0) || item.max != null && value! > item.max)) {
55
+ $('#status').textContent = 'Enter an amount within the displayed range.'; return;
56
+ }
61
57
  busy = true; paint();
62
58
  try {
63
- const choice = select.value;
64
- await records.create({ choice: config.kind === 'quiz' ? `${question + 1}. ${choice}` : choice, status: words.pending, amount: config.kind === 'giving' ? Number(choice) : 0, monthly: $<HTMLSelectElement>('#monthly').value === 'month' }, randomId());
65
- $('#status').textContent = words.thanks;
66
- if (config.kind === 'quiz') { question++; choices(); }
67
- room.command('refresh', {}); await refresh();
68
- } catch (error) { $('#status').textContent = (error as Error).message; }
69
- finally { busy = false; paint(); }
59
+ // Opening Checkout is not a payment. Only Stripe's verified webhook records a gift.
60
+ const opened = await shop.buy(item.id, 1, value);
61
+ $('#status').textContent = opened ? 'Finish your gift on Stripe. Your receipt confirms payment.' : 'Open the shop to sign in or complete the steps for this gift.';
62
+ if (!opened) shop.open(item.id);
63
+ } finally { busy = false; paint(); }
70
64
  };
71
- room.on('refresh', () => { void refresh(); });
72
- net.on('link', () => { void refresh(); });
73
- setInterval(() => { $('#connection').textContent = net.link === 'online' ? words.live : words.reconnecting; void refresh(); }, 2000);
65
+ async function refresh() {
66
+ catalog = await shop.catalog(); options();
67
+ $('#status').textContent = wall ? 'Join on your phone to choose a one-time or monthly gift.' : catalog.open ? 'Monthly gifts renew until you cancel through Stripe. Manage your gifts using the link below.' : 'Preview — giving is unavailable until the organisation connects Stripe.';
68
+ $('#connection').textContent = room.net.link === 'online' ? 'Together · live' : 'Connecting…';
69
+ room.net.playable();
70
+ }
71
+ shop.on('closed', () => { void refresh(); });
74
72
  void refresh();
package/apps/APPS.md CHANGED
@@ -49,7 +49,7 @@ Parts import exactly as games do (`@parts/<id>` or `@parts/<host>/<id>`), with t
49
49
 
50
50
  ## Shop button on the screen
51
51
 
52
- 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 hide this button (merge with any existing screen settings). The room panel and your own shop buttons still work. Closed shops, kids policies and an empty selection show no button.
52
+ 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.
53
53
 
54
54
  ```ts
55
55
  import { createShop } from '@homie-rocks/studio/shop';
@@ -103,3 +103,27 @@ and the `app_records` tool schema explain the contract; validation errors name i
103
103
  Live and local unconnected shop/giving pages display the declared items and amounts
104
104
  with disabled payment controls. They say they are not taking payments yet. A fresh
105
105
  `/shop/` has an empty preview; an authored shop page still takes precedence.
106
+
107
+ ## Booking capacity
108
+
109
+ See `shop/SHOP.md` for “book a tune and hold the slot”. A collection opts in with
110
+ `booking: {slots: 'slots', item: 'full-tune'}`. Slots are ordinary staff-managed
111
+ records with integer `capacity`. Bookings are server-managed records created by
112
+ `shop.book`; their public data is only slot, quantity and state. Keep personal
113
+ information in a separate private collection. Public roles must not have write
114
+ capabilities for slots or bookings. Staff movement is a versioned record update
115
+ containing only `slot`; confirmation and release follow verified Checkout state.
116
+ The default keyless connection uses one Payment Link per published slot. Call
117
+ `stripe_bookings_sync` after publishing/changing slots. Pending checkout does not
118
+ block others; payment secures the seat. Use record.booking.full/available/synced
119
+ for slot display. Read SHOP.md for the complete manifest and limitations.
120
+
121
+ The giving starter now reads the actual shop donation options with `shop.catalog()`.
122
+ `shop init --giving --currency CAD` includes $20 monthly; `app new giving --from giving`
123
+ opens Checkout for the selected amount and frequency. A disconnected shop displays
124
+ a disabled preview. No intention record or unverified collected total is created.
125
+ Use the Manage my gifts link for the buyer-owned Stripe portal in the shop.
126
+
127
+ Records-only apps support the same shop bridge as room apps. In the sandboxed
128
+ view, use `createShop({target: parent})` to send checkout and booking requests to
129
+ the authenticated outer shell; a room simulation is not required for payments.
package/apps/apps.ts CHANGED
@@ -1,6 +1,6 @@
1
1
  /** Lasting records through the same sandbox bridge used by saves and the shop. */
2
2
  import { randomId } from '../links/links.mjs';
3
- export interface AppRecord<T> { id: string; data: T; version: number; updatedAt: number }
3
+ export interface AppRecord<T> { id: string; data: T; version: number; updatedAt: number; booking?: {full: boolean; available: number; synced: boolean} }
4
4
  export function createAppRecords<T extends Record<string, unknown>>({ collection }: { collection: string }) {
5
5
  if (!/^[a-z][a-z0-9-]{0,39}$/.test(collection)) throw new Error('invalid collection');
6
6
  const pending = new Map<string, { resolve: (v: any) => void; reject: (e: Error) => void; timer: ReturnType<typeof setTimeout> }>();
@@ -788,7 +788,11 @@ function print(result) {
788
788
  `Written: ${result.file}`);
789
789
  break;
790
790
  case 'check':
791
- if (result.kind === 'app') { lines.push(`PASS: app ${result.game ?? ''}: wall and two phones shared an action; reconnect ${result.reconnect ? 'passed' : 'failed'}.`, ` ${result.before} → ${result.after}`); break; }
791
+ if (result.kind === 'app') {
792
+ if (result.needs?.length) lines.push(`PASS: app ${result.game ?? ''}: ${result.needs.length} Codex needs checked on ${(result.surfaces ?? []).join(', ')}.`);
793
+ if (result.reconnect) lines.push(`PASS: app ${result.game ?? ''}: wall and two phones shared an action; reconnect passed.`, ` ${result.before} → ${result.after}`);
794
+ break;
795
+ }
792
796
  if (result.results) { for (const r of result.results) lines.push(`${r.game}: PASS at ${r.site}: room ${r.room}, round ${r.round?.n} (${r.round?.humans} humans, ${r.round?.bots} bots), connection ${r.uninterrupted === true ? 'uninterrupted' : r.uninterrupted === false ? 'INTERRUPTED' : 'unknown'}`); break; }
793
797
  if (result.site) lines.push(`Checked ${result.local ? 'this studio\'s own build' : 'the site'} at ${result.site}.`);
794
798
  lines.push(`PASS: two fresh browsers in room ${result.room} finished round ${result.round.n} (${result.round.humans} humans, ${result.round.bots} bots) in ${Math.round(result.totalMs / 1000)} s.`,
@@ -108,6 +108,8 @@ await shop.buy('full-tune', 1, undefined, { reference: booking.id });
108
108
  await shop.checkout({ reference: booking.id });
109
109
  ```
110
110
 
111
+ On the default keyless connection, `shop.checkout({ reference: 'table-7' })` opens the studio's synced Stripe menu: the guest sets quantities on Stripe's page and `order.paid.data.lines` carries what was actually paid. A slot with capacity uses `shop.book` (see below), not `shop.buy`. Monthly gifts and memberships are recurring items on their own links. None of these needs a key.
112
+
111
113
  These retain their existing boolean result: true means checkout opened, not payment received. The durable `order.paid` function event includes `data.order` (the order ID), `data.reference` (your reference or null), and `data.app` (the app ID). A verified provider payment emits it. Validate the referenced record, expected amount/currency and app, deduplicate by event/order ID, then fulfil that booking or add that gift to the confirmed meal table. A buyer supplies the reference: it is correlation, never proof they own a private record or paid the expected price. Do not put personal data in it.
112
114
 
113
115
  For each offer, build the promised action and fulfilment, not just a price label: a naming purchase needs the proposed name and the staff/customer outcome; a ticket needs its receipt. Keep an unpaid request distinct when charging is unavailable.
@@ -175,7 +177,7 @@ Scaffold businesses with `new <folder> --kind business`; causes with `--kind cau
175
177
 
176
178
  For giving, set the studio currency and run `shop init --giving --currency CAD` (use the organisation’s currency). It creates one-time/monthly suggested gifts. The owner supplies `giving.receiptText` in shop.json; Homie supplies no legal or tax text. Before Stripe is connected, local /shop/ previews items and prices with checkout disabled. Run the connection step yourself; the owner approves their Stripe account in the browser.
177
179
 
178
- Booking requests are not capacity reservations. Calendar availability and seat capacity are the next app capability; do not promise them.
180
+ Use `shop.book` and the booking collection declaration in SHOP.md. Default keyless booking links show pending while paying; an unpaid guest does not block others. Stripe limits completed sessions to capacity. The owner’s AI syncs published/changed slots with `stripe_bookings_sync`. Say “Pay to secure your seat.” Exclusive unpaid holds require the explicitly selected optional keyed connection.
179
181
 
180
182
 
181
183
  ## Tell my AI when a booking comes in
@@ -256,3 +258,13 @@ and the `app_records` tool schema explain the contract; validation errors name i
256
258
  Live and local unconnected shop/giving pages display the declared items and amounts
257
259
  with disabled payment controls. They say they are not taking payments yet. A fresh
258
260
  `/shop/` has an empty preview; an authored shop page still takes precedence.
261
+
262
+ The giving starter now reads the actual shop donation options with `shop.catalog()`.
263
+ `shop init --giving --currency CAD` includes $20 monthly; `app new giving --from giving`
264
+ opens Checkout for the selected amount and frequency. A disconnected shop displays
265
+ a disabled preview. No intention record or unverified collected total is created.
266
+ Use the Manage my gifts link for the buyer-owned Stripe portal in the shop.
267
+
268
+ For a records-only booking app, use `createShop({target: parent})` inside its
269
+ sandboxed view. The shell includes the authenticated shop bridge; do not add a
270
+ room simulation merely to make a payment button work.
@@ -1,6 +1,6 @@
1
1
  ---
2
2
  name: shop
3
- description: Sell things in a Homie studio's games with the studio's OWN Stripe - a supporter pack, cosmetics, a season pass, a one-time unlock, a tip - in real money, with Stripe Checkout, Stripe Tax or Stripe Managed Payments, refunds from the office, and optional studio policies with an open default; set up with Stripe's own agent tools (Stripe's MCP server and skills) with one browser approval and Payment Links by default, no human-handled key and no API key in the Worker. Use when someone asks to sell something, add a shop or store, take payments or donations, make money from a game, set up or connect Stripe, make the products in Stripe, asks "how are sales?", about tax or Managed Payments, to add a supporter badge, refund a player, or about chargebacks.
3
+ description: Sell things in a Homie studio's games with the studio's OWN Stripe - a supporter pack, cosmetics, a season pass, a one-time unlock, a tip, a round of several items to a table, a paid booking with slot capacity, monthly giving or a membership - in real money, with Stripe Checkout, Stripe Tax or Stripe Managed Payments, refunds from the office, and optional studio policies with an open default; set up with Stripe's own agent tools (Stripe's MCP server and skills) with one browser approval and Payment Links by default, no human-handled key and no API key in the Worker. Use when someone asks to sell something, add a shop or store, take payments or donations, take paid bookings, set up monthly giving or memberships, make money from a game, set up or connect Stripe, make the products in Stripe, asks "how are sales?", about tax or Managed Payments, to add a supporter badge, refund a player, or about chargebacks.
4
4
  compatibility: The studio's own current toolkit with stripe_login or shop connect --renew, Wrangler, and the official Stripe CLI. Stripe's own agent plugin (MCP and skills) is optional for catalog work and analytics.
5
5
  metadata:
6
6
  providers: stripe
@@ -43,13 +43,18 @@ The Worker redirects buyers to Stripe and grants purchases from signed events. N
43
43
  Homie service or API key is needed when someone pays. Run connect again after editing shop.json;
44
44
  it updates changed objects and archives removed ones. Existing connections need no new approval.
45
45
 
46
- Only when the person asks for combined carts, spending reservations, custom checkout expiry,
47
- automatic recovery of lost webhooks or office refunds, explain one sentence:
48
- “The fuller connection adds combined carts, spending limits, automatic recovery and refunds here.”
49
- A pre-existing Worker key selects that path automatically. Never silently select `--manual` or ask
50
- for a key: explain its remaining Dashboard step only if the person explicitly chooses that route.
51
- Before relying on spending restrictions after switching, deactivate old Payment Links with the approved CLI and resolve in-flight checkouts; a credential change does not cancel them.
52
- `shop connect` selects keyless again and removes an existing Worker API key after saving the links.
46
+ Keyless also supports mixed fixed-price menus, capacity-limited booking links and fixed
47
+ monthly gifts/memberships. Read SHOP.md's 0.51 examples before implementing these.
48
+ Guests choose menu quantities on Stripe. Booking links show pending without holding
49
+ an unpaid seat; Stripe's completion limit is the hard capacity. Use
50
+ `stripe_bookings_sync` after publishing/changing slots (complete manifest), or update
51
+ `.studio/booking-slots.json` and rerun connect. Sync creates the no-code portal login
52
+ through the API; no Dashboard toggle is needed. Never ask for a key.
53
+
54
+ Only when explicitly selected, an existing Worker key plus
55
+ `STRIPE_CHECKOUT_MODE=sessions` enables exact app carts, exclusive expiring holds,
56
+ spending reservations, provider reads and runtime refunds. A key alone never selects
57
+ it. Default connect selects links and removes the runtime key.
53
58
 
54
59
  ## Never
55
60
 
@@ -124,7 +129,7 @@ No toolkit amount or duration ceiling applies. Stripe currency constraints and s
124
129
  A refund window includes used items unless `policy.refundUsedItems` is false. A player cannot refund a tip;
125
130
  the owner can refund through Stripe; keyed shops also offer office refunds. Guest purchases stay owned when the buyer signs in later.
126
131
 
127
- For keyed shops, carts: `shop.add(item, quantity, amount?)`, then `shop.checkout()`; `shop.buy(item)` buys directly.
132
+ For menus or explicitly keyed shops, carts: `shop.add(item, quantity, amount?)`, then `shop.checkout()`; `shop.buy(item)` buys directly.
128
133
  Free and paid lines share one order and one Stripe Session. The studio chooses where to open the shop.
129
134
 
130
135
  ## The first sale (the acceptance)
@@ -133,17 +138,11 @@ In test mode: buy the supporter pack on a phone with Stripe's test card `4242 42
133
138
  see the badge on the account page and beside the name in a room (from the next room the player joins), refund it
134
139
  in Stripe (or through the connected AI), and see the badge go. With the protective preset, a kids server's room shows no shop and `/<game>/tv` shows only a code.
135
140
 
136
- Keyless purchases buy one item type at a time, with quantity confirmed on Stripe. Tips choose the
137
- amount on Stripe and always have quantity one. Free orders grant locally without Stripe.
138
- Refund and dispute snapshots are stored by provider object ID and tolerate retries and reordering.
139
- A missing webhook stays pending: ask Stripe to resend the event (Dashboard or official CLI
140
- `stripe events resend <event> --webhook-endpoint <endpoint>`). Never grant based on the return URL.
141
- Payment Links are public and reusable; do not promise that local sale dates, repeat-purchase or
142
- eligibility checks revoke a previously opened link. Deactivation stops new sessions after re-sync;
143
- already opened sessions may still complete. Strict pre-payment enforcement needs the keyed way.
144
- Stripe always enables Adaptive Pricing on Payment Links; the buyer can use the original currency.
145
- Keyless has no per-session expiry or provider-read reconciliation. The keyed path retains both,
146
- carts, cap reservations, local refunds. Managed Payments is supported on Payment Links when Stripe enables it for the account. The studio chooses its needs.
141
+ Keyless menus offer several fixed-price items with adjustable quantities on Stripe.
142
+ Custom amounts and monthly preset gifts have their own links. Free orders stay local.
143
+ A missing webhook stays pending; ask Stripe to resend it. Never grant from a return
144
+ URL. Keyless has no exclusive unpaid hold or custom session expiry. Consult SHOP.md
145
+ for menu limits, signed invoice fulfilment, slot sync and honest pending/full wording.
147
146
 
148
147
  For a cause, use `shop init --giving --currency <organisation currency>`: one-time and monthly suggested gifts, no badge or game access. Read `giving.receiptText` from the organisation; leave it empty until provided. Local /shop/ previews before Stripe. Monthly gifts use the existing keyless Payment Links connection and Stripe handles renewals.
149
148
 
@@ -152,3 +151,25 @@ For a cause, use `shop init --giving --currency <organisation currency>`: one-ti
152
151
  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.
153
152
 
154
153
  For referrals, use Stripe Connect to split payments or a Stripe-based affiliate service; Homie builds none.
154
+
155
+ ## Earning with the keyless default
156
+
157
+ Read SHOP.md for a round to table 7, `shop.book` and $20 monthly giving.
158
+ Menus let guests choose several quantities on Stripe; signed invoices provide actual
159
+ paid lines and the app reference. Slot Payment Links enforce completed-session
160
+ capacity. Pending guests do not block others. The owner's AI syncs published slots
161
+ with `stripe_bookings_sync`; the app shows Full from verified availability.
162
+ Recurring Prices provide monthly preset gifts and memberships. Connect enables the
163
+ no-code customer portal login through Stripe's API, without a Dashboard toggle.
164
+ Paid invoice periods govern membership; failed renewal never extends it.
165
+ The giving starter opens real keyless Checkout and shows a disabled preview before
166
+ connection. It never counts an intention as a gift.
167
+
168
+ For real sandbox acceptance use `startKeylessStripeSandbox` from the installed
169
+ `lib/dev-stripe-sandbox.mjs` as documented in SHOP.md. Only its explicitly authorised
170
+ sandbox CLI profile creates Stripe objects. No Worker API key. Keep returned signing
171
+ secret bindings private and temporary. Complete payments with Stripe's published
172
+ CLI fixture/API tokens; never enter card digits into a page. The local listener is
173
+ for development; production sends signed events directly to the studio's webhook.
174
+ The optional keyed fixture remains available for testing explicitly selected session
175
+ checkout. Label offline fixtures simulations.
@@ -0,0 +1,37 @@
1
+ /** Offline Checkout fixture for local acceptance. No Stripe account, cards, or
2
+ * real credentials. This is a simulator, not evidence of Stripe sandbox access. */
3
+ import { createServer } from 'node:http';
4
+ import { signPayload } from '../worker/stripe.mjs';
5
+ export async function startStripeFixture({ origin }) {
6
+ const target=new URL(origin);
7
+ if (target.protocol!=='http:' || !['localhost','127.0.0.1'].includes(target.hostname)) throw new Error('Fixture requires a loopback studio');
8
+ const sessions=new Map(), idempotency=new Map(); let seq=0, base;
9
+ const key='rk_test_homieLocalFixture', secret='whsec_homieLocalFixture';
10
+ const server=createServer(async(req,res)=>{
11
+ const send=(data,status=200)=>{res.writeHead(status,{'content-type':'application/json'});res.end(JSON.stringify(data));};
12
+ try {
13
+ const url=new URL(req.url,base);let body='';for await(const chunk of req){body+=chunk;if(body.length>262144)return send({error:{message:'fixture body limit'}},413);}
14
+ const form=new URLSearchParams(body);
15
+ if(url.pathname==='/v1/checkout/sessions'&&req.method==='GET') return send({data:[...sessions.values()].filter(s=>s.created>=Number(url.searchParams.get('created[gte]')??0)),has_more:false});
16
+ if(url.pathname==='/v1/checkout/sessions'&&req.method==='POST') {
17
+ const idem=req.headers['idempotency-key'];if(idem&&idempotency.has(idem))return send(sessions.get(idempotency.get(idem)));
18
+ const id=`cs_fixture_${++seq}`,lines=[];
19
+ for(let i=0;form.has(`line_items[${i}][quantity]`);i++) {const p=`line_items[${i}]`;lines.push({quantity:Number(form.get(`${p}[quantity]`)),amount_subtotal:Number(form.get(`${p}[quantity]`))*Number(form.get(`${p}[price_data][unit_amount]`)),name:form.get(`${p}[price_data][product_data][name]`)});}
20
+ const s={id,object:'checkout.session',livemode:false,mode:form.get('mode'),status:'open',payment_status:'unpaid',created:Math.floor(Date.now()/1000),expires_at:Number(form.get('expires_at')),currency:form.get('line_items[0][price_data][currency]'),amount_subtotal:lines.reduce((n,l)=>n+l.amount_subtotal,0),metadata:{homie:form.get('metadata[homie]'),order:form.get('metadata[order]')},client_reference_id:form.get('client_reference_id'),url:`${base}/checkout/${id}`,success_url:form.get('success_url'),cancel_url:form.get('cancel_url'),lines};
21
+ s.amount_total=s.amount_subtotal;sessions.set(id,s);if(idem)idempotency.set(idem,id);return send(s);
22
+ }
23
+ const api=/^\/v1\/checkout\/sessions\/(cs_fixture_\d+)(\/expire)?$/.exec(url.pathname);
24
+ if(api){const s=sessions.get(api[1]);if(!s)return send({error:{code:'resource_missing'}},404);if(api[2]){if(s.status!=='open')return send({error:{message:'Session is not open'}},400);s.status='expired';await hook(s,'checkout.session.expired');}return send(s);}
25
+ const page=/^\/checkout\/(cs_fixture_\d+)$/.exec(url.pathname);
26
+ if(page){const s=sessions.get(page[1]);if(!s)return send({},404);res.writeHead(200,{'content-type':'text/html'});return res.end(`<!doctype html><meta name="viewport" content="width=device-width"><title>Offline Stripe fixture</title><style>body{font:20px system-ui;max-width:32em;margin:3em auto;padding:1em}button{padding:1em;font:inherit}</style><h1>Offline Stripe test fixture</h1><p>This is a simulation. No card or money is used.</p><p>${s.amount_total/100} ${s.currency.toUpperCase()}</p><form method="post" action="/complete/${s.id}"><button>Simulate successful test payment</button></form><form method="post" action="/expire/${s.id}"><button>Simulate expiry</button></form>`);}
27
+ const finish=/^\/(complete|expire)\/(cs_fixture_\d+)$/.exec(url.pathname);
28
+ if(finish&&req.method==='POST'){const s=sessions.get(finish[2]);if(!s||s.status!=='open')return send({error:'not open'},409);const paid=finish[1]==='complete';s.status=paid?'complete':'expired';s.payment_status=paid?'paid':'unpaid';if(paid){s.payment_intent={id:`pi_fixture${seq}`,status:'succeeded',latest_charge:{id:`ch_fixture${seq}`,created:Math.floor(Date.now()/1000),refunded:false,disputed:false}};if(s.mode==='subscription'){s.subscription=`sub_fixture${seq}`;s.customer=`cus_fixture${seq}`;}}await hook(s,paid?'checkout.session.completed':'checkout.session.expired');res.writeHead(303,{location:(paid?s.success_url:s.cancel_url).replace('{CHECKOUT_SESSION_ID}',s.id)});return res.end();}
29
+ if(url.pathname.startsWith('/v1/payment_intents/')) { const payment=[...sessions.values()].map(s=>s.payment_intent).find(p=>p?.id===url.pathname.split('/').at(-1));return payment?send(payment):send({error:{code:'resource_missing'}},404); }
30
+ if(url.pathname==='/v1/refunds')return send({data:[],has_more:false});
31
+ return send({error:{message:'Unsupported offline fixture operation'}},404);
32
+ }catch{send({error:{message:'Offline fixture failed'}},500);}
33
+ });
34
+ async function hook(session,type){const event={id:`evt_fixture_${session.id}_${type}`,type,livemode:false,created:Math.floor(Date.now()/1000),data:{object:session}};const payload=JSON.stringify(event),t=event.created;const response=await fetch(`${target.origin}/api/shop/hook`,{method:'POST',headers:{'stripe-signature':`t=${t},v1=${await signPayload(payload,secret,t)}`},body:payload});if(!response.ok)throw new Error('Studio rejected fixture webhook');}
35
+ await new Promise(resolve=>server.listen(0,'127.0.0.1',resolve));base=`http://127.0.0.1:${server.address().port}`;
36
+ return {base,bindings:{STRIPE_CHECKOUT_MODE: 'sessions', STRIPE_KEY:key,STRIPE_WEBHOOK_SECRET:secret,STRIPE_API_BASE:base},close:()=>new Promise(resolve=>server.close(resolve))};
37
+ }