@homie-rocks/studio 0.51.5 → 0.51.7
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 +33 -0
- package/apps/APPS.md +41 -0
- package/bin/homie-studio.mjs +1 -1
- package/functions/FUNCTIONS.md +2 -2
- package/guides/app/NEEDS.md +160 -0
- package/guides/app/SKILL.md +45 -3
- package/guides/office/SKILL.md +27 -1
- package/guides/servers/SKILL.md +27 -1
- package/guides/shop/SKILL.md +38 -15
- package/lib/app-needs.mjs +93 -19
- package/lib/app-ready.mjs +21 -0
- package/lib/check.mjs +16 -7
- package/lib/cloudflare.mjs +4 -4
- package/lib/codex.mjs +7 -5
- package/lib/mcp-tools.mjs +1 -1
- package/lib/needs-headless.mjs +34 -0
- package/lib/shop-links.mjs +91 -27
- package/lib/shop-mcp.mjs +37 -10
- package/lib/stripe-connect.mjs +4 -4
- package/lib/template-history.json +84 -0
- package/lib/worker-config.mjs +3 -0
- package/package.json +1 -1
- package/shop/SHOP.md +82 -15
- package/worker/app-records.mjs +1 -0
- package/worker/build-id.mjs +1 -1
- package/worker/event-resources.mjs +9 -3
- package/worker/local-test.mjs +4 -0
- package/worker/mcp-events.mjs +2 -2
- package/worker/mcp-tools.mjs +31 -4
- package/worker/mcp.mjs +1 -1
- package/worker/shop-links.mjs +1 -1
- package/worker/shop-rules.mjs +3 -0
- package/worker/shop.mjs +53 -5
- package/worker/version.mjs +1 -1
package/lib/app-needs.mjs
CHANGED
|
@@ -1,7 +1,8 @@
|
|
|
1
|
-
import {waitInApp} from './app-ready.mjs';
|
|
1
|
+
import {actInApp,waitInApp} from './app-ready.mjs';
|
|
2
2
|
/** Opt-in acceptance evidence, sourced from the person's Codex, not starter labels. */
|
|
3
|
-
import { existsSync, readFileSync, mkdirSync, writeFileSync } from 'node:fs';
|
|
4
|
-
import { join } from 'node:path';
|
|
3
|
+
import { existsSync, readFileSync, mkdirSync, writeFileSync, copyFileSync, realpathSync } from 'node:fs';
|
|
4
|
+
import { join, resolve, relative, isAbsolute, basename } from 'node:path';
|
|
5
|
+
import { isDeepStrictEqual } from 'node:util';
|
|
5
6
|
import { createHash } from 'node:crypto';
|
|
6
7
|
|
|
7
8
|
export function readAppNeeds(root, id) {
|
|
@@ -22,30 +23,38 @@ export function readAppNeeds(root, id) {
|
|
|
22
23
|
}
|
|
23
24
|
|
|
24
25
|
/** Run real UI actions. A failed or absent flow stays visible; continue collecting other needs. */
|
|
25
|
-
export async function proveAppNeeds({ plan, surfaces, shots, report, ready = async () => {} }) {
|
|
26
|
+
export async function proveAppNeeds({ plan, surfaces, shots, report, ready = async () => {}, root, headless }) {
|
|
26
27
|
const results = [];
|
|
27
28
|
const appBarrier=ready;
|
|
28
|
-
ready=async s=>{await s.page.bringToFront?.();if(s.external){s.frame=s.page.mainFrame();return;}await appBarrier(s);};
|
|
29
|
+
ready=async (s,options)=>{await s.page.bringToFront?.();if(s.external){s.frame=s.page.mainFrame();return;}await appBarrier(s,options);};
|
|
29
30
|
let activeRole;
|
|
30
31
|
const surface = name => { const s = surfaces.find(s => s.name === name && (!activeRole || s.role === activeRole)) ?? surfaces.find(s => s.name === name); if (!s) throw new Error(`unknown surface ${name}`); return s; };
|
|
31
32
|
for (const need of plan.needs) {
|
|
32
33
|
activeRole = need.role;
|
|
33
|
-
const names = [...new Set([...(need.steps ?? []), ...(need.assertions ?? [])].map(s => s.surface))];
|
|
34
|
+
const names = [...new Set([...(need.steps ?? []), ...(need.assertions ?? [])].map(s => s.surface).filter(Boolean))];
|
|
35
|
+
const saved = new Map();
|
|
36
|
+
const run = async item => { if (!headless) throw new Error('headless MCP proof requires local dev and its test credential'); return headless(item,item.role ?? need.role); };
|
|
34
37
|
let waiting = 'validating assertions';
|
|
35
38
|
const result = { id: need.id, statement: need.statement, status: 'not', captures: [] };
|
|
36
39
|
try {
|
|
37
|
-
if (!Array.isArray(need.assertions) || !need.assertions.length) throw new Error('need requires
|
|
40
|
+
if (!Array.isArray(need.assertions) || !need.assertions.length) throw new Error('need requires at least one assertion (UI, result, records or file)');
|
|
38
41
|
const popupNames=new Set((need.steps??[]).map(s=>s.popup).filter(Boolean));
|
|
39
42
|
for (const name of names) { if(popupNames.has(name))continue; const s = surface(name); if (need.role && s.role !== need.role) throw new Error(`${name} requires role ${need.role}, got ${s.role ?? 'unspecified'}`); }
|
|
40
43
|
for (const name of new Set(need.assertions.filter(a => a.effect).map(a => a.surface))) await surface(name).frame.evaluate(() => { window.__homieNeedsEffects = []; });
|
|
41
|
-
for (const
|
|
44
|
+
for (const rawStep of need.steps ?? []) {
|
|
45
|
+
const step=resolveNeedResults(rawStep,saved);
|
|
46
|
+
if (step.mcp || step.schedule) {
|
|
47
|
+
waiting = step.mcp ? `MCP ${step.mcp.tool ?? step.mcp.method} as ${step.role ?? need.role ?? 'unspecified'}` : `schedule ${step.schedule.name}`;
|
|
48
|
+
const value = await run(step);
|
|
49
|
+
if (step.save) { if(saved.has(step.save))throw new Error(`duplicate saved result ${step.save}`); saved.set(step.save,value); }
|
|
50
|
+
continue;
|
|
51
|
+
}
|
|
42
52
|
const target=surface(step.surface);
|
|
43
53
|
if(!step.reload)await ready(target);
|
|
44
54
|
const { page } = target;
|
|
45
55
|
waiting = `${step.surface}: ${step.reload ? 'reload and arrival readiness' : step.selector}`;
|
|
46
56
|
if (step.reload) { await page.reload({ waitUntil: 'domcontentloaded' }); await ready(surface(step.surface)); continue; }
|
|
47
|
-
|
|
48
|
-
const frame=target.frame;
|
|
57
|
+
const act = fn => actInApp(target,step.selector,fn,{ready});
|
|
49
58
|
if (step.click && step.popup) {
|
|
50
59
|
let listener,timer;
|
|
51
60
|
const opened=new Promise((resolve,reject)=>{
|
|
@@ -53,17 +62,42 @@ export async function proveAppNeeds({ plan, surfaces, shots, report, ready = asy
|
|
|
53
62
|
timer=setTimeout(()=>reject(new Error('payment popup did not open')),10000);
|
|
54
63
|
});
|
|
55
64
|
try {
|
|
56
|
-
const [popup]=await Promise.all([opened,frame.click(step.selector)]);
|
|
65
|
+
const [popup]=await Promise.all([opened,act(frame=>frame.click(step.selector))]);
|
|
57
66
|
surfaces.push({name:step.popup,page:popup,frame:popup.mainFrame(),external:true,role:target.role});
|
|
58
67
|
await page.bringToFront();
|
|
59
68
|
} finally {clearTimeout(timer);page.off('popup',listener);}
|
|
60
69
|
}
|
|
61
|
-
else if (step.click) {await frame.click(step.selector);await page.bringToFront?.();}
|
|
62
|
-
else if (step.fill !== undefined) { await frame.$eval(step.selector, el => { el.value = ''; el.dispatchEvent(new Event('input', { bubbles: true })); }); await frame.type(step.selector, String(step.fill)); }
|
|
63
|
-
else if (step.select !== undefined) await frame.select(step.selector, String(step.select));
|
|
70
|
+
else if (step.click) {await act(frame=>frame.click(step.selector));await page.bringToFront?.();}
|
|
71
|
+
else if (step.fill !== undefined) { await act(async frame=>{ await frame.$eval(step.selector, el => { el.value = ''; el.dispatchEvent(new Event('input', { bubbles: true })); }); await frame.type(step.selector, String(step.fill)); }); }
|
|
72
|
+
else if (step.select !== undefined) await act(frame=>frame.select(step.selector, String(step.select)));
|
|
64
73
|
else throw new Error('step needs click, fill, select or reload');
|
|
65
74
|
}
|
|
66
|
-
for (const
|
|
75
|
+
for (const rawAssertion of need.assertions) {
|
|
76
|
+
const assertion=resolveNeedResults(rawAssertion,saved);
|
|
77
|
+
if (assertion.result || assertion.records || assertion.file || assertion.mcp) {
|
|
78
|
+
waiting = assertion.file ? `file ${assertion.file}` : assertion.result ? `result ${assertion.result}${assertion.path ? '.'+assertion.path : ''}` : assertion.records ? `records ${assertion.records.collection}` : `MCP ${assertion.mcp.tool ?? assertion.mcp.method}`;
|
|
79
|
+
let value;
|
|
80
|
+
if (assertion.file) {
|
|
81
|
+
if (!root) throw new Error('file proof requires the studio root');
|
|
82
|
+
const file=resolve(root,assertion.file), rel=relative(resolve(root),file);
|
|
83
|
+
if(rel.startsWith('..') || isAbsolute(rel))throw new Error('handover file must be inside the studio');
|
|
84
|
+
value=readFileSync(file,'utf8');
|
|
85
|
+
} else if (assertion.result) {
|
|
86
|
+
if(!saved.has(assertion.result))throw new Error(`no saved result ${assertion.result}; add save to its step`);
|
|
87
|
+
value=saved.get(assertion.result);
|
|
88
|
+
} else {
|
|
89
|
+
const waitMs=assertion.waitMs??0;
|
|
90
|
+
if(!Number.isInteger(waitMs)||waitMs<0||waitMs>10000)throw new Error('waitMs must be an integer from 0 to 10000');
|
|
91
|
+
const end=Date.now()+waitMs;
|
|
92
|
+
for(;;) {
|
|
93
|
+
value=await run(assertion);
|
|
94
|
+
try {assertNeedValue(value,assertion);break;}
|
|
95
|
+
catch(error){if(Date.now()>=end)throw error;await new Promise(resolve=>setTimeout(resolve,100));}
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
assertNeedValue(value,assertion);
|
|
99
|
+
continue;
|
|
100
|
+
}
|
|
67
101
|
waiting = `${assertion.surface}: ${assertion.effect ? assertion.effect + ' ' + (assertion.name ?? '') : assertion.selector + (assertion.text === undefined ? '' : ' containing ' + JSON.stringify(assertion.text))}`;
|
|
68
102
|
const target=surface(assertion.surface);
|
|
69
103
|
await ready(target);
|
|
@@ -77,13 +111,14 @@ export async function proveAppNeeds({ plan, surfaces, shots, report, ready = asy
|
|
|
77
111
|
if (assertion.text !== undefined) await frame.waitForFunction((selector, text) => document.querySelector(selector)?.textContent.includes(text), { timeout: 10000 }, assertion.selector, String(assertion.text));
|
|
78
112
|
},{ready});
|
|
79
113
|
}
|
|
80
|
-
result.status =
|
|
81
|
-
result.why =
|
|
114
|
+
result.status = 'met';
|
|
115
|
+
result.why = 'Assertions passed';
|
|
82
116
|
} catch (error) { result.why = `Waiting for ${waiting}: ${error.message}`; }
|
|
117
|
+
if (!shots) for (const name of names) result.why += `; ${name} capture not requested: rerun check with --shots .checks and inspect the scene`;
|
|
83
118
|
if (shots) for (const name of names) {
|
|
84
119
|
const file = join(shots, `${need.id}-${name}.jpg`);
|
|
85
|
-
try { await ready(surface(name)); await surface(name).page.screenshot({ path: file, type: 'jpeg', quality: 85 }); result.captures.push(file); }
|
|
86
|
-
catch (error) {
|
|
120
|
+
try { mkdirSync(shots,{recursive:true}); await ready(surface(name)); await surface(name).page.screenshot({ path: file, type: 'jpeg', quality: 85 }); result.captures.push(file); }
|
|
121
|
+
catch (error) { result.why += `; ${name} capture failed: ${error.message}. Resolve this capture error and rerun check with --shots .checks`; }
|
|
87
122
|
}
|
|
88
123
|
report?.check?.(`need-${need.id}`, result.status === 'met' ? 'pass' : 'fail', { label: need.statement, note: result.why });
|
|
89
124
|
results.push(result);
|
|
@@ -94,5 +129,44 @@ export async function proveAppNeeds({ plan, surfaces, shots, report, ready = asy
|
|
|
94
129
|
export function saveAppNeeds(root, id, plan, needs) {
|
|
95
130
|
if (!root) return;
|
|
96
131
|
const dir = join(root, '.studio', 'checks'); mkdirSync(dir, { recursive: true });
|
|
132
|
+
needs=needs.map(need=>({...need,captures:(need.captures??[]).map(file=>{
|
|
133
|
+
const source=realpathSync(resolve(root,file)),rel=relative(realpathSync(root),source);
|
|
134
|
+
if(!rel.startsWith('..')&&!isAbsolute(rel))return rel;
|
|
135
|
+
const target=join(dir,`${id}-${createHash('sha256').update(source).digest('hex').slice(0,12)}-${basename(source)}`);
|
|
136
|
+
copyFileSync(source,target);return relative(root,target);
|
|
137
|
+
})}));
|
|
97
138
|
writeFileSync(join(dir, `${id}-needs.json`), JSON.stringify({ at: new Date().toISOString(), codexHash: plan.hash, needs }, null, 2));
|
|
98
139
|
}
|
|
140
|
+
|
|
141
|
+
/** Partial structured matching: object keys must match; each expected array entry must occur. */
|
|
142
|
+
export function containsValue(actual, expected) {
|
|
143
|
+
if (typeof expected === 'string') return typeof actual === 'string' && actual.includes(expected);
|
|
144
|
+
if (Array.isArray(expected)) return Array.isArray(actual) && expected.every(e=>actual.some(a=>containsValue(a,e)));
|
|
145
|
+
if (expected && typeof expected === 'object') return actual !== null && typeof actual === 'object' && Object.entries(expected).every(([k,v])=>Object.hasOwn(actual,k)&&containsValue(actual[k],v));
|
|
146
|
+
return isDeepStrictEqual(actual,expected);
|
|
147
|
+
}
|
|
148
|
+
export function assertNeedValue(value, assertion) {
|
|
149
|
+
if(assertion.path) for(const key of assertion.path.split('.')) value=value!=null&&Object.hasOwn(Object(value),key)?value[key]:undefined;
|
|
150
|
+
if(value === undefined)throw new Error('value does not exist');
|
|
151
|
+
if(Object.hasOwn(assertion,'equals')&&!isDeepStrictEqual(value,assertion.equals))throw new Error(`expected equals ${JSON.stringify(assertion.equals)}; got ${JSON.stringify(value)}`);
|
|
152
|
+
if(Object.hasOwn(assertion,'contains')&&!containsValue(value,assertion.contains))throw new Error(`expected contains ${JSON.stringify(assertion.contains)}; got ${JSON.stringify(value)}`);
|
|
153
|
+
if(assertion.text!==undefined)for(const text of [].concat(assertion.text))if(typeof value!=='string'||!value.includes(String(text)))throw new Error(`expected text ${JSON.stringify(text)}`);
|
|
154
|
+
if(!assertion.file&&!Object.hasOwn(assertion,'equals')&&!Object.hasOwn(assertion,'contains')&&assertion.text===undefined)throw new Error('result/records assertion requires equals, contains or text');
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
/** Explicit result references never evaluate code or interpolate arbitrary strings. */
|
|
158
|
+
export function resolveNeedResults(value,saved) {
|
|
159
|
+
if(Array.isArray(value))return value.map(v=>resolveNeedResults(v,saved));
|
|
160
|
+
if(value && typeof value==='object') {
|
|
161
|
+
if(Object.keys(value).length===1 && typeof value.$result==='string') {
|
|
162
|
+
const [name,...path]=value.$result.split('.');
|
|
163
|
+
if(!saved.has(name))throw new Error(`no saved result ${name}`);
|
|
164
|
+
let result=saved.get(name);
|
|
165
|
+
for(const key of path)result=result!=null&&Object.hasOwn(Object(result),key)?result[key]:undefined;
|
|
166
|
+
if(result===undefined)throw new Error(`saved result path ${value.$result} does not exist`);
|
|
167
|
+
return result;
|
|
168
|
+
}
|
|
169
|
+
return Object.fromEntries(Object.entries(value).map(([key,v])=>[key,resolveNeedResults(v,saved)]));
|
|
170
|
+
}
|
|
171
|
+
return value;
|
|
172
|
+
}
|
package/lib/app-ready.mjs
CHANGED
|
@@ -48,3 +48,24 @@ export function answerDialogs(page,surface,dialogs,report) {
|
|
|
48
48
|
await dialog.accept().catch(()=>{});
|
|
49
49
|
});
|
|
50
50
|
}
|
|
51
|
+
|
|
52
|
+
/** Retry only errors identifying a detached element, never an ambiguous navigation after dispatch. */
|
|
53
|
+
export async function actInApp(surface, selector, action, {ready=appReady,timeout=10000}={}) {
|
|
54
|
+
const end=Date.now()+timeout;
|
|
55
|
+
let stale;
|
|
56
|
+
for (;;) {
|
|
57
|
+
try {
|
|
58
|
+
await waitInApp(surface,selector,frame=>frame.waitForSelector(selector,{visible:true,timeout:Math.max(1,end-Date.now())}),{ready,timeout:Math.max(1,end-Date.now())});
|
|
59
|
+
return await action(surface.frame);
|
|
60
|
+
} catch(error) {
|
|
61
|
+
if(!/node is detached|element.*(?:detach|stale)|stale.*element/i.test(error.message)) {
|
|
62
|
+
if(stale && Date.now()>=end)throw new Error(`${surface.name}: stale element ${selector} never settled within ${timeout}ms: ${error.message}`);
|
|
63
|
+
throw error;
|
|
64
|
+
}
|
|
65
|
+
stale=error;
|
|
66
|
+
if(Date.now()>=end)throw new Error(`${surface.name}: stale element ${selector} never settled within ${timeout}ms: ${error.message}`);
|
|
67
|
+
await new Promise(resolve=>setTimeout(resolve,Math.min(50,Math.max(1,end-Date.now()))));
|
|
68
|
+
await ready(surface,{timeout:Math.max(1,end-Date.now())});
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
}
|
package/lib/check.mjs
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
|
+
import {needsHeadless} from './needs-headless.mjs';
|
|
1
2
|
import {randomBytes} from 'node:crypto';
|
|
2
|
-
import {appReady,observeEffects,waitInApp,answerDialogs} from './app-ready.mjs';
|
|
3
|
+
import {actInApp,appReady,observeEffects,waitInApp,answerDialogs} from './app-ready.mjs';
|
|
3
4
|
import {localCredential,localCookie} from './local-test.mjs';
|
|
4
5
|
import { readAppNeeds, proveAppNeeds, saveAppNeeds } from './app-needs.mjs';
|
|
5
6
|
/**
|
|
@@ -148,6 +149,12 @@ export async function check({ isolate = true, root = null, url, game, roundTimeo
|
|
|
148
149
|
if (meta?.rulesError) return { ok: false, command: 'check', why: meta.rulesError };
|
|
149
150
|
if (meta?.kind === 'app') appMeta = meta;
|
|
150
151
|
} catch { /* browser preflight below still names failures */ }
|
|
152
|
+
if(appMeta && !appMeta.check?.action) {
|
|
153
|
+
try {
|
|
154
|
+
const plan=readAppNeeds(root,game);
|
|
155
|
+
if(plan && !plan.needs.some(n=>[...(n.steps??[]),...(n.assertions??[])].some(i=>i.surface)))return checkApp({isolate,root,url:base,game,meta:appMeta,shots,log,report});
|
|
156
|
+
} catch(error) {return {ok:false,command:'check',kind:'app',why:error.message};}
|
|
157
|
+
}
|
|
151
158
|
const chrome = findChrome();
|
|
152
159
|
if (!chrome) return { ok: false, command: 'check', why: noChrome() };
|
|
153
160
|
let puppeteer;
|
|
@@ -357,13 +364,13 @@ export async function checkApp({ isolate = true, root = null, url, game, meta, s
|
|
|
357
364
|
const proof = meta.check??{};
|
|
358
365
|
const baseline=typeof proof.action==='string'&&typeof proof.observe==='string';
|
|
359
366
|
if (!baseline && !plan) return { ok: false, command: 'check', kind: 'app', why: 'app.json check needs action and observe CSS selectors for a real shared action' };
|
|
360
|
-
|
|
367
|
+
const captureWarnings=[];
|
|
361
368
|
const profile = mkdtempSync(join(tmpdir(), 'homie-app-check-'));
|
|
362
369
|
let browser;
|
|
363
370
|
const surfaces = [];
|
|
364
371
|
const room = `check-${randomBytes(10).toString('hex')}`;
|
|
365
372
|
try {
|
|
366
|
-
browser = await puppeteer.launch({ executablePath: chrome, headless: true, userDataDir: profile, timeout: LAUNCH_TIMEOUT_MS, args: chromeArgs() });
|
|
373
|
+
if(baseline || plan?.needs.some(n=>[...(n.steps??[]),...(n.assertions??[])].some(i=>i.surface))) browser = await puppeteer.launch({ executablePath: chrome, headless: true, userDataDir: profile, timeout: LAUNCH_TIMEOUT_MS, args: chromeArgs() });
|
|
367
374
|
const openSurface = async (name, role) => {
|
|
368
375
|
const dimensions={wall:[1280,720],phone:[390,844],'phone-two':[390,844],tablet:[1024,768],staff:[1024,768],kiosk:[1080,1920]};
|
|
369
376
|
if(!dimensions[name])throw new Error(`Unknown needs surface ${name}`);
|
|
@@ -390,7 +397,7 @@ export async function checkApp({ isolate = true, root = null, url, game, meta, s
|
|
|
390
397
|
await appReady(actor);
|
|
391
398
|
await waitInApp(actor,proof.action,f=>f.waitForFunction((selector) => { const el = document.querySelector(selector); return el && !el.disabled && !el.hidden; }, {}, proof.action));
|
|
392
399
|
before = await waitInApp(actor,proof.observe,textOf);
|
|
393
|
-
await actor.
|
|
400
|
+
await actInApp(actor,proof.action,f=>f.click(proof.action));
|
|
394
401
|
await waitInApp(actor,proof.observe+' changes after action',f=>f.waitForFunction((selector, was) => document.querySelector(selector)?.textContent !== was, {}, proof.observe, before));
|
|
395
402
|
after = await waitInApp(actor,proof.observe,textOf);
|
|
396
403
|
for (const s of surfaces) await waitInApp(s,proof.observe+' equals '+after,f=>f.waitForFunction((selector, wanted) => document.querySelector(selector)?.textContent === wanted, {}, proof.observe, after));
|
|
@@ -402,19 +409,21 @@ export async function checkApp({ isolate = true, root = null, url, game, meta, s
|
|
|
402
409
|
if (rooms.some((r) => r !== room)) throw new Error('surfaces did not retain the same room');
|
|
403
410
|
for (const s of surfaces) {
|
|
404
411
|
await appReady(s);
|
|
405
|
-
if (shots) await s.page.screenshot({ path: join(shots, `${s.name}.jpg`), type: 'jpeg', quality: 85 });
|
|
412
|
+
if (shots) try {mkdirSync(shots,{recursive:true});await s.page.screenshot({ path: join(shots, `${s.name}.jpg`), type: 'jpeg', quality: 85 });}
|
|
413
|
+
catch(error){const warning=`${s.name} capture failed: ${error.message}. Resolve this capture error and rerun check with --shots .checks`;captureWarnings.push(warning);log(warning);}
|
|
406
414
|
}
|
|
407
415
|
report?.check?.('app-action', 'pass', { note: 'action reached wall and both phones; reconnect retained it' });
|
|
408
416
|
}
|
|
409
417
|
for(const need of plan?.needs??[])for(const item of [...(need.steps??[]),...(need.assertions??[])]) {
|
|
418
|
+
if(!item.surface)continue;
|
|
410
419
|
if(plan.needs.some(n=>(n.steps??[]).some(s=>s.popup===item.surface)))continue;
|
|
411
420
|
const role=item.role??need.role??meta.surfaces?.[item.surface]??(item.surface==='staff'?'staff':undefined);
|
|
412
421
|
if(!surfaces.some(s=>s.name===item.surface&&s.role===role))await openSurface(item.surface,role);
|
|
413
422
|
}
|
|
414
|
-
const needs = plan ? await proveAppNeeds({ plan, surfaces, shots, report, ready:appReady }) : null;
|
|
423
|
+
const needs = plan ? await proveAppNeeds({ plan, surfaces, shots, report, ready:appReady, root, headless:needsHeadless({url,secret,app:game,meta,scope:isolate?room:null}) }) : null;
|
|
415
424
|
if (plan) { saveAppNeeds(root, game, plan, needs); const { refreshCodexFile } = await import('./codex.mjs'); refreshCodexFile(root, game); }
|
|
416
425
|
const ok = !needs || needs.every(n => n.status === 'met');
|
|
417
|
-
return { ok, ...(needs ? { needs, why: ok ? undefined : 'Some needs from the person’s Codex are not yet met; see needs and captures' } : {}), command: 'check', kind: 'app', isolatedRecords:isolate, dialogs, room, surfaces: surfaces.map((s) => s.name), action: proof.action, before, after, reconnect: baseline };
|
|
426
|
+
return { ok, ...(needs ? { needs, why: ok ? undefined : 'Some needs from the person’s Codex are not yet met; see needs and captures' } : {}), command: 'check', kind: 'app', isolatedRecords:isolate, captureWarnings, dialogs, room, surfaces: surfaces.map((s) => s.name), action: proof.action, before, after, reconnect: baseline };
|
|
418
427
|
} catch (error) { return { ok: false, command: 'check', kind: 'app', dialogs, why: error.message }; }
|
|
419
428
|
finally { await browser?.close().catch(() => {}); rmSync(profile, { recursive: true, force: true }); }
|
|
420
429
|
}
|
package/lib/cloudflare.mjs
CHANGED
|
@@ -435,7 +435,7 @@ async function deployLocked(root, { log = () => {}, homie, fetchFn = null, ownRo
|
|
|
435
435
|
if (url) {
|
|
436
436
|
const read = await readLiveSite(url, fetchFn ? { fetchFn } : {});
|
|
437
437
|
claim = read.claim;
|
|
438
|
-
if (claim) step('the site
|
|
438
|
+
if (claim) step(listedHere(root,{site:url})?'the site is listed':'the site is claimed, not listed');
|
|
439
439
|
else if (read.state === 'other' && !isWorkersDev(url)) {
|
|
440
440
|
// The studio's own domain answered as something else: say which route is needed (lib/routes.mjs). The deploy
|
|
441
441
|
// itself went through; the zone's other routes are never touched.
|
|
@@ -486,7 +486,7 @@ async function deployLocked(root, { log = () => {}, homie, fetchFn = null, ownRo
|
|
|
486
486
|
...(reread ? { reread } : {}),
|
|
487
487
|
songs: b.songs.map((slug) => ({ slug, page: url ? `${url}/music/${slug}/` : null })),
|
|
488
488
|
videos: b.videos.map((slug) => ({ slug, page: url ? `${url}/videos/${slug}/` : null })),
|
|
489
|
-
claim: Boolean(claim), directory,
|
|
489
|
+
claim: Boolean(claim), listing: listedHere(root,{site:url}) ? 'listed' : claim ? 'claimed, not listed' : 'not listed', directory,
|
|
490
490
|
};
|
|
491
491
|
}
|
|
492
492
|
|
|
@@ -638,7 +638,7 @@ export async function ciDeploy(root, { log = () => {} } = {}) {
|
|
|
638
638
|
let url = null;
|
|
639
639
|
try { url = siteUrl(root) ?? workersDev; } catch { url = workersDev; }
|
|
640
640
|
const claim = url ? await warmClaim(url) : null;
|
|
641
|
-
step(claim ? 'the site
|
|
641
|
+
step(claim ? (listedHere(root,{site:url})?'the site is listed':'the site is claimed, not listed') : 'no directory claim yet (the site claims itself when the directory first reads it)');
|
|
642
642
|
let games = [];
|
|
643
643
|
try { games = JSON.parse(readFileSync(join(root, 'site', 'dist', 'games.json'), 'utf8')).games.map((g) => g.id); } catch { games = []; }
|
|
644
644
|
const hashes = builtGames(root, games);
|
|
@@ -648,7 +648,7 @@ export async function ciDeploy(root, { log = () => {} } = {}) {
|
|
|
648
648
|
commit: process.env.WORKERS_CI_COMMIT_SHA ?? null, branch: process.env.WORKERS_CI_BRANCH ?? null,
|
|
649
649
|
// A CI checkout has no earlier deploy to compare with: each game's content hash only (lib/deploy-state.mjs).
|
|
650
650
|
games: games.map((id) => ({ id, page: url ? `${url}/${id}/` : null, ...experienceAddresses(root, id, url), hash: hashes[id]?.hash ?? null })), songs: [], videos: [],
|
|
651
|
-
claim: Boolean(claim),
|
|
651
|
+
claim: Boolean(claim), listing: listedHere(root,{site:url}) ? 'listed' : claim ? 'claimed, not listed' : 'not listed',
|
|
652
652
|
};
|
|
653
653
|
}
|
|
654
654
|
|
package/lib/codex.mjs
CHANGED
|
@@ -318,7 +318,7 @@ function sectionHtml(section, emb, ctx) {
|
|
|
318
318
|
if (block) {
|
|
319
319
|
try {
|
|
320
320
|
const needs = JSON.parse(block[1]);
|
|
321
|
-
if (Array.isArray(needs)) return `<section class="tab" id="${esc(section.id)}" data-key="needs"><h2>${esc(section.title)}</h2>${renderMarkdown(source.replace(block[0], '')).html}<div class="cards">${needs.map(n => `<article class="card"><h3>${esc(n.id)}</h3><p>${esc(n.statement)}</p><p class="meta">${Array.isArray(n.assertions) && n.assertions.length ? 'To demonstrate
|
|
321
|
+
if (Array.isArray(needs)) return `<section class="tab" id="${esc(section.id)}" data-key="needs"><h2>${esc(section.title)}</h2>${renderMarkdown(source.replace(block[0], '')).html}<div class="cards">${needs.map(n => `<article class="card"><h3>${esc(n.id)}</h3><p>${esc(n.statement)}</p><p class="meta">${Array.isArray(n.assertions) && n.assertions.length ? 'To demonstrate with check' : 'Not yet demonstrable'}</p></article>`).join('')}</div></section>`;
|
|
322
322
|
} catch { /* Keep malformed source visible for correction. */ }
|
|
323
323
|
}
|
|
324
324
|
}
|
|
@@ -582,7 +582,7 @@ export function renderCodex(root, id, { mode = 'file', feed, now = new Date() }
|
|
|
582
582
|
const r = JSON.parse(readFileSync(needReportFile, 'utf8'));
|
|
583
583
|
const stale = r.codexHash !== createHash('sha256').update(readFileSync(file, 'utf8')).digest('hex');
|
|
584
584
|
tabs.push('<a href="#needs-status">Needs status</a>');
|
|
585
|
-
needStatus = `<section class="tab" id="needs-status"><h2>Needs status</h2><p>${stale ? 'Plan changed: this evidence is stale; check again. ' : ''}Checked ${esc(r.at)}.
|
|
585
|
+
needStatus = `<section class="tab" id="needs-status"><h2>Needs status</h2><p>${stale ? 'Plan changed: this evidence is stale; check again. ' : ''}Checked ${esc(r.at)}. Assertions are evidence; review available captures against Their words.</p>${r.needs.map(n => `<article><h3>${esc(n.statement)}</h3><p><strong>${stale ? 'not yet rechecked' : esc(n.status)}</strong> — ${esc(n.why ?? '')}</p>${(n.captures ?? []).map(p => figure(emb, n.statement, relative(root, resolve(root, p)), p)).join('')}</article>`).join('')}</section>`;
|
|
586
586
|
} catch { /* The next check repairs an incomplete receipt. */ }
|
|
587
587
|
}
|
|
588
588
|
const body = needStatus + sections.map((x) => sectionHtml(x, emb, ctx)).join('\n');
|
|
@@ -772,9 +772,11 @@ try: "Open the wall and phone. Follow a real request through the scene."
|
|
|
772
772
|
<!-- What is sold or given, price and currency, once/monthly, and what payment fulfils. -->
|
|
773
773
|
|
|
774
774
|
## Needs
|
|
775
|
-
<!-- Each statement
|
|
776
|
-
Each need: id, statement, steps
|
|
777
|
-
|
|
775
|
+
<!-- Each owner statement belongs in this executable needs block.
|
|
776
|
+
Each need: id, statement, optional role, steps and assertions. UI: surface/selector with
|
|
777
|
+
click/fill/select/reload; headless: mcp tool/arguments + save, or schedule name/at.
|
|
778
|
+
Assert UI selectors, saved result/path/equals/contains, records, or file/text.
|
|
779
|
+
See node_modules/@homie-rocks/studio/guides/app/NEEDS.md for complete examples.
|
|
778
780
|
A missing flow is reported not met. Do not replace an unmet need with an easier one. -->
|
|
779
781
|
\`\`\`needs
|
|
780
782
|
[]
|
package/lib/mcp-tools.mjs
CHANGED
|
@@ -1178,7 +1178,7 @@ export function toolDefs(ctx, avail = {}) {
|
|
|
1178
1178
|
},
|
|
1179
1179
|
{
|
|
1180
1180
|
name: 'stripe_shop_plan', title: 'Plan or verify the Stripe shop',
|
|
1181
|
-
description: 'Default setup with Stripe official MCP. Supply public API observations as {path,params,body}, account ID, mode, and complete booking slots. Returns
|
|
1181
|
+
description: 'Default setup with Stripe official MCP. Supply public API observations as {method,path,params,body}, account ID, mode, and complete booking slots. Returns every independent operation in a dependency-ordered batch, with metadata and idempotency. Execute via Stripe stripe_api_search/details/read/write, retain write responses as observations and replan until verified. verify:true saves compact public config for deploy and stripe_webhook. Never pass webhook creation responses or secrets.',
|
|
1182
1182
|
inputSchema: {type:'object',properties:{...STUDIO_ARG,account:{type:'string'},mode:{enum:['test','live']},url:{type:'string'},slots:{type:'array',items:{type:'object'}},observations:{type:'array',items:{type:'object'}},verify:{type:'boolean'}}},
|
|
1183
1183
|
annotations:{readOnlyHint:false,destructiveHint:false,openWorldHint:false},
|
|
1184
1184
|
run:async a=>{
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
import {localCookie} from './local-test.mjs';
|
|
2
|
+
|
|
3
|
+
/** Each action has an explicit local identity, using the real MCP transport and permissions. */
|
|
4
|
+
export function needsHeadless({url,secret,app,meta,scope}) {
|
|
5
|
+
return async (item,role) => {
|
|
6
|
+
if(!secret)throw new Error('Headless role proof requires homie-studio dev in this studio and its localhost URL');
|
|
7
|
+
if(!role || (role!=='owner'&&!Object.hasOwn(meta.roles??{},role)))throw new Error(`Name a declared role (or owner) for headless proof; got ${role??'none'}`);
|
|
8
|
+
const {Client,StreamableHTTPClientTransport}=await import('mcp-studio-client');
|
|
9
|
+
const client=new Client({name:'Homie needs check',version:'1.0.0'});
|
|
10
|
+
const transport=new StreamableHTTPClientTransport(new URL('/mcp',url),{requestInit:{headers:{authorization:`Bearer ${secret}`,cookie:`homie_test=${localCookie(secret,{app,role,scope})}`}}});
|
|
11
|
+
try {
|
|
12
|
+
await client.connect(transport);
|
|
13
|
+
let call=item.mcp;
|
|
14
|
+
if(item.records)call={tool:'app_records',arguments:{...item.records,app,role,operation:'read'}};
|
|
15
|
+
if(item.schedule)call={tool:'studio_schedule_fire',arguments:item.schedule};
|
|
16
|
+
if(call?.method) {
|
|
17
|
+
if(call.method==='resources/read')return await client.readResource(call.params);
|
|
18
|
+
// The extension's JSON-RPC result is intentionally preserved for path assertions.
|
|
19
|
+
if(['events/list','events/poll'].includes(call.method)) {
|
|
20
|
+
const {z}=await import('zod');
|
|
21
|
+
return await client.request({method:call.method,params:call.params??{}},z.object({}).passthrough(),{timeout:10000});
|
|
22
|
+
}
|
|
23
|
+
throw new Error(`Unsupported MCP proof method ${call.method}; use tools, resources/read, events/list or events/poll`);
|
|
24
|
+
}
|
|
25
|
+
if(!call?.tool)throw new Error('MCP step requires tool and optional arguments');
|
|
26
|
+
const result=await client.callTool({name:call.tool,arguments:call.arguments??{}},undefined,{timeout:item.schedule?15000:10000});
|
|
27
|
+
if(result.isError)throw new Error(result.content?.filter(c=>c.type==='text').map(c=>c.text).join('; ')||'MCP tool failed');
|
|
28
|
+
if(result.structuredContent!==undefined)return result.structuredContent;
|
|
29
|
+
const content=result.content?.filter(c=>c.type==='text');
|
|
30
|
+
if(content?.length===1) {try {const parsed=JSON.parse(content[0].text);return parsed.kind==='untrusted-tool-data'?parsed.data:parsed;}catch{}}
|
|
31
|
+
return result;
|
|
32
|
+
} finally {await client.close();}
|
|
33
|
+
};
|
|
34
|
+
}
|
package/lib/shop-links.mjs
CHANGED
|
@@ -7,7 +7,11 @@ 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
9
|
export const matchesFields = (actual, expected) => Array.isArray(expected) ? Array.isArray(actual) && actual.length === expected.length && expected.every((v,i)=>matchesFields(actual[i],v)) : expected && typeof expected === 'object' ? Object.entries(expected).every(([k,v]) => matchesFields(actual?.[k],v)) : actual === expected;
|
|
10
|
-
export async function reconcilePaymentLinks({ api, shop, slug, site, mode, bookingSlots = [], verifyObjects = false }) {
|
|
10
|
+
export async function reconcilePaymentLinks({ api, shop, slug, site, mode, bookingSlots = [], verifyObjects = false, scoped = false, knownLinks = [], knownPortal }) {
|
|
11
|
+
if(scoped) {
|
|
12
|
+
const transport=api;
|
|
13
|
+
api=(method,path,params={},idempotency)=>transport(method,path,method==='POST'&&path.startsWith('/v1/payment_links/')?{...params,expand:['line_items']}:params,idempotency);
|
|
14
|
+
}
|
|
11
15
|
const list = async (path, params = {}) => {
|
|
12
16
|
const all = []; let cursor;
|
|
13
17
|
do {
|
|
@@ -20,13 +24,32 @@ export async function reconcilePaymentLinks({ api, shop, slug, site, mode, booki
|
|
|
20
24
|
} while (cursor);
|
|
21
25
|
return all;
|
|
22
26
|
};
|
|
23
|
-
const
|
|
24
|
-
|
|
25
|
-
|
|
27
|
+
const parallel = async tasks => {
|
|
28
|
+
if(!scoped){const values=[];for(const task of tasks)values.push(await task());return values;}
|
|
29
|
+
const results = await Promise.allSettled(tasks.map(task=>task()));
|
|
30
|
+
const errors=results.filter(r=>r.status==='rejected').map(r=>r.reason);
|
|
31
|
+
const fatal=errors.find(e=>!e.next&&!e.operations);if(fatal)throw fatal;
|
|
32
|
+
if(errors.length)throw {operations:errors.flatMap(e=>e.operations??[e.next])};
|
|
33
|
+
return results.map(r=>r.value);
|
|
34
|
+
};
|
|
35
|
+
const search = async kind => {
|
|
36
|
+
const data=[];let page;
|
|
37
|
+
do {
|
|
38
|
+
const result=await api('GET',`/v1/${kind}/search`,{query:`metadata['homie']:'shop-links-v1' AND metadata['studio']:'${slug}' AND metadata['origin']:'${site}'`,limit:100,...(kind==='products'?{expand:['data.default_price']}:{}),...(page?{page}:{})});
|
|
39
|
+
if(!Array.isArray(result.data)||result.has_more&&!result.next_page||page&&result.next_page===page)throw new Error('Incomplete Stripe pagination');
|
|
40
|
+
data.push(...result.data);page=result.has_more?result.next_page:null;
|
|
41
|
+
}while(page);
|
|
42
|
+
return data;
|
|
43
|
+
};
|
|
44
|
+
const [products,prices,links]=await parallel([
|
|
45
|
+
()=>scoped?search('products'):list('/v1/products'),
|
|
46
|
+
()=>scoped?search('prices'):list('/v1/prices'),
|
|
47
|
+
()=>scoped?parallel([...new Set(knownLinks)].map(id=>()=>api('GET',`/v1/payment_links/${id}`,{expand:['line_items']}))):list('/v1/payment_links'),
|
|
48
|
+
]);
|
|
26
49
|
const own = (o) => o.metadata?.homie === 'shop-links-v1' && o.metadata?.studio === slug && o.metadata?.origin === site;
|
|
27
50
|
const verifyLink = async (link, params) => {
|
|
28
51
|
if (!verifyObjects) return;
|
|
29
|
-
const actual=await api('GET',`/v1/payment_links/${link.id}`,{expand:['line_items']});
|
|
52
|
+
const actual=link.line_items?.data?link:await api('GET',`/v1/payment_links/${link.id}`,{expand:['line_items']});
|
|
30
53
|
const normalized={...actual,line_items:actual.line_items?.data?.map(line=>({...line,price:line.price?.id??line.price}))};
|
|
31
54
|
if(actual.id!==link.id || actual.active!==link.active || actual.url!==link.url || actual.line_items?.has_more===true || !matchesFields(actual.metadata,link.metadata) || !paymentLinkURL(actual.url) || !matchesFields(normalized,params)) throw new Error(`Stripe Payment Link ${link.id} differs from its planned fields. Restore its configuration through Stripe MCP before verifying.`);
|
|
32
55
|
};
|
|
@@ -34,21 +57,26 @@ export async function reconcilePaymentLinks({ api, shop, slug, site, mode, booki
|
|
|
34
57
|
const customText=shop.giving?.receiptText ? {custom_text:{submit:{message:shop.giving.receiptText}}} : {};
|
|
35
58
|
const catalogPrices = new Map();
|
|
36
59
|
const keepPrices = new Set(), keepLinks = new Set(), keepProducts = new Set();
|
|
37
|
-
|
|
60
|
+
await parallel(shop.items.map(item=>async()=>{
|
|
38
61
|
const id = await productIdOf(slug, item.id);
|
|
39
62
|
keepProducts.add(id);
|
|
40
63
|
const metadata = { homie: 'shop-links-v1', studio: slug, origin: site };
|
|
41
64
|
const fields = { name: item.name, description:item.blurb??'', tax_code: item.taxCode ?? defaultTaxCode(item), metadata, active: true };
|
|
42
|
-
let product = products.find((p) => p.id === id);
|
|
43
|
-
if (!product) product = await api('POST', '/v1/products', { id, ...fields }, `product-${hash(id)}`);
|
|
44
|
-
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);
|
|
45
65
|
// Omit an unset minimum: Stripe applies its settlement-currency floor itself.
|
|
46
66
|
// https://docs.stripe.com/api/prices/create#create_price-custom_unit_amount-minimum
|
|
47
67
|
const priceFields = { product: id, currency: shop.currency, ...(item.interval ? { recurring: { interval: item.interval } } : {}), tax_behavior: 'exclusive', ...(item.price === 'choose'
|
|
48
68
|
? { custom_unit_amount: { enabled: true, ...(item.min > 0 ? { minimum: item.min } : {}), ...(item.max !== null ? { maximum: item.max } : {}) } }
|
|
49
69
|
: { unit_amount: item.price }) };
|
|
50
70
|
const revision = hash(priceFields);
|
|
71
|
+
const createFields={...fields};if(!createFields.description)delete createFields.description;
|
|
72
|
+
let product = products.find(p=>p.id===id);
|
|
73
|
+
if(!product) {
|
|
74
|
+
const {product:_,...default_price_data}=priceFields;
|
|
75
|
+
product=await api('POST','/v1/products',{id,...createFields,...(scoped?{default_price_data:{...default_price_data,metadata:{...metadata,revision}},expand:['default_price']}:{})},`product-${hash(id)}`);
|
|
76
|
+
} 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);
|
|
77
|
+
|
|
51
78
|
let price = prices.find((p) => own(p) && p.product === id && p.metadata?.revision === revision);
|
|
79
|
+
if(!price && product.default_price?.id && product.default_price.metadata?.revision===revision)price=product.default_price;
|
|
52
80
|
if (!price) price = await api('POST', '/v1/prices', { ...priceFields, metadata: { ...metadata, revision } }, `price-${hash({ priceFields, metadata })}`);
|
|
53
81
|
if (verifyObjects && ((price.recurring?.interval_count??1)!==1 || Boolean(price.recurring)!==Boolean(item.interval) || Boolean(price.custom_unit_amount)!==(item.price==='choose'))) throw new Error(`Stripe Price ${price.id} has a different payment type from shop.json`);
|
|
54
82
|
if (verifyObjects && !matchesFields({...price, product:price.product?.id??price.product, ...(price.custom_unit_amount?{custom_unit_amount:{...price.custom_unit_amount,enabled:true}}:{})},priceFields)) throw new Error(`Stripe Price ${price.id} differs from shop.json; restore or archive it through Stripe MCP before planning again.`);
|
|
@@ -56,7 +84,7 @@ export async function reconcilePaymentLinks({ api, shop, slug, site, mode, booki
|
|
|
56
84
|
keepPrices.add(price.id);
|
|
57
85
|
catalogPrices.set(item.id, price.id);
|
|
58
86
|
if ((product.default_price?.id ?? product.default_price) !== price.id) await api('POST', `/v1/products/${id}`, { default_price: price.id });
|
|
59
|
-
if (item.price === 0)
|
|
87
|
+
if (item.price === 0) return; // Free orders never leave the studio.
|
|
60
88
|
const params = { ...customText, line_items: [{ price: price.id, quantity: 1, ...(item.price !== 'choose' ? { adjustable_quantity: { enabled: shop.policy.repeatPurchases, minimum: 1, maximum: 999999 } } : {}) }],
|
|
61
89
|
after_completion: { type: 'redirect', redirect: { url: `${site}/shop/thanks?session_id={CHECKOUT_SESSION_ID}` } },
|
|
62
90
|
...(shop.till === 'stripe-managed' ? { managed_payments: { enabled: true } } : { automatic_tax: { enabled: shop.automaticTax } }),
|
|
@@ -64,35 +92,68 @@ export async function reconcilePaymentLinks({ api, shop, slug, site, mode, booki
|
|
|
64
92
|
};
|
|
65
93
|
const linkRevision = hash({ params, item, policy: shop.policy });
|
|
66
94
|
let link = links.find((p) => own(p) && p.metadata?.revision === linkRevision);
|
|
67
|
-
if (!link) link = await api('POST', '/v1/payment_links', { ...params, metadata: { ...metadata, revision: linkRevision } }, `link-${linkRevision}`);
|
|
95
|
+
if (!link) link = await api('POST', '/v1/payment_links', { ...params, ...(scoped?{expand:['line_items']}:{}), metadata: { ...metadata, revision: linkRevision } }, `link-${linkRevision}`);
|
|
68
96
|
await verifyLink(link,params);
|
|
69
97
|
if (!link.active) await api('POST', `/v1/payment_links/${link.id}`, { active: true });
|
|
70
98
|
if (!paymentLinkURL(link.url) || !/^plink_\w+$/.test(link.id)) throw new Error('Invalid Payment Link');
|
|
71
99
|
keepLinks.add(link.id);
|
|
72
100
|
config.items.push({ item: item.id, id: link.id, url: link.url, price: price.id, revision: linkRevision });
|
|
73
|
-
}
|
|
101
|
+
}));
|
|
74
102
|
// Menus use paid invoice snapshots: Checkout events omit their line items.
|
|
75
103
|
// Stripe allows 20 Payment Link lines; invoice snapshots include only the first
|
|
76
104
|
// 10. Split catalogues so keyless fulfillment needs no authenticated pagination.
|
|
77
105
|
config.menus = [];
|
|
78
106
|
const menuItems = shop.items.filter(i => typeof i.price === 'number' && i.price > 0 && !i.interval);
|
|
79
107
|
const metadata = { homie: 'shop-links-v1', studio: slug, origin: site };
|
|
108
|
+
const tasks=[];
|
|
80
109
|
const groups=Map.groupBy(menuItems,i=>i.game??'');
|
|
81
|
-
for(const group of groups.values()) for (let offset = 0; offset < group.length; offset += 10) {
|
|
82
|
-
const
|
|
83
|
-
|
|
110
|
+
for(const group of (shop.basket ? [] : groups.values())) for (let offset = 0; offset < group.length; offset += 10) {
|
|
111
|
+
const groupOffset=offset;tasks.push(async()=>{
|
|
112
|
+
const items = group.slice(groupOffset, groupOffset + 10);
|
|
113
|
+
if (items.length < 2) return;
|
|
84
114
|
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}})),
|
|
85
115
|
invoice_creation:{enabled:true,invoice_data:{metadata}},
|
|
86
116
|
after_completion:{type:'redirect',redirect:{url:`${site}/shop/thanks?session_id={CHECKOUT_SESSION_ID}`}},
|
|
87
117
|
automatic_tax:{enabled:shop.automaticTax}, payment_intent_data:{metadata} };
|
|
88
118
|
const revision=hash(params);
|
|
89
119
|
let link=links.find(p=>own(p)&&p.metadata?.revision===revision);
|
|
90
|
-
if(!link)link=await api('POST','/v1/payment_links',{...params,metadata:{...metadata,revision}},`menu-${revision}`);
|
|
120
|
+
if(!link)link=await api('POST','/v1/payment_links',{...params,...(scoped?{expand:['line_items']}:{}),metadata:{...metadata,revision}},`menu-${revision}`);
|
|
91
121
|
await verifyLink(link,params);
|
|
92
122
|
if(!link.active)await api('POST',`/v1/payment_links/${link.id}`,{active:true});
|
|
93
123
|
keepLinks.add(link.id);
|
|
94
124
|
config.menus.push({id:link.id,url:link.url,revision,items:items.map(i=>({item:i.id,price:catalogPrices.get(i.id)}))});
|
|
95
|
-
}
|
|
125
|
+
});}
|
|
126
|
+
// One aggregate payment represents a frozen local basket, including one-item baskets.
|
|
127
|
+
// Runtime uses prefilled_amount, never a shared Price mutation: concurrent
|
|
128
|
+
// guests must receive their own saved totals.
|
|
129
|
+
if(shop.basket) for(const [game,items] of groups) {tasks.push(async()=>{
|
|
130
|
+
const id=await productIdOf(slug,`basket-${game||'shop'}`);
|
|
131
|
+
keepProducts.add(id);
|
|
132
|
+
const fields={name:shop.basket.name,description:shop.basket.description,metadata,active:true};
|
|
133
|
+
const priceFields={product:id,currency:shop.currency,custom_unit_amount:{enabled:true},tax_behavior:'inclusive'};
|
|
134
|
+
const priceRevision=hash(priceFields);
|
|
135
|
+
let product=products.find(p=>p.id===id);
|
|
136
|
+
if(!product) {
|
|
137
|
+
const {product:_,...default_price_data}=priceFields;
|
|
138
|
+
const createFields={...fields};if(!createFields.description)delete createFields.description;
|
|
139
|
+
product=await api('POST','/v1/products',{id,...createFields,...(scoped?{default_price_data:{...default_price_data,metadata:{...metadata,revision:priceRevision}},expand:['default_price']}:{})},`product-${hash(id)}`);
|
|
140
|
+
} else if(product.name!==fields.name || product.description!==fields.description || !product.active)await api('POST',`/v1/products/${id}`,fields);
|
|
141
|
+
let price=prices.find(p=>own(p)&&p.product===id&&p.metadata?.revision===priceRevision);
|
|
142
|
+
if(!price&&product.default_price?.metadata?.revision===priceRevision)price=product.default_price;
|
|
143
|
+
if(!price)price=await api('POST','/v1/prices',{...priceFields,metadata:{...metadata,revision:priceRevision}},`price-${hash({priceFields,metadata})}`);
|
|
144
|
+
if(!price.active)await api('POST',`/v1/prices/${price.id}`,{active:true});
|
|
145
|
+
keepPrices.add(price.id);
|
|
146
|
+
const params={line_items:[{price:price.id,quantity:1}],automatic_tax:{enabled:false},payment_intent_data:{metadata},
|
|
147
|
+
after_completion:{type:'redirect',redirect:{url:`${site}/shop/thanks?session_id={CHECKOUT_SESSION_ID}`}}};
|
|
148
|
+
const revision=hash(params);
|
|
149
|
+
let link=links.find(p=>own(p)&&p.metadata?.revision===revision);
|
|
150
|
+
if(!link)link=await api('POST','/v1/payment_links',{...params,...(scoped?{expand:['line_items']}:{}),metadata:{...metadata,revision}},`basket-${revision}`);
|
|
151
|
+
if(!link.active)await api('POST',`/v1/payment_links/${link.id}`,{active:true});
|
|
152
|
+
await verifyLink(link,params);
|
|
153
|
+
if(!paymentLinkURL(link.url))throw new Error('Invalid basket Payment Link');
|
|
154
|
+
keepLinks.add(link.id);
|
|
155
|
+
config.menus.push({id:link.id,url:link.url,revision,price:price.id,basket:true,items:items.map(i=>({item:i.id,price:catalogPrices.get(i.id)}))});
|
|
156
|
+
});}
|
|
96
157
|
config.slots=[];
|
|
97
158
|
const pause=async link=>{
|
|
98
159
|
if(link.active) await api('POST',`/v1/payment_links/${link.id}`,{active:false});
|
|
@@ -106,12 +167,13 @@ export async function reconcilePaymentLinks({ api, shop, slug, site, mode, booki
|
|
|
106
167
|
const resource=JSON.stringify([slot.app,slot.slots,slot.slot]);
|
|
107
168
|
if(slotKeys.has(resource))throw new Error('Publish only one service Payment Link per capacity slot');
|
|
108
169
|
slotKeys.add(resource);
|
|
170
|
+
tasks.push(async()=>{
|
|
109
171
|
const item=shop.items.find(i=>i.id===slot.item);
|
|
110
172
|
if(!item || item.interval || item.price==='choose')throw new Error('Bookings require a fixed one-time service price');
|
|
111
|
-
if(!item.price)
|
|
173
|
+
if(!item.price)return;
|
|
112
174
|
const identity=hash({slug,site,app:slot.app,collection:slot.collection,slots:slot.slots,slot:slot.slot});
|
|
113
175
|
let link=links.find(p=>own(p)&&p.metadata?.slot===identity);
|
|
114
|
-
if(!link && (!slot.capacity||slot.active===false))
|
|
176
|
+
if(!link && (!slot.capacity||slot.active===false))return;
|
|
115
177
|
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.');
|
|
116
178
|
const seatsRevision=hash({slot,capacity:slot.capacity,confirmed:slot.confirmed});
|
|
117
179
|
const counted=slot.confirmed!==undefined && link?.metadata?.seats_revision===seatsRevision;
|
|
@@ -123,7 +185,7 @@ export async function reconcilePaymentLinks({ api, shop, slug, site, mode, booki
|
|
|
123
185
|
const active=slot.active!==false && limit>count;
|
|
124
186
|
const params={...customText,line_items:[{price:catalogPrices.get(item.id),quantity:1}],payment_method_types:['card'],
|
|
125
187
|
restrictions:{completed_sessions:{limit:Math.max(1,limit)}},after_completion:{type:'redirect',redirect:{url:`${site}/shop/thanks?session_id={CHECKOUT_SESSION_ID}`}}};
|
|
126
|
-
if(!link)link=await api('POST','/v1/payment_links',{...params,metadata:{...metadata,slot:identity,revision:identity,price:catalogPrices.get(item.id)}},`slot-${identity}`);
|
|
188
|
+
if(!link)link=await api('POST','/v1/payment_links',{...params,...(scoped?{expand:['line_items']}:{}),metadata:{...metadata,slot:identity,revision:identity,price:catalogPrices.get(item.id)}},`slot-${identity}`);
|
|
127
189
|
else {
|
|
128
190
|
// Pause first whenever publishing a changed budget, so old sessions cannot
|
|
129
191
|
// spend a previous capacity after staff moves or removes a slot.
|
|
@@ -133,17 +195,19 @@ export async function reconcilePaymentLinks({ api, shop, slug, site, mode, booki
|
|
|
133
195
|
await verifyLink(link,params);
|
|
134
196
|
keepLinks.add(link.id);
|
|
135
197
|
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});
|
|
198
|
+
});
|
|
136
199
|
}
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
for(const link of links.filter(p=>own(p)&&p.metadata?.slot&&!keepLinks.has(p.id)))await pause(link);
|
|
140
|
-
if(shop.items.some(i=>i.interval)) {
|
|
141
|
-
const portals=await list('/v1/billing_portal/configurations');
|
|
200
|
+
if(shop.items.some(i=>i.interval)) tasks.push(async()=>{
|
|
201
|
+
const portals=scoped?(knownPortal?[await api('GET',`/v1/billing_portal/configurations/${knownPortal}`,{})]:[]):await list('/v1/billing_portal/configurations');
|
|
142
202
|
let portal=portals.find(p=>own(p));
|
|
143
203
|
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};
|
|
144
204
|
if (!portal || !matchesFields(portal, params)) portal=await api('POST',portal?`/v1/billing_portal/configurations/${portal.id}`:'/v1/billing_portal/configurations',params, portal?undefined:`portal-${hash({slug,site,params})}`);
|
|
145
|
-
config.portal=portal.login_page?.url;
|
|
146
|
-
}
|
|
205
|
+
config.portal=portal.login_page?.url;config.portalId=portal.id;
|
|
206
|
+
});
|
|
207
|
+
await parallel(tasks);
|
|
208
|
+
// Retired booking links must be closed before publishing a configuration which
|
|
209
|
+
// no longer names them; staff can then move into that local-only slot safely.
|
|
210
|
+
for(const link of links.filter(p=>own(p)&&p.metadata?.slot&&!keepLinks.has(p.id)))await pause(link);
|
|
147
211
|
const retire = [
|
|
148
212
|
...links.filter(p=>own(p)&&p.active&&!keepLinks.has(p.id)).map(p=>({method:'POST',path:`/v1/payment_links/${p.id}`,params:{active:false}})),
|
|
149
213
|
...prices.filter(p=>own(p)&&p.active&&!keepPrices.has(p.id)).map(p=>({method:'POST',path:`/v1/prices/${p.id}`,params:{active:false}})),
|