@homie-rocks/studio 0.50.6 → 0.51.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +62 -0
- package/app-starters/giving/README.md +25 -2
- package/app-starters/giving/app.json +16 -49
- package/app-starters/giving/index.html +1 -1
- package/app-starters/giving/src/rules.ts +1 -1
- package/app-starters/giving/src/view.ts +57 -59
- package/apps/APPS.md +70 -2
- package/apps/apps.ts +1 -1
- package/bin/homie-studio.mjs +8 -4
- package/functions/FUNCTIONS.md +39 -2
- package/guides/app/SKILL.md +61 -5
- package/guides/functions/SKILL.md +30 -0
- package/guides/office/SKILL.md +20 -0
- package/guides/servers/SKILL.md +18 -0
- package/guides/shop/SKILL.md +41 -20
- package/lib/app-needs.mjs +31 -11
- package/lib/app-ready.mjs +49 -0
- package/lib/check.mjs +51 -35
- package/lib/dev-stripe-fixture.mjs +37 -0
- package/lib/dev-stripe-sandbox.mjs +80 -0
- package/lib/dev.mjs +19 -17
- package/lib/events-cli.mjs +5 -1
- package/lib/local-test.mjs +18 -0
- package/lib/mcp-tools.mjs +15 -1
- package/lib/office.mjs +1 -1
- package/lib/scaffold.mjs +22 -1
- package/lib/shop-links.mjs +79 -7
- package/lib/shop.mjs +4 -2
- package/lib/stripe-connect.mjs +1 -0
- package/lib/template-history.json +84 -0
- package/lib/tools-cli.mjs +3 -0
- package/package.json +1 -1
- package/shop/SELLING.md +2 -2
- package/shop/SHOP.md +120 -5
- package/shop/shop.ts +29 -0
- package/tools/tools.ts +8 -1
- package/worker/app-format.mjs +8 -1
- package/worker/app-frame.mjs +9 -0
- package/worker/app-records.mjs +39 -10
- package/worker/booking-store.mjs +79 -0
- package/worker/build-id.mjs +1 -1
- package/worker/event-resources.mjs +30 -3
- package/worker/functions.mjs +5 -2
- package/worker/index.mjs +17 -3
- package/worker/local-test.mjs +19 -0
- package/worker/mcp-tools.mjs +19 -8
- package/worker/mcp.mjs +23 -11
- package/worker/records-page.mjs +13 -0
- package/worker/shop-links.mjs +3 -2
- package/worker/shop-page.mjs +27 -5
- package/worker/shop-rules.mjs +2 -1
- package/worker/shop-store.mjs +1 -1
- package/worker/shop-subscriptions.mjs +198 -0
- package/worker/shop.mjs +195 -29
- package/worker/tool-database.mjs +36 -0
- package/worker/version.mjs +1 -1
|
@@ -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/dev.mjs
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import {createLocalCredential} from './local-test.mjs';
|
|
1
2
|
import {workerEntry} from './worker-build.mjs';
|
|
2
3
|
import { networkInterfaces } from 'node:os';
|
|
3
4
|
/**
|
|
@@ -132,7 +133,7 @@ export function devState(root) {
|
|
|
132
133
|
export function runningDev(root) {
|
|
133
134
|
const st = devState(root);
|
|
134
135
|
if (st.state !== 'running' || !st.port) return null;
|
|
135
|
-
return { url: `http://
|
|
136
|
+
return { url: `http://localhost:${st.port}`, port: st.port, pid: st.rec.pid ?? st.pids[0] };
|
|
136
137
|
}
|
|
137
138
|
|
|
138
139
|
async function endAll(pids, ms = 8000) {
|
|
@@ -292,15 +293,14 @@ function passLines(stream, to, opts) {
|
|
|
292
293
|
* saved. `rulesStamp` is what it compares: the newest change among them, and how many there are.
|
|
293
294
|
*/
|
|
294
295
|
export function rulesStamp(g) {
|
|
295
|
-
const
|
|
296
|
-
for
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
return `${n}:${Math.round(newest)}`;
|
|
296
|
+
const rows=[];
|
|
297
|
+
const walk=dir=>{for(const entry of readdirSync(dir,{withFileTypes:true})) {
|
|
298
|
+
if(['node_modules','.git','.wrangler','.studio','.port','.cache','.parcel-cache','.vite','.DS_Store','coverage','dist','codex'].includes(entry.name))continue;
|
|
299
|
+
const file=join(dir,entry.name);
|
|
300
|
+
if(entry.isDirectory())walk(file);
|
|
301
|
+
else if(entry.isFile()){const st=statSync(file);rows.push(`${file}:${st.size}:${st.mtimeMs}:${st.ctimeMs}`);}
|
|
302
|
+
}};
|
|
303
|
+
walk(g.dir);return rows.sort().join('|');
|
|
304
304
|
}
|
|
305
305
|
|
|
306
306
|
const builtIds = (root) => { try { return JSON.parse(readFileSync(join(root, 'site', 'dist', 'games.json'), 'utf8')).games.map((g) => g.id); } catch { return null; } };
|
|
@@ -316,8 +316,8 @@ export async function dev(root, { lan = false, port: askedPort = 8787, remoteAi
|
|
|
316
316
|
// What is really there, before anything is built or started (the header: stale state heals itself).
|
|
317
317
|
const st = devState(root);
|
|
318
318
|
if (st.state === 'running') {
|
|
319
|
-
const at = st.port ? `http://
|
|
320
|
-
return { ok: false, command: 'dev', already: true, ...(st.port ? { url: `http://
|
|
319
|
+
const at = st.port ? `http://localhost:${st.port}/` : 'an unknown port';
|
|
320
|
+
return { ok: false, command: 'dev', already: true, ...(st.port ? { url: `http://localhost:${st.port}` } : {}), why: `this studio's dev server is already running at ${at} (process ${st.pids.join(', ')}). Use that one, or stop it first: ${stop}` };
|
|
321
321
|
}
|
|
322
322
|
if (st.state === 'stale') {
|
|
323
323
|
rmSync(st.file, { force: true });
|
|
@@ -329,7 +329,7 @@ export async function dev(root, { lan = false, port: askedPort = 8787, remoteAi
|
|
|
329
329
|
if (p === Number(port) && left.open) {
|
|
330
330
|
const busy = port;
|
|
331
331
|
do { port = String(Number(port) + 1); } while (Number(port) < 65535 && await portOpen(Number(port)));
|
|
332
|
-
log(`Port ${busy} is busy; using http://
|
|
332
|
+
log(`Port ${busy} is busy; using http://localhost:${port}/`);
|
|
333
333
|
}
|
|
334
334
|
}
|
|
335
335
|
|
|
@@ -348,7 +348,8 @@ export async function dev(root, { lan = false, port: askedPort = 8787, remoteAi
|
|
|
348
348
|
m.on('error', done);
|
|
349
349
|
});
|
|
350
350
|
const origin = `http://127.0.0.1:${port}`;
|
|
351
|
-
|
|
351
|
+
const printedOrigin = `http://localhost:${port}`;
|
|
352
|
+
log(`Local site: ${printedOrigin}/ (each game: ${printedOrigin}/<id>/play — open it in two browsers)`);
|
|
352
353
|
if (lan) for (const address of lanAddresses()) log(`Same Wi-Fi: http://${address}:${port}/ (open this address on phones and wall screens)`);
|
|
353
354
|
const ai = devConfig(root, remoteAi);
|
|
354
355
|
if (ai.note) log(ai.note);
|
|
@@ -362,7 +363,8 @@ export async function dev(root, { lan = false, port: askedPort = 8787, remoteAi
|
|
|
362
363
|
}
|
|
363
364
|
// --remote-ai: Wrangler's --local turns every remote binding off ("not supported"), so a dev with the real Workers AI
|
|
364
365
|
// (the guides' brains, room chat's review) runs without it; everything else stays local all the same.
|
|
365
|
-
const
|
|
366
|
+
const localSecret = createLocalCredential(root);
|
|
367
|
+
const args = ['dev', '--var', `HOMIE_LOCAL_TEST_SECRET:${localSecret}`, ...(remoteAi ? [] : ['--local']), '--ip', lan ? '0.0.0.0' : '127.0.0.1', '--port', port, '--var', 'HOMIE_EMBED_PREVIEW:1', ...ai.args, ...localVars];
|
|
366
368
|
log(`Stop it with: ${stop} (this studio's dev server only)`);
|
|
367
369
|
|
|
368
370
|
let child = null;
|
|
@@ -417,13 +419,13 @@ export async function dev(root, { lan = false, port: askedPort = 8787, remoteAi
|
|
|
417
419
|
} finally { busy = false; }
|
|
418
420
|
};
|
|
419
421
|
// A rules game saved while dev runs is built again (see rulesStamp), one game at a time.
|
|
420
|
-
const stamps = new Map(listGames(root).
|
|
422
|
+
const stamps = new Map(listGames(root).map((g) => [g.id, rulesStamp(g)]));
|
|
421
423
|
const timingRetries = new Map();
|
|
422
424
|
let rebuilding = false;
|
|
423
425
|
let published = Promise.resolve();
|
|
424
426
|
const rulesLook = async () => {
|
|
425
427
|
if (rebuilding || ending) return;
|
|
426
|
-
for (const g of listGames(root)
|
|
428
|
+
for (const g of listGames(root)) {
|
|
427
429
|
const now = rulesStamp(g);
|
|
428
430
|
const retry = timingRetries.get(g.id);
|
|
429
431
|
if (stamps.get(g.id) === now && !(retry?.stamp === now && Date.now() >= retry.at)) continue;
|
package/lib/events-cli.mjs
CHANGED
|
@@ -31,7 +31,11 @@ export async function eventsWebhook(root,action,name,url,{run,connect=connectToo
|
|
|
31
31
|
writeStudio(root,studio);
|
|
32
32
|
return {ok:true,command:'events webhook '+action,feed:name,...(secret?{secret}:{}),next:action==='rotate'?'Key replaced in the Worker. Configure the receiver with this key; it is shown only here.':'Build, commit and deploy the updated feed declaration through your usual authorized workflow.'};
|
|
33
33
|
}
|
|
34
|
-
export function eventsCommand(root,sub,positional,flags,options={}) {
|
|
34
|
+
export async function eventsCommand(root,sub,positional,flags,options={}) {
|
|
35
|
+
if(sub==='clear'||sub==='read') {
|
|
36
|
+
const client=await connectToolClient(root,flags.get('url')??'http://localhost:8787',options);
|
|
37
|
+
try {const result=await client.callTool({name:'studio_events_'+sub,arguments:sub==='clear'?{through:Number(flags.get('through'))}:{feed:positional[2],after:Number(flags.get('after')??0)}});return {ok:!result.isError,result};} finally {await client.close();}
|
|
38
|
+
}
|
|
35
39
|
if(sub!=='webhook')throw new Error('Use events webhook <add|remove|rotate|status|test>');
|
|
36
40
|
return eventsWebhook(root,positional[2],positional[3],positional[4]??flags.get('url'),{...options,after:flags.get('after')??0});
|
|
37
41
|
}
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
import {readFileSync,mkdirSync,writeFileSync,chmodSync} from 'node:fs';
|
|
2
|
+
import {join} from 'node:path';
|
|
3
|
+
import {randomBytes,createHmac} from 'node:crypto';
|
|
4
|
+
export function createLocalCredential(root) {
|
|
5
|
+
const secret=randomBytes(32).toString('hex');
|
|
6
|
+
mkdirSync(join(root,'.studio'),{recursive:true});
|
|
7
|
+
const file=join(root,'.studio','local-test.json');
|
|
8
|
+
writeFileSync(file,JSON.stringify({secret}),{mode:0o600});chmodSync(file,0o600);
|
|
9
|
+
return secret;
|
|
10
|
+
}
|
|
11
|
+
export function localCredential(root,site) {
|
|
12
|
+
if(!root||!['localhost','127.0.0.1','[::1]'].includes(new URL(site).hostname))return null;
|
|
13
|
+
try{return JSON.parse(readFileSync(join(root,'.studio','local-test.json'),'utf8')).secret;}catch{return null;}
|
|
14
|
+
}
|
|
15
|
+
export function localCookie(secret,session) {
|
|
16
|
+
const data=Buffer.from(JSON.stringify(session)).toString('base64url');
|
|
17
|
+
return data+'.'+createHmac('sha256',secret).update(data).digest('hex');
|
|
18
|
+
}
|
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/office.mjs
CHANGED
|
@@ -29,7 +29,7 @@ const GAME = /^[a-z0-9][a-z0-9-]{0,39}$/;
|
|
|
29
29
|
/** Call the studio's office API with a key that lives ten minutes and is dropped as soon as the answer is in. */
|
|
30
30
|
export async function withKey(root, url, fn) {
|
|
31
31
|
const { studio, site } = siteOf(root, url);
|
|
32
|
-
if (!site) return { ok: false, why: 'this studio has no live site yet: `npm run deploy` first, or give --url http://
|
|
32
|
+
if (!site) return { ok: false, why: 'this studio has no live site yet: `npm run deploy` first, or give --url http://localhost:8787 for `npm run dev`' };
|
|
33
33
|
const local = loopback(site);
|
|
34
34
|
const k = mint(root, studio, 'office', 10 * 60_000, { local });
|
|
35
35
|
let expiresAt = Date.now() + 10 * 60_000;
|
package/lib/scaffold.mjs
CHANGED
|
@@ -1,3 +1,6 @@
|
|
|
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';
|
|
1
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';
|
|
2
5
|
import { MCP_MIGRATION, MCP_MIGRATION_FILE } from '../worker/mcp-store.mjs';
|
|
3
6
|
import { APPS_MIGRATION, APPS_MIGRATION_FILE } from '../worker/app-records.mjs';
|
|
@@ -820,6 +823,9 @@ export { default, Table, Lobby, Gate, Concentrator } from '@homie-rocks/studio/w
|
|
|
820
823
|
[`site/migrations/${SHOP_LINES_FILE}`]: SHOP_LINES,
|
|
821
824
|
[`site/migrations/${SHOP_REMOVE_REFERRALS_FILE}`]: SHOP_REMOVE_REFERRALS,
|
|
822
825
|
[`site/migrations/${APPS_MIGRATION_FILE}`]: APPS_MIGRATION,
|
|
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,
|
|
823
829
|
[`site/migrations/${MCP_MIGRATION_FILE}`]: MCP_MIGRATION,
|
|
824
830
|
[`site/migrations/${FUNCTIONS_MIGRATION_FILE}`]: FUNCTIONS_MIGRATION,
|
|
825
831
|
[`site/migrations/${FUNCTIONS_CURSOR_MIGRATION_FILE}`]: FUNCTIONS_CURSOR_MIGRATION,
|
|
@@ -997,6 +1003,16 @@ function ensurePartsStateMigration(root) {
|
|
|
997
1003
|
return `site/migrations/${PURCHASE_STATE_FILE}`;
|
|
998
1004
|
}
|
|
999
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
|
+
}
|
|
1000
1016
|
function ensureFunctionRuns(root) {
|
|
1001
1017
|
const file=join(root,'site','migrations',FUNCTIONS_RUNS_MIGRATION_FILE);
|
|
1002
1018
|
if(existsSync(file))return null;
|
|
@@ -1027,8 +1043,13 @@ function ensureMcpMigration(root) {
|
|
|
1027
1043
|
if(existsSync(file))return null;
|
|
1028
1044
|
mkdirSync(dirname(file),{recursive:true});writeFileSync(file,MCP_MIGRATION);return `site/migrations/${MCP_MIGRATION_FILE}`;
|
|
1029
1045
|
}
|
|
1046
|
+
function ensureToolDatabase(root) {
|
|
1047
|
+
const file=join(root,'site','migrations',TOOL_DATABASE_MIGRATION_FILE);
|
|
1048
|
+
if(existsSync(file))return null;
|
|
1049
|
+
mkdirSync(dirname(file),{recursive:true});writeFileSync(file,TOOL_DATABASE_MIGRATION);return `site/migrations/${TOOL_DATABASE_MIGRATION_FILE}`;
|
|
1050
|
+
}
|
|
1030
1051
|
export function ensureMigrations(root) {
|
|
1031
|
-
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)].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);
|
|
1032
1053
|
}
|
|
1033
1054
|
|
|
1034
1055
|
/** What a migration file the template added is for, in a few words (deploy and dev say it). */
|
package/lib/shop-links.mjs
CHANGED
|
@@ -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:
|
|
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
|
|
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 };
|
package/lib/stripe-connect.mjs
CHANGED
|
@@ -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);
|
|
@@ -3450,6 +3450,90 @@
|
|
|
3450
3450
|
}
|
|
3451
3451
|
}
|
|
3452
3452
|
}
|
|
3453
|
+
},
|
|
3454
|
+
"0.50.7": {
|
|
3455
|
+
"files": {
|
|
3456
|
+
"CLAUDE.md": "ddf2b2a0376202f6",
|
|
3457
|
+
"README.md": "684b3cca8001673c",
|
|
3458
|
+
"HANDOFF.md": "29337e2ace95b2e7",
|
|
3459
|
+
"games/README.md": "5ad32fe80c61f117",
|
|
3460
|
+
"music/README.md": "a3dc042dadd39df8",
|
|
3461
|
+
"videos/README.md": "16babc3437d5545b",
|
|
3462
|
+
"posts/README.md": "37841aae476bd540",
|
|
3463
|
+
"site/README.md": "1dbdf22c03a1e505",
|
|
3464
|
+
"site/src/worker.mjs": "6cc5c6131eaf6fd0"
|
|
3465
|
+
},
|
|
3466
|
+
"sections": {
|
|
3467
|
+
"AGENTS.md": {
|
|
3468
|
+
"(intro)": "394cbbe934e80722",
|
|
3469
|
+
"## Version-matched authoring guides": "f6261a0e5801b96b",
|
|
3470
|
+
"## Layout": "f3a280b8c96cd068",
|
|
3471
|
+
"## Apps: one screen, roles and parts": "c10aa0441ea89702",
|
|
3472
|
+
"## Commands (all through the pinned CLI in node_modules)": "5222e08ca55e326e",
|
|
3473
|
+
"## The site": "0cb8dd9a6f8a0096",
|
|
3474
|
+
"## Making games": "bb715272fd1a615b",
|
|
3475
|
+
"## The Game Codex and progress": "0738ef719cef3a4c",
|
|
3476
|
+
"## Art direction and models": "a48fff7bf0a5f2fc",
|
|
3477
|
+
"## Running live games (the back office)": "51ebf2ab756f6e4d",
|
|
3478
|
+
"## Servers and AI seats": "1ccfd549d2369a60",
|
|
3479
|
+
"## Selling things (the shop)": "43a3556be93d1235",
|
|
3480
|
+
"## Continuing a build from the Claude app": "0d68081e924ca419",
|
|
3481
|
+
"## Rules": "b2234d80f6cbe71d",
|
|
3482
|
+
"## Telling Homie": "80b46fdd9e578db8",
|
|
3483
|
+
"## Beta": "9d02638207a26e73"
|
|
3484
|
+
}
|
|
3485
|
+
},
|
|
3486
|
+
"named": {
|
|
3487
|
+
"Homie Arcade": {
|
|
3488
|
+
"sections": {
|
|
3489
|
+
"AGENTS.md": {
|
|
3490
|
+
"## Commands (all through the pinned CLI in node_modules)": "611b437f5b3303f0"
|
|
3491
|
+
}
|
|
3492
|
+
}
|
|
3493
|
+
}
|
|
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
|
+
}
|
|
3453
3537
|
}
|
|
3454
3538
|
}
|
|
3455
3539
|
}
|
package/lib/tools-cli.mjs
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import {localCredential} from './local-test.mjs';
|
|
1
2
|
import {existsSync,mkdirSync,writeFileSync,readFileSync,chmodSync} from 'node:fs';
|
|
2
3
|
import {join,dirname} from 'node:path';
|
|
3
4
|
import {createServer} from 'node:http';
|
|
@@ -16,6 +17,8 @@ export async function connectToolClient(root,site,{publicOnly=false,open=default
|
|
|
16
17
|
const url=new URL('/mcp',site);if(url.protocol!=='https:'&&!['localhost','127.0.0.1','[::1]'].includes(url.hostname))throw new Error('Use HTTPS, or localhost for local development');
|
|
17
18
|
const client=new Client({name:'Homie studio tool CLI',version:'1.0.0'});
|
|
18
19
|
if(publicOnly){await client.connect(new StreamableHTTPClientTransport(url));return client;}
|
|
20
|
+
const secret=localCredential(root,url);
|
|
21
|
+
if(secret){await client.connect(new StreamableHTTPClientTransport(url,{requestInit:{headers:{authorization:`Bearer ${secret}`}}}));return client;}
|
|
19
22
|
const file=join(root,'.studio','mcp',Buffer.from(url.origin).toString('base64url')+'.json');
|
|
20
23
|
let state={};try{state=JSON.parse(readFileSync(file,'utf8'));}catch{}
|
|
21
24
|
// Credentials without an issuer predate the SDK's OAuth binding fix; reconnect safely.
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@homie-rocks/studio",
|
|
3
|
-
"version": "0.
|
|
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.
|
|
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
|
|
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.
|