@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/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 assertions on its named surfaces');
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 step of need.steps ?? []) {
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
- await waitInApp(target,step.selector,f=>f.waitForSelector(step.selector, { visible: true, timeout:10000 }),{ready});
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 assertion of need.assertions) {
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 = shots ? 'met' : 'partly';
81
- result.why = shots ? 'UI assertions passed; inspect captures against the pictured scene' : 'Assertions passed but no captures saved';
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) { if (result.status === 'met') result.status = 'partly'; result.why += `; capture failed: ${error.message}`; }
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
- if (shots) mkdirSync(shots, { recursive: true });
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.frame.click(proof.action);
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
  }
@@ -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 claimed itself in the directory');
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 claimed itself in the directory' : 'no directory claim yet (the site claims itself when the directory first reads it)');
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 on the wall and phone' : 'Not yet demonstrable'}</p></article>`).join('')}</div></section>`;
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)}. Screen assertions are evidence; review the 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>`;
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 is observable on a real screen. Fill this executable needs block.
776
- Each need: id, statement, steps (surface wall/phone/phone-two; click/fill/select and selector/value),
777
- assertions (surface, selector, optional text). Include wall and phone assertions and real flow steps.
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 the exact next read/write with metadata and idempotency. Execute via Stripe stripe_api_search/details/read/write, clear observations after writes, repeat until verified. verify:true saves the public config for stripe_webhook. Never pass webhook creation responses or secrets.',
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
+ }
@@ -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 products = await list('/v1/products');
24
- const prices = await list('/v1/prices');
25
- const links = await list('/v1/payment_links');
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
- for (const item of shop.items) {
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) continue; // Free orders never leave the studio.
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 items = group.slice(offset, offset + 10);
83
- if (items.length < 2) continue;
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)continue;
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))continue;
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
- // Retired booking links must be closed before publishing a configuration which
138
- // no longer names them; staff can then move into that local-only slot safely.
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}})),