@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.
@@ -0,0 +1,80 @@
1
+ /** Local acceptance adapter. Stripe's CLI retains its credentials and forwards
2
+ * genuine sandbox events. Never deploy this adapter: the desktop is test-only. */
3
+ import { createServer } from 'node:http';
4
+ import { spawn } from 'node:child_process';
5
+ import { randomBytes } from 'node:crypto';
6
+ import { redactStripe } from '../worker/stripe.mjs';
7
+ import { stripeProcess } from './stripe-connect.mjs';
8
+ export async function startStripeSandbox({ origin, profile }) {
9
+ const target = new URL(origin);
10
+ if (target.protocol !== 'http:' || !['127.0.0.1','localhost'].includes(target.hostname)) throw new Error('Sandbox acceptance requires loopback');
11
+ if (!profile || typeof profile !== 'string') throw new Error('An explicit Stripe sandbox profile is required');
12
+ const call = async (method,path,params=new URLSearchParams(),idempotency) => {
13
+ const args=['--project-name',profile,method.toLowerCase(),path,'--stripe-version','2025-03-31.basil'];
14
+ if(method.toUpperCase()==='DELETE')args.push('--confirm');
15
+ for(const [k,v] of params)args.push('-d',`${k}=${v}`);
16
+ if(idempotency)args.push('--idempotency',idempotency);
17
+ const r=await stripeProcess(args);let body;try{body=JSON.parse(r.stdout);}catch{}
18
+ if(r.code!==0 || body?.error || !body) throw new Error('Stripe sandbox request failed: '+redactStripe(body?.error?.message ?? r.stderr ?? 'no response').slice(0,600));
19
+ return body;
20
+ };
21
+ // A mode-bearing object verifies test context without reading any credential.
22
+ const probe=await call('GET','/v1/prices',new URLSearchParams({limit:'1'}));
23
+ if(probe.data?.some(p=>p.livemode))throw new Error('Sandbox adapter refuses live data');
24
+ const key=`rk_test_${randomBytes(24).toString('hex')}`;
25
+ const server=createServer(async(req,res)=>{
26
+ const send=(value,status=200)=>{res.writeHead(status,{'content-type':'application/json'});res.end(JSON.stringify(value));};
27
+ if(req.headers.authorization!==`Bearer ${key}`)return send({error:{message:'Unauthorized sandbox adapter'}},401);
28
+ try{
29
+ const url=new URL(req.url,'http://127.0.0.1');
30
+ if(!url.pathname.startsWith('/v1/')||!['GET','POST','DELETE'].includes(req.method))return send({},404);
31
+ let body='';for await(const chunk of req){body+=chunk;if(body.length>262144)return send({},413);}
32
+ const data=await call(req.method,url.pathname,req.method==='GET'?url.searchParams:new URLSearchParams(body),req.headers['idempotency-key']);
33
+ if(data.livemode===true)throw new Error('Live object refused');
34
+ send(data);
35
+ }catch{send({error:{message:'Stripe sandbox adapter request failed'}},502);}
36
+ });
37
+ await new Promise(r=>server.listen(0,'127.0.0.1',r));
38
+ const childEnv={...process.env};for(const k of Object.keys(childEnv))if(k.startsWith('STRIPE_'))delete childEnv[k];
39
+ const listener=spawn('stripe',['--project-name',profile,'listen','--events','payment_link.updated,checkout.session.completed,checkout.session.expired,invoice.paid,invoice.payment_failed,customer.subscription.updated,customer.subscription.deleted,charge.refunded,refund.created,refund.updated','--forward-to',`${target.origin}/api/shop/hook`],{env:childEnv,stdio:['ignore','pipe','pipe']});
40
+ const close=async()=>{listener.kill();await new Promise(r=>server.close(r));};
41
+ try {
42
+ const secret=await new Promise((resolve,reject)=>{
43
+ let buffer='';const timer=setTimeout(()=>reject(new Error('Stripe listener did not become ready')),30000);
44
+ const collect=b=>{buffer=(buffer+b).slice(-16000);const match=/whsec_[A-Za-z0-9]+/.exec(buffer);if(match){clearTimeout(timer);resolve(match[0]);}};
45
+ listener.stdout.on('data',collect);listener.stderr.on('data',collect);
46
+ listener.on('error',()=>{clearTimeout(timer);reject(new Error('Stripe listener failed'));});
47
+ listener.on('exit',()=>{clearTimeout(timer);reject(new Error('Stripe listener exited: '+redactStripe(buffer).slice(-1200)));});
48
+ });
49
+ return {bindings:{STRIPE_CHECKOUT_MODE: 'sessions', STRIPE_KEY:key,STRIPE_WEBHOOK_SECRET:secret,STRIPE_API_BASE:`http://127.0.0.1:${server.address().port}`},call,close};
50
+ }catch(e){await close();throw e;}
51
+ }
52
+
53
+ /** Local keyless acceptance: only links and the listener's signing secret reach
54
+ * the Worker. Object sync uses the approved CLI directly, never an API adapter. */
55
+ export async function startKeylessStripeSandbox({origin,profile,shop,bookingSlots=[],name='keyless-dev'}) {
56
+ const {syncPaymentLinks}=await import('./shop-links.mjs');
57
+ const {checkShop}=await import('../worker/shop-rules.mjs');
58
+ const {formEncode}=await import('../worker/stripe.mjs');
59
+ const checked=checkShop(shop);
60
+ if(!checked.ok)throw new Error('Check shop.json before sandbox setup');
61
+ const sandbox=await startStripeSandbox({origin,profile});
62
+ let links,endpoint;
63
+ const api=(method,path,params={},idempotency)=>sandbox.call(method,path,formEncode(params),idempotency);
64
+ const synchronize=async bookingSlots=>{
65
+ const result=await syncPaymentLinks({api,shop:checked.shop,slug:name,site:`https://${name}.example.com`,mode:'test',bookingSlots,
66
+ w:async(args,opts)=>{if(args[1]==='list')return{code:0,stdout:'[]'};if(args[1]==='bulk')links=JSON.parse(opts.input).STRIPE_SHOP_LINKS;return{code:0};},saveReceipt:r=>{endpoint=r.endpoint??endpoint;}});
67
+ if(!result.ok)throw Error('Sandbox Payment Link sync failed');
68
+ // The placeholder endpoint points at an unreachable example address; signed
69
+ // events reach the loopback studio through `stripe listen`. Remove it so
70
+ // repeated syncs never accumulate toward Stripe's per-account endpoint limit.
71
+ if(endpoint){await api('DELETE',`/v1/webhook_endpoints/${endpoint}`);endpoint=undefined;}
72
+ return {links:JSON.parse(links),bindings:{STRIPE_CHECKOUT_MODE:'links',STRIPE_SHOP_LINKS:'base64:'+Buffer.from(links).toString('base64'),STRIPE_WEBHOOK_SECRET:sandbox.bindings.STRIPE_WEBHOOK_SECRET}};
73
+ };
74
+ try {
75
+ const client={...await synchronize(bookingSlots),call:api,
76
+ sync:async slots=>{Object.assign(client,await synchronize(slots));return client;},
77
+ close:async()=>{try{const config=JSON.parse(links);for(const link of [...config.items,...config.menus,...config.slots])await api('POST',`/v1/payment_links/${link.id}`,{active:false});}finally{await sandbox.close();}}};
78
+ return client;
79
+ }catch(e){await sandbox.close();throw e;}
80
+ }
package/lib/mcp-tools.mjs CHANGED
@@ -20,7 +20,7 @@ import { providerKey } from './provider-key.mjs';
20
20
  import { prepareNode } from './prepare.mjs';
21
21
  import { studioDomain } from './domain.mjs';
22
22
  import { spawnSync } from 'node:child_process';
23
- import { copyFileSync, existsSync, mkdirSync, readdirSync, readFileSync, realpathSync, renameSync, rmSync, statSync } from 'node:fs';
23
+ import { copyFileSync, existsSync, mkdirSync, readdirSync, readFileSync, realpathSync, renameSync, rmSync, statSync, writeFileSync } from 'node:fs';
24
24
  import { homedir } from 'node:os';
25
25
  import { basename, dirname, extname, isAbsolute, join, relative, resolve, sep } from 'node:path';
26
26
  import { renderCodex, newCodex } from './codex.mjs';
@@ -1177,6 +1177,20 @@ export function toolDefs(ctx, avail = {}) {
1177
1177
  return startRun(ctx, root, { kind: 'deploy', game: null, title: `${studio.name}: online`, steps: [{ label: 'deploy', args: ['deploy', ...(a.ownRoute === true ? ['--own-route'] : [])] }] });
1178
1178
  },
1179
1179
  },
1180
+ {
1181
+ name:'stripe_bookings_sync', title:'Sync published booking slots to Stripe',
1182
+ description:'Owner AI: after reading published slot records, supply the complete slot manifest. Creates/updates Stripe Payment Links with completed-session capacity limits through the approved CLI profile. Before moving a booking, sync target active:false to deactivate and expire open sessions. After the versioned staff move, sync current confirmed counts for affected slots to restore the remaining budget. Removed slots deactivate and expire open sessions. No Worker API key. Follow studio_job. Never increase capacity merely to reopen a full slot.',
1183
+ inputSchema:{type:'object',properties:{...STUDIO_ARG,slots:{type:'array',items:{type:'object',properties:{app:{type:'string'},collection:{type:'string'},slots:{type:'string'},slot:{type:'string'},item:{type:'string'},capacity:{type:'integer',minimum:0},confirmed:{type:'integer',minimum:0},active:{type:'boolean'}},required:['app','collection','slots','slot','item','capacity'],additionalProperties:false}},live:{type:'boolean'}},required:['slots']},
1184
+ annotations:{readOnlyHint:false,destructiveHint:false,openWorldHint:true},
1185
+ run:async a=>{
1186
+ const root=ctx.root(a.studio);
1187
+ mkdirSync(join(root,'.studio'),{recursive:true});
1188
+ writeFileSync(join(root,'.studio','booking-slots.json'),JSON.stringify(a.slots,null,2));
1189
+ const job=cliJob(root,'Sync Stripe booking links',['shop','connect',...(a.live?['--live']:[])],{cli:join(PACKAGE_ROOT,'bin','homie-studio.mjs'),keep:false});
1190
+ await waitJob(job,Math.min(ctx.waitMs,1000));
1191
+ return ok('Booking link sync started. Check studio_job for completion.',{kind:'job',...jobView(job)});
1192
+ },
1193
+ },
1180
1194
  {
1181
1195
  name: 'stripe_login', title: 'Connect the studio to Stripe',
1182
1196
  description: 'Connect and sync shop.json through Stripe’s official CLI with one browser approval if needed. Creates Products, Prices, Payment Links and the webhook; installs only links and signing secret, no API key in the Worker. Repeat after edits. Follow studio_job. An existing key supports fuller checkout; manual only if explicitly chosen for those features. live only when the owner says go live.',
package/lib/scaffold.mjs CHANGED
@@ -1,4 +1,6 @@
1
1
  import {TOOL_DATABASE_MIGRATION,TOOL_DATABASE_MIGRATION_FILE} from '../worker/tool-database.mjs';
2
+ import { SUBSCRIPTIONS_MIGRATION, SUBSCRIPTIONS_MIGRATION_FILE } from '../worker/shop-subscriptions.mjs';
3
+ import { BOOKINGS_MIGRATION, BOOKINGS_MIGRATION_FILE } from '../worker/booking-store.mjs';
2
4
  import { FUNCTIONS_RUNS_MIGRATION, FUNCTIONS_RUNS_MIGRATION_FILE, FUNCTIONS_REFERENCE_MIGRATION, FUNCTIONS_REFERENCE_MIGRATION_FILE, FUNCTIONS_META_MIGRATION, FUNCTIONS_META_MIGRATION_FILE, FUNCTIONS_MIGRATION, FUNCTIONS_MIGRATION_FILE, FUNCTIONS_CURSOR_MIGRATION, FUNCTIONS_CURSOR_MIGRATION_FILE } from '../worker/functions-schema.mjs';
3
5
  import { MCP_MIGRATION, MCP_MIGRATION_FILE } from '../worker/mcp-store.mjs';
4
6
  import { APPS_MIGRATION, APPS_MIGRATION_FILE } from '../worker/app-records.mjs';
@@ -822,6 +824,8 @@ export { default, Table, Lobby, Gate, Concentrator } from '@homie-rocks/studio/w
822
824
  [`site/migrations/${SHOP_REMOVE_REFERRALS_FILE}`]: SHOP_REMOVE_REFERRALS,
823
825
  [`site/migrations/${APPS_MIGRATION_FILE}`]: APPS_MIGRATION,
824
826
  [`site/migrations/${TOOL_DATABASE_MIGRATION_FILE}`]: TOOL_DATABASE_MIGRATION,
827
+ [`site/migrations/${BOOKINGS_MIGRATION_FILE}`]: BOOKINGS_MIGRATION,
828
+ [`site/migrations/${SUBSCRIPTIONS_MIGRATION_FILE}`]: SUBSCRIPTIONS_MIGRATION,
825
829
  [`site/migrations/${MCP_MIGRATION_FILE}`]: MCP_MIGRATION,
826
830
  [`site/migrations/${FUNCTIONS_MIGRATION_FILE}`]: FUNCTIONS_MIGRATION,
827
831
  [`site/migrations/${FUNCTIONS_CURSOR_MIGRATION_FILE}`]: FUNCTIONS_CURSOR_MIGRATION,
@@ -999,6 +1003,16 @@ function ensurePartsStateMigration(root) {
999
1003
  return `site/migrations/${PURCHASE_STATE_FILE}`;
1000
1004
  }
1001
1005
 
1006
+ function ensureSubscriptions(root) {
1007
+ const file=join(root,'site','migrations',SUBSCRIPTIONS_MIGRATION_FILE);
1008
+ if(existsSync(file))return null;
1009
+ mkdirSync(dirname(file),{recursive:true});writeFileSync(file,SUBSCRIPTIONS_MIGRATION);return `site/migrations/${SUBSCRIPTIONS_MIGRATION_FILE}`;
1010
+ }
1011
+ function ensureBookings(root) {
1012
+ const file=join(root,'site','migrations',BOOKINGS_MIGRATION_FILE);
1013
+ if(existsSync(file))return null;
1014
+ mkdirSync(dirname(file),{recursive:true});writeFileSync(file,BOOKINGS_MIGRATION);return `site/migrations/${BOOKINGS_MIGRATION_FILE}`;
1015
+ }
1002
1016
  function ensureFunctionRuns(root) {
1003
1017
  const file=join(root,'site','migrations',FUNCTIONS_RUNS_MIGRATION_FILE);
1004
1018
  if(existsSync(file))return null;
@@ -1035,7 +1049,7 @@ function ensureToolDatabase(root) {
1035
1049
  mkdirSync(dirname(file),{recursive:true});writeFileSync(file,TOOL_DATABASE_MIGRATION);return `site/migrations/${TOOL_DATABASE_MIGRATION_FILE}`;
1036
1050
  }
1037
1051
  export function ensureMigrations(root) {
1038
- return [ensureStatsMigration(root), ensurePlayersMigration(root), ensureOfficeMigration(root), ensureServersMigration(root), ensureChatMigration(root), ensureShopMigration(root), ensureLoungeMigration(root), ensureShopReservations(root), ensureShopLines(root), ensureAppsMigration(root), ensurePartsMigration(root), ensurePartsStateMigration(root), ensureMcpMigration(root), ensureFunctionsMigration(root), ensureFunctionCursors(root), ensureFunctionMeta(root), ensureFunctionReference(root), ensureShopRemoveReferrals(root), ensureFunctionRuns(root), ensureToolDatabase(root)].filter(Boolean);
1052
+ return [ensureStatsMigration(root), ensurePlayersMigration(root), ensureOfficeMigration(root), ensureServersMigration(root), ensureChatMigration(root), ensureShopMigration(root), ensureLoungeMigration(root), ensureShopReservations(root), ensureShopLines(root), ensureAppsMigration(root), ensurePartsMigration(root), ensurePartsStateMigration(root), ensureMcpMigration(root), ensureFunctionsMigration(root), ensureFunctionCursors(root), ensureFunctionMeta(root), ensureFunctionReference(root), ensureShopRemoveReferrals(root), ensureFunctionRuns(root), ensureToolDatabase(root), ensureBookings(root), ensureSubscriptions(root)].filter(Boolean);
1039
1053
  }
1040
1054
 
1041
1055
  /** What a migration file the template added is for, in a few words (deploy and dev say it). */
@@ -6,7 +6,7 @@ import { LINKS_STRIPE_VERSION, linkSettings, paymentLinkURL } from '../worker/sh
6
6
  import { HOOK_EVENTS, SHOP_HOOK_EVENTS } from './shop.mjs';
7
7
  const hash = (value) => createHash('sha256').update(JSON.stringify(value)).digest('hex');
8
8
  const same = (a, b) => Object.keys(a ?? {}).length === Object.keys(b).length && Object.entries(b).every(([k, v]) => a?.[k] === v);
9
- export async function syncPaymentLinks({ root, api, shop, slug, site, mode, w, receipt, saveReceipt }) {
9
+ export async function syncPaymentLinks({ root, api, shop, slug, site, mode, w, receipt, saveReceipt, bookingSlots = [] }) {
10
10
  const list = async (path, params = {}) => {
11
11
  const all = []; let cursor;
12
12
  do {
@@ -28,15 +28,17 @@ export async function syncPaymentLinks({ root, api, shop, slug, site, mode, w, r
28
28
  const links = await list('/v1/payment_links');
29
29
  const own = (o) => o.metadata?.homie === 'shop-links-v1' && o.metadata?.studio === slug && o.metadata?.origin === site;
30
30
  const config = { v: 1, mode, settings: linkSettings(shop), items: [] };
31
+ const customText=shop.giving?.receiptText ? {custom_text:{submit:{message:shop.giving.receiptText}}} : {};
32
+ const catalogPrices = new Map();
31
33
  const keepPrices = new Set(), keepLinks = new Set(), keepProducts = new Set();
32
34
  for (const item of shop.items) {
33
35
  const id = await productIdOf(slug, item.id);
34
36
  keepProducts.add(id);
35
37
  const metadata = { homie: 'shop-links-v1', studio: slug, origin: site };
36
- const fields = { name: item.name, description: item.blurb ?? '', tax_code: item.taxCode ?? defaultTaxCode(item), metadata, active: true };
38
+ const fields = { name: item.name, ...(item.blurb ? {description:item.blurb} : {}), tax_code: item.taxCode ?? defaultTaxCode(item), metadata, active: true };
37
39
  let product = products.find((p) => p.id === id);
38
40
  if (!product) product = await api('POST', '/v1/products', { id, ...fields }, `product-${hash(id)}`);
39
- else if (!same(product.metadata, metadata) || product.name !== fields.name || (product.description ?? '') !== fields.description || (product.tax_code?.id ?? product.tax_code) !== fields.tax_code || !product.active) await api('POST', `/v1/products/${id}`, fields);
41
+ else if (!same(product.metadata, metadata) || product.name !== fields.name || (product.description ?? '') !== (fields.description ?? '') || (product.tax_code?.id ?? product.tax_code) !== fields.tax_code || !product.active) await api('POST', `/v1/products/${id}`, fields);
40
42
  // Omit an unset minimum: Stripe applies its settlement-currency floor itself.
41
43
  // https://docs.stripe.com/api/prices/create#create_price-custom_unit_amount-minimum
42
44
  const priceFields = { product: id, currency: shop.currency, ...(item.interval ? { recurring: { interval: item.interval } } : {}), tax_behavior: 'exclusive', ...(item.price === 'choose'
@@ -47,9 +49,10 @@ export async function syncPaymentLinks({ root, api, shop, slug, site, mode, w, r
47
49
  if (!price) price = await api('POST', '/v1/prices', { ...priceFields, metadata: { ...metadata, revision } }, `price-${hash({ priceFields, metadata })}`);
48
50
  if (!price.active) await api('POST', `/v1/prices/${price.id}`, { active: true });
49
51
  keepPrices.add(price.id);
52
+ catalogPrices.set(item.id, price.id);
50
53
  if ((product.default_price?.id ?? product.default_price) !== price.id) await api('POST', `/v1/products/${id}`, { default_price: price.id });
51
54
  if (item.price === 0) continue; // Free orders never leave the studio.
52
- const params = { line_items: [{ price: price.id, quantity: 1, ...(item.price !== 'choose' ? { adjustable_quantity: { enabled: shop.policy.repeatPurchases, minimum: 1, maximum: 999999 } } : {}) }],
55
+ const params = { ...customText, line_items: [{ price: price.id, quantity: 1, ...(item.price !== 'choose' ? { adjustable_quantity: { enabled: shop.policy.repeatPurchases, minimum: 1, maximum: 999999 } } : {}) }],
53
56
  after_completion: { type: 'redirect', redirect: { url: `${site}/shop/thanks?session_id={CHECKOUT_SESSION_ID}` } },
54
57
  ...(shop.till === 'stripe-managed' ? { managed_payments: { enabled: true } } : { automatic_tax: { enabled: shop.automaticTax } }),
55
58
  ...(item.interval ? { subscription_data: { metadata } } : { payment_intent_data: { metadata } }),
@@ -60,7 +63,76 @@ export async function syncPaymentLinks({ root, api, shop, slug, site, mode, w, r
60
63
  if (!link.active) await api('POST', `/v1/payment_links/${link.id}`, { active: true });
61
64
  if (!paymentLinkURL(link.url) || !/^plink_\w+$/.test(link.id)) throw new Error('Invalid Payment Link');
62
65
  keepLinks.add(link.id);
63
- config.items.push({ item: item.id, id: link.id, url: link.url, revision: linkRevision });
66
+ config.items.push({ item: item.id, id: link.id, url: link.url, price: price.id, revision: linkRevision });
67
+ }
68
+ // Menus use paid invoice snapshots: Checkout events omit their line items.
69
+ // Stripe allows 20 Payment Link lines; invoice snapshots include only the first
70
+ // 10. Split catalogues so keyless fulfillment needs no authenticated pagination.
71
+ config.menus = [];
72
+ const menuItems = shop.items.filter(i => typeof i.price === 'number' && i.price > 0 && !i.interval);
73
+ const metadata = { homie: 'shop-links-v1', studio: slug, origin: site };
74
+ const groups=Map.groupBy(menuItems,i=>i.game??'');
75
+ for(const group of groups.values()) for (let offset = 0; offset < group.length; offset += 10) {
76
+ const items = group.slice(offset, offset + 10);
77
+ if (items.length < 2) continue;
78
+ const params = { ...customText, line_items: items.map(i => ({price:catalogPrices.get(i.id),quantity:1,adjustable_quantity:{enabled:true,minimum:0,maximum:shop.policy.repeatPurchases?999999:1}})),
79
+ invoice_creation:{enabled:true,invoice_data:{metadata}},
80
+ after_completion:{type:'redirect',redirect:{url:`${site}/shop/thanks?session_id={CHECKOUT_SESSION_ID}`}},
81
+ automatic_tax:{enabled:shop.automaticTax}, payment_intent_data:{metadata} };
82
+ const revision=hash(params);
83
+ let link=links.find(p=>own(p)&&p.metadata?.revision===revision);
84
+ if(!link)link=await api('POST','/v1/payment_links',{...params,metadata:{...metadata,revision}},`menu-${revision}`);
85
+ if(!link.active)await api('POST',`/v1/payment_links/${link.id}`,{active:true});
86
+ keepLinks.add(link.id);
87
+ config.menus.push({id:link.id,url:link.url,revision,items:items.map(i=>({item:i.id,price:catalogPrices.get(i.id)}))});
88
+ }
89
+ config.slots=[];
90
+ const pause=async link=>{
91
+ await api('POST',`/v1/payment_links/${link.id}`,{active:false});
92
+ for(const session of await list('/v1/checkout/sessions',{payment_link:link.id,status:'open'})) {
93
+ await api('POST',`/v1/checkout/sessions/${session.id}/expire`);
94
+ }
95
+ };
96
+ const slotKeys=new Set();
97
+ for(const slot of bookingSlots) {
98
+ if(!['app','collection','slots','slot','item'].every(k=>typeof slot[k]==='string'&&/^[a-zA-Z0-9_-]{1,64}$/.test(slot[k])) || !Number.isSafeInteger(slot.capacity) || slot.capacity<0 || slot.confirmed!==undefined && (!Number.isSafeInteger(slot.confirmed)||slot.confirmed<0||slot.confirmed>slot.capacity))throw new Error('Invalid booking slot manifest');
99
+ const resource=JSON.stringify([slot.app,slot.slots,slot.slot]);
100
+ if(slotKeys.has(resource))throw new Error('Publish only one service Payment Link per capacity slot');
101
+ slotKeys.add(resource);
102
+ const item=shop.items.find(i=>i.id===slot.item);
103
+ if(!item || item.interval || item.price==='choose')throw new Error('Bookings require a fixed one-time service price');
104
+ if(!item.price)continue;
105
+ const identity=hash({slug,site,app:slot.app,collection:slot.collection,slots:slot.slots,slot:slot.slot});
106
+ let link=links.find(p=>own(p)&&p.metadata?.slot===identity);
107
+ if(!link && (!slot.capacity||slot.active===false))continue;
108
+ if(link?.metadata?.price && link.metadata.price!==catalogPrices.get(item.id))throw new Error('Slot service price changed. Deactivate the old slot and publish a new slot ID after reviewing existing bookings.');
109
+ if(slot.confirmed!==undefined && link?.active && slot.active!==false)throw new Error('Pause affected slot links before recounting confirmed seats; wait for signed payments, move/refund, then sync the fresh counts.');
110
+ const count=link?.restrictions?.completed_sessions?.count??0;
111
+ // Initial publication uses capacity exactly. After a reviewed move/refund the
112
+ // AI supplies current confirmed seats; keep the existing Stripe lifetime count.
113
+ const limit=slot.confirmed===undefined ? slot.capacity : count+slot.capacity-slot.confirmed;
114
+ const active=slot.active!==false && limit>count;
115
+ const params={...customText,line_items:[{price:catalogPrices.get(item.id),quantity:1}],payment_method_types:['card'],
116
+ restrictions:{completed_sessions:{limit:Math.max(1,limit)}},after_completion:{type:'redirect',redirect:{url:`${site}/shop/thanks?session_id={CHECKOUT_SESSION_ID}`}}};
117
+ if(!link)link=await api('POST','/v1/payment_links',{...params,metadata:{...metadata,slot:identity,revision:identity,price:catalogPrices.get(item.id)}},`slot-${identity}`);
118
+ else {
119
+ // Pause first whenever publishing a changed budget, so old sessions cannot
120
+ // spend a previous capacity after staff moves or removes a slot.
121
+ if(!active || link.restrictions?.completed_sessions?.limit!==limit)await pause(link);
122
+ link=await api('POST',`/v1/payment_links/${link.id}`,{restrictions:params.restrictions,active});
123
+ }
124
+ keepLinks.add(link.id);
125
+ config.slots.push({...slot,id:link.id,url:link.url,price:catalogPrices.get(item.id),revision:identity,active:link.active,count:link.restrictions?.completed_sessions?.count??0,limit,paused:slot.active===false});
126
+ }
127
+ // Retired booking links must be closed before publishing a configuration which
128
+ // no longer names them; staff can then move into that local-only slot safely.
129
+ for(const link of links.filter(p=>own(p)&&p.metadata?.slot&&!keepLinks.has(p.id)))await pause(link);
130
+ if(shop.items.some(i=>i.interval)) {
131
+ const portals=await list('/v1/billing_portal/configurations');
132
+ let portal=portals.find(p=>own(p));
133
+ const params={features:{subscription_cancel:{enabled:true},payment_method_update:{enabled:true},invoice_history:{enabled:true}},login_page:{enabled:true},business_profile:{headline:'Manage your membership or giving'},default_return_url:`${site}/shop/`,metadata};
134
+ portal=await api('POST',portal?`/v1/billing_portal/configurations/${portal.id}`:'/v1/billing_portal/configurations',params);
135
+ config.portal=portal.login_page?.url;
64
136
  }
65
137
  const purchases = root ? await (await import('./purchase-links.mjs')).syncPurchaseLinks({ root, api, slug, site, mode, list, till: shop.purchasesTill ?? shop.till }) : null;
66
138
  const purchaseSecrets = {};
@@ -69,7 +141,7 @@ export async function syncPaymentLinks({ root, api, shop, slug, site, mode, w, r
69
141
  const pair = await generateKeyPair('Ed25519', { extractable: true });
70
142
  purchaseSecrets.PURCHASE_SIGNING_KEYS = JSON.stringify([await exportJWK(pair.privateKey)]);
71
143
  }
72
- const events = purchases?.config.offers.length ? HOOK_EVENTS : SHOP_HOOK_EVENTS;
144
+ const events = purchases?.config.offers.length ? [...new Set([...HOOK_EVENTS,...SHOP_HOOK_EVENTS])] : SHOP_HOOK_EVENTS;
73
145
  const endpoints = await list('/v1/webhook_endpoints');
74
146
  const url = `${site}/api/shop/hook`;
75
147
  const current = endpoints.find((e) => e.id === receipt?.endpoint && e.url === url && e.status === 'enabled' && events.every((event) => e.enabled_events?.includes(event)));
@@ -84,7 +156,7 @@ export async function syncPaymentLinks({ root, api, shop, slug, site, mode, w, r
84
156
  const fingerprint = hash({config, purchases:purchases?.config});
85
157
  const unchanged = current && !secret && receipt?.fingerprint === fingerprint && has('STRIPE_SHOP_LINKS') && !has('STRIPE_KEY');
86
158
  if (!unchanged) {
87
- const saved = await w(['secret', 'bulk'], { input: JSON.stringify({ STRIPE_SHOP_LINKS: JSON.stringify(config), ...purchaseSecrets, ...(purchases && (purchases.config.offers.length || has('PURCHASE_LINKS')) ? { PURCHASE_LINKS: JSON.stringify(purchases.config) } : {}), ...(secret ? { STRIPE_WEBHOOK_SECRET: secret } : {}) }) });
159
+ const saved = await w(['secret', 'bulk'], { input: JSON.stringify({ STRIPE_CHECKOUT_MODE: 'links', STRIPE_SHOP_LINKS: JSON.stringify(config), ...purchaseSecrets, ...(purchases && (purchases.config.offers.length || has('PURCHASE_LINKS')) ? { PURCHASE_LINKS: JSON.stringify(purchases.config) } : {}), ...(secret ? { STRIPE_WEBHOOK_SECRET: secret } : {}) }) });
88
160
  if (saved.code !== 0) return { ok: false, needs: 'cloudflare', why: 'Cloudflare could not save the shop connection. Run shop connect again; existing webhooks remain active.' };
89
161
  // Explicitly running connect selects keyless; manual selects the existing keyed checkout.
90
162
  if (has('STRIPE_KEY') && (await w(['secret', 'delete', 'STRIPE_KEY'], { input: 'y\n' })).code !== 0) return { ok: false, needs: 'cloudflare', why: 'The links are ready; run shop connect again to finish removing the old checkout credential.' };
package/lib/shop.mjs CHANGED
@@ -63,6 +63,7 @@ export const KEY_PERMISSIONS = Object.freeze([
63
63
  ]);
64
64
 
65
65
  export const SHOP_HOOK_EVENTS = Object.freeze([
66
+ 'payment_link.updated', 'invoice.paid', 'invoice.payment_failed', 'customer.subscription.updated', 'customer.subscription.deleted',
66
67
  'checkout.session.completed', 'checkout.session.async_payment_succeeded', 'checkout.session.async_payment_failed', 'checkout.session.expired',
67
68
  'charge.refunded', 'refund.created', 'refund.updated', 'refund.failed', 'charge.dispute.created', 'charge.dispute.closed',
68
69
  ]);
@@ -114,7 +115,7 @@ export function shopInit(root, { supporter = false, giving = false, currency = n
114
115
  const shop = {
115
116
  ...(giving ? { giving: { receiptText: '' } } : {}),
116
117
  till: managed ? 'stripe-managed' : 'stripe', currency: String(currency ?? studio.currency ?? 'usd').toLowerCase(),
117
- items: giving ? [1000, 2500, 5000].flatMap(amount => [false, true].map(monthly => ({ id: `gift-${amount}${monthly ? '-monthly' : ''}`, kind: 'donation', name: monthly ? 'Monthly gift' : 'One-time gift', price: amount, ...(monthly ? { interval: 'month' } : {}), gives: [], blurb: monthly ? 'Give this amount each month. Manage your gift through Stripe.' : 'A gift to support our work.' }))) : supporter ? [supporterItem({ price: Number(price) })] : [],
118
+ items: giving ? [1000, 2000, 2500, 5000].flatMap(amount => [false, true].map(monthly => ({ id: `gift-${amount}${monthly ? '-monthly' : ''}`, kind: 'donation', name: monthly ? 'Monthly gift' : 'One-time gift', price: amount, ...(monthly ? { interval: 'month' } : {}), gives: [], blurb: monthly ? 'Give this amount each month. Manage your gift through Stripe.' : 'A gift to support our work.' }))) : supporter ? [supporterItem({ price: Number(price) })] : [],
118
119
  };
119
120
  const r = checkShop(shop, { games: listGames(root).map((g) => g.id) });
120
121
  if (!r.ok) return { ok: false, command: 'shop init', why: r.errors.map((e) => e.message).join('; ') };
@@ -323,7 +324,8 @@ export async function shopConnectManual(root, { fromClipboard = false, clipboard
323
324
  const machineNote = ` Machine payments: ${Object.entries(machine.machine).map(([name, enabled]) => `${name}: ${enabled ? 'permission check passed' : machine.reasons[name] ?? 'unavailable'}`).join('; ')}.`;
324
325
  finished = true;
325
326
  const a = w(['secret', 'put', 'STRIPE_KEY'], { input: `${key}\n` });
326
- const b = a.code === 0 ? w(['secret', 'put', 'STRIPE_WEBHOOK_SECRET'], { input: `${hook}\n` }) : { code: 1 };
327
+ const selected = a.code === 0 ? w(['secret','put','STRIPE_CHECKOUT_MODE'],{input:'sessions\n'}) : {code:1};
328
+ const b = selected.code === 0 ? w(['secret', 'put', 'STRIPE_WEBHOOK_SECRET'], { input: `${hook}\n` }) : { code: 1 };
327
329
  hook = '';
328
330
  const readiness = { ...machine, key: createHash('sha256').update(key).digest('hex'), mode: modeOf(key), recurring: recurringReady };
329
331
  const c = !paidParts ? b : b.code === 0 ? w(['secret', 'put', 'PURCHASE_PAYMENT_CAPABILITIES'], { input: JSON.stringify(readiness) }) : { code: 1 };
@@ -134,6 +134,7 @@ export async function shopConnect(root, options = {}) {
134
134
  const receiptFile = join(dir, `${mode}.json`);
135
135
  const receipt = existsSync(receiptFile) ? json(readFileSync(receiptFile, 'utf8')) : null;
136
136
  const result = await syncPaymentLinks({ root, api, shop: checked.shop, slug: studio.slug, site: target.origin, mode, w, receipt,
137
+ bookingSlots: options.bookingSlots ?? (existsSync(join(root,'.studio','booking-slots.json')) ? JSON.parse(readFileSync(join(root,'.studio','booking-slots.json'),'utf8')) : []),
137
138
  saveReceipt: (record) => {
138
139
  writeFileSync(`${receiptFile}.tmp`, JSON.stringify(record), { mode: 0o600 });
139
140
  renameSync(`${receiptFile}.tmp`, receiptFile);
@@ -3492,6 +3492,48 @@
3492
3492
  }
3493
3493
  }
3494
3494
  }
3495
+ },
3496
+ "0.51.0": {
3497
+ "files": {
3498
+ "CLAUDE.md": "ddf2b2a0376202f6",
3499
+ "README.md": "684b3cca8001673c",
3500
+ "HANDOFF.md": "29337e2ace95b2e7",
3501
+ "games/README.md": "5ad32fe80c61f117",
3502
+ "music/README.md": "a3dc042dadd39df8",
3503
+ "videos/README.md": "16babc3437d5545b",
3504
+ "posts/README.md": "37841aae476bd540",
3505
+ "site/README.md": "1dbdf22c03a1e505",
3506
+ "site/src/worker.mjs": "6cc5c6131eaf6fd0"
3507
+ },
3508
+ "sections": {
3509
+ "AGENTS.md": {
3510
+ "(intro)": "394cbbe934e80722",
3511
+ "## Version-matched authoring guides": "f6261a0e5801b96b",
3512
+ "## Layout": "f3a280b8c96cd068",
3513
+ "## Apps: one screen, roles and parts": "c10aa0441ea89702",
3514
+ "## Commands (all through the pinned CLI in node_modules)": "5222e08ca55e326e",
3515
+ "## The site": "0cb8dd9a6f8a0096",
3516
+ "## Making games": "bb715272fd1a615b",
3517
+ "## The Game Codex and progress": "0738ef719cef3a4c",
3518
+ "## Art direction and models": "a48fff7bf0a5f2fc",
3519
+ "## Running live games (the back office)": "51ebf2ab756f6e4d",
3520
+ "## Servers and AI seats": "1ccfd549d2369a60",
3521
+ "## Selling things (the shop)": "43a3556be93d1235",
3522
+ "## Continuing a build from the Claude app": "0d68081e924ca419",
3523
+ "## Rules": "b2234d80f6cbe71d",
3524
+ "## Telling Homie": "80b46fdd9e578db8",
3525
+ "## Beta": "9d02638207a26e73"
3526
+ }
3527
+ },
3528
+ "named": {
3529
+ "Homie Arcade": {
3530
+ "sections": {
3531
+ "AGENTS.md": {
3532
+ "## Commands (all through the pinned CLI in node_modules)": "611b437f5b3303f0"
3533
+ }
3534
+ }
3535
+ }
3536
+ }
3495
3537
  }
3496
3538
  }
3497
3539
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@homie-rocks/studio",
3
- "version": "0.50.7",
3
+ "version": "0.51.0",
4
4
  "description": "A studio for games, music and video on its own Cloudflare: scaffold, multiplayer starters on the netplay contract, a port toolkit that makes existing single-player web games multiplayer, the site Worker with public rooms (Table/Lobby Durable Objects and D1, all on the free Workers plan, no payment method), a site with the hub's sections (Home, Games, Music, Videos, Rooms, Posts with feeds) in the studio's own look, an epic landing page for every game, big songs and videos in the studio's own R2 once it has storage (uploaded, checked by SHA-256, served at the same addresses), deploy, and the homie.rocks directory.",
5
5
  "type": "module",
6
6
  "license": "Apache-2.0",
package/shop/SELLING.md CHANGED
@@ -15,7 +15,7 @@ A player can refund items within a window you set; a tip stays given unless you
15
15
  Stripe handles payments, receipts, payment limits, held refunds and disputes. Stripe Tax is optional;
16
16
  Managed Payments is another selectable integration. Your office links Stripe's own pages.
17
17
 
18
- `homie-studio shop connect` opens Stripe's approval page if needed, then your AI syncs your products, prices, payment links and webhook. You never handle a key. The shop sends buyers to Stripe, where they confirm quantity or choose a tip; your Worker holds only the webhook signing secret and link settings. Buy different items separately. Refund in Stripe or ask your connected AI; signed events update your shop. A missing payment stays pending until Stripe resends its event. Free orders need no Stripe.
18
+ `homie-studio shop connect` opens Stripe's approval page if needed, then your AI syncs your products, prices, payment links and webhook. You never handle a key. The shop sends buyers to Stripe, where they confirm quantity or choose a tip; your Worker holds only the webhook signing secret and link settings. Menus let guests choose several items and quantities on Stripe. Paid bookings use a capacity-limited link for each slot: opening is pending, payment secures the seat. Monthly presets charge through recurring Stripe Prices, with a Stripe login link to manage or cancel. Refund in Stripe or ask your connected AI; signed events update your shop. A missing payment stays pending until Stripe resends its event. Free orders need no Stripe.
19
19
 
20
- The fuller connection adds combined carts, spending limits, custom checkout expiry, automatic recovery and refunds in the office. An existing Worker key selects it; your AI explains it only if you ask for those features. The optional manual setup remains available, but is not the default.
20
+ The optional fuller connection adds exact app-built carts, spending reservations, exclusive unpaid holds, custom expiry, automatic recovery and office refunds. It needs explicit session mode as well as an existing Worker key; a key alone never selects it. The optional manual setup remains available, but is not the default.
21
21
  The scaffold has no policy, cap or refund window. Test mode is available before choosing live mode.
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 | Buy separately. Stripe supports static multi-item links (up to 20 fixed-price lines), but they are not an arbitrary buyer cart. Dynamic carts use the keyed path. |
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
- An existing `STRIPE_KEY` selects the fuller path automatically. Only if requested, explain:
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 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.
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.