@homie-rocks/studio 0.50.6 → 0.51.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +62 -0
- package/app-starters/giving/README.md +25 -2
- package/app-starters/giving/app.json +16 -49
- package/app-starters/giving/index.html +1 -1
- package/app-starters/giving/src/rules.ts +1 -1
- package/app-starters/giving/src/view.ts +57 -59
- package/apps/APPS.md +70 -2
- package/apps/apps.ts +1 -1
- package/bin/homie-studio.mjs +8 -4
- package/functions/FUNCTIONS.md +39 -2
- package/guides/app/SKILL.md +61 -5
- package/guides/functions/SKILL.md +30 -0
- package/guides/office/SKILL.md +20 -0
- package/guides/servers/SKILL.md +18 -0
- package/guides/shop/SKILL.md +41 -20
- package/lib/app-needs.mjs +31 -11
- package/lib/app-ready.mjs +49 -0
- package/lib/check.mjs +51 -35
- package/lib/dev-stripe-fixture.mjs +37 -0
- package/lib/dev-stripe-sandbox.mjs +80 -0
- package/lib/dev.mjs +19 -17
- package/lib/events-cli.mjs +5 -1
- package/lib/local-test.mjs +18 -0
- package/lib/mcp-tools.mjs +15 -1
- package/lib/office.mjs +1 -1
- package/lib/scaffold.mjs +22 -1
- package/lib/shop-links.mjs +79 -7
- package/lib/shop.mjs +4 -2
- package/lib/stripe-connect.mjs +1 -0
- package/lib/template-history.json +84 -0
- package/lib/tools-cli.mjs +3 -0
- package/package.json +1 -1
- package/shop/SELLING.md +2 -2
- package/shop/SHOP.md +120 -5
- package/shop/shop.ts +29 -0
- package/tools/tools.ts +8 -1
- package/worker/app-format.mjs +8 -1
- package/worker/app-frame.mjs +9 -0
- package/worker/app-records.mjs +39 -10
- package/worker/booking-store.mjs +79 -0
- package/worker/build-id.mjs +1 -1
- package/worker/event-resources.mjs +30 -3
- package/worker/functions.mjs +5 -2
- package/worker/index.mjs +17 -3
- package/worker/local-test.mjs +19 -0
- package/worker/mcp-tools.mjs +19 -8
- package/worker/mcp.mjs +23 -11
- package/worker/records-page.mjs +13 -0
- package/worker/shop-links.mjs +3 -2
- package/worker/shop-page.mjs +27 -5
- package/worker/shop-rules.mjs +2 -1
- package/worker/shop-store.mjs +1 -1
- package/worker/shop-subscriptions.mjs +198 -0
- package/worker/shop.mjs +195 -29
- package/worker/tool-database.mjs +36 -0
- package/worker/version.mjs +1 -1
package/worker/index.mjs
CHANGED
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
import {APP_STORAGE_JS} from './app-frame.mjs';
|
|
2
|
+
import {recordsPage} from './records-page.mjs';
|
|
1
3
|
import { RULES_WIRING_MESSAGE } from './rules-wiring.mjs';
|
|
2
4
|
import { characterStore, characterRequest } from './characters.mjs';
|
|
3
5
|
export { useFunctions } from './functions.mjs';
|
|
@@ -183,7 +185,10 @@ async function sellingShop(env, cat) {
|
|
|
183
185
|
}
|
|
184
186
|
|
|
185
187
|
async function availableShellShop(env, cat, game, options) {
|
|
186
|
-
|
|
188
|
+
// The shell can show a disabled catalog before payment setup is complete.
|
|
189
|
+
// Checkout readiness is enforced by the shop API, separately from visibility.
|
|
190
|
+
const preview = (cat.games ?? []).find(g => g.id === game)?.screen?.shopPreview === true;
|
|
191
|
+
return (preview && !shopOf(cat)?.broken || await sellingShop(env, cat)) ? shellShop(cat, game, options) : null;
|
|
187
192
|
}
|
|
188
193
|
|
|
189
194
|
async function postsOf(env, origin) {
|
|
@@ -529,8 +534,8 @@ async function gameDocument(request, env, url, game, meta, cat, { agent = null }
|
|
|
529
534
|
...(!agent && !watching && await availableShellShop(env, cat, game) ? { shop: true } : {}),
|
|
530
535
|
};
|
|
531
536
|
let html = await res.text();
|
|
532
|
-
const fallback = meta?.kind === 'app' ? `<script>addEventListener('DOMContentLoaded',function(){if(!document.querySelector('canvas')||document.querySelector('[data-webgl-fallback]'))return;try{var c=document.createElement('canvas');if(c.getContext('webgl2'))return;}catch(e){}var p=document.createElement('p');p.dataset.webglFallback='';p.setAttribute('role','status');p.textContent=${JSON.stringify(meta.words?.fallback ?? 'This device cannot display the 3D scene. Try a browser with WebGL enabled; use any available controls below.').replace(/</g, '\u003c')};p.style.cssText='position:fixed;z-index:2147483647;left:16px;right:16px;
|
|
533
|
-
const head = fallback + `${embedded ? `<script>${EMBED_GAME_JS}</script>` : ''}<script>window.HOMIE_NET=${JSON.stringify(cfg).replace(/</g, '\\u003c')}</script>`;
|
|
537
|
+
const fallback = meta?.kind === 'app' ? `<script>addEventListener('DOMContentLoaded',function(){if(!document.querySelector('canvas')||document.querySelector('[data-webgl-fallback]'))return;try{var c=document.createElement('canvas');if(c.getContext('webgl2'))return;}catch(e){}var p=document.createElement('p');p.dataset.webglFallback='';p.setAttribute('role','status');p.textContent=${JSON.stringify(meta.words?.fallback ?? 'This device cannot display the 3D scene. Try a browser with WebGL enabled; use any available controls below.').replace(/</g, '\u003c')};p.style.cssText='position:fixed;z-index:2147483647;left:16px;right:16px;bottom:16px;padding:12px;background:#fff;color:#222;font:16px system-ui;border-radius:12px';document.body.append(p);});</script>` : '';
|
|
538
|
+
const head = (meta.kind==='app'?`<script>${APP_STORAGE_JS}</script>`:'') + fallback + `${embedded ? `<script>${EMBED_GAME_JS}</script>` : ''}<script>window.HOMIE_NET=${JSON.stringify(cfg).replace(/</g, '\\u003c')}</script>`;
|
|
534
539
|
html = /<head[^>]*>/i.test(html) ? html.replace(/<head([^>]*)>/i, `<head$1>${head}`) : head + html;
|
|
535
540
|
return new Response(html, {
|
|
536
541
|
headers: {
|
|
@@ -610,6 +615,7 @@ export default {
|
|
|
610
615
|
async scheduled(event, env, ctx) {
|
|
611
616
|
const maintenance=(async () => {
|
|
612
617
|
const cat = await catalogue(env, 'https://studio.invalid');
|
|
618
|
+
await notifyTools(env,cat,cat.studio?.url??'https://studio.invalid').catch(error=>console.warn('Tool catalogue refresh:',error.message));
|
|
613
619
|
await reconcileOrders(env, shopOf(cat));
|
|
614
620
|
await (await import('./functions.mjs')).scheduleFunctions(event,env,cat,cat.studio?.url??'https://studio.invalid');
|
|
615
621
|
})();
|
|
@@ -638,6 +644,8 @@ export default {
|
|
|
638
644
|
try { console.error(JSON.stringify({ at: new Date().toISOString(), ev: 'lobby-route-failed', path: url.pathname, error: errorLine(error) })); } catch { /* no console */ }
|
|
639
645
|
res = json({ ok: false, error: 'failed' }, 500);
|
|
640
646
|
} finally {
|
|
647
|
+
// Refresh after the route has populated the catalogue cache.
|
|
648
|
+
if(env.DB&&env.LOBBY&&ctx?.waitUntil)ctx.waitUntil(catalogue(env,url.origin).then(cat=>notifyTools(env,cat,url.origin)).catch(()=>{}));
|
|
641
649
|
// A route can commit a write and then fail. Its outbox still needs delivery.
|
|
642
650
|
if(env.DB) {
|
|
643
651
|
const delivery=dispatch();
|
|
@@ -649,6 +657,11 @@ export default {
|
|
|
649
657
|
},
|
|
650
658
|
};
|
|
651
659
|
|
|
660
|
+
async function notifyTools(env,cat,origin) {
|
|
661
|
+
if(!env.DB||!env.LOBBY)return;
|
|
662
|
+
const [{pushToolList},{builtins}]=await Promise.all([import('./event-resources.mjs'),import('./mcp-tools.mjs')]);
|
|
663
|
+
await pushToolList(env,cat,[...builtins(env,cat,origin),...await studioTools()],origin);
|
|
664
|
+
}
|
|
652
665
|
let studioTools = async () => [];
|
|
653
666
|
/** Studio-authored Worker code, loaded only on the MCP surface. */
|
|
654
667
|
export function useTools(load) { studioTools = load; }
|
|
@@ -920,6 +933,7 @@ async function route(request, env, ctx) {
|
|
|
920
933
|
if (a.status === 401) return Response.redirect(`${url.origin}/account/?next=${encodeURIComponent(url.pathname + url.search)}`, 302);
|
|
921
934
|
return json({ ok: false, error: a.error }, a.status);
|
|
922
935
|
}
|
|
936
|
+
if(!meta.room)return recordsPage(meta,url,cat,await availableShellShop(env,cat,game));
|
|
923
937
|
}
|
|
924
938
|
}
|
|
925
939
|
const lobby = () => env.LOBBY.get(env.LOBBY.idFromName(game));
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
/** Only dev supplies this secret. Neither a loopback URL nor a role parameter grants authority. */
|
|
2
|
+
const local = (request, env) => env.HOMIE_LOCAL_TEST_SECRET && ['localhost','127.0.0.1','[::1]'].includes(new URL(request.url).hostname);
|
|
3
|
+
export async function localCaller(request, env) {
|
|
4
|
+
if (!local(request, env) || request.headers.get('authorization') !== `Bearer ${env.HOMIE_LOCAL_TEST_SECRET}`) return null;
|
|
5
|
+
return {id:'local-test-owner',owner:true,client:null};
|
|
6
|
+
}
|
|
7
|
+
export async function localSession(request, env) {
|
|
8
|
+
if (!local(request, env)) return null;
|
|
9
|
+
const token = /(?:^|;\s*)homie_test=([^;]+)/.exec(request.headers.get('cookie') ?? '')?.[1];
|
|
10
|
+
if (!token) return null;
|
|
11
|
+
try {
|
|
12
|
+
const [data, signature] = token.split('.');
|
|
13
|
+
if (!/^[a-f0-9]{64}$/.test(signature)) return null;
|
|
14
|
+
const key = await crypto.subtle.importKey('raw',new TextEncoder().encode(env.HOMIE_LOCAL_TEST_SECRET),{name:'HMAC',hash:'SHA-256'},false,['verify']);
|
|
15
|
+
if (!await crypto.subtle.verify('HMAC',key,Uint8Array.from(signature.match(/../g),h=>parseInt(h,16)),new TextEncoder().encode(data))) return null;
|
|
16
|
+
const session = JSON.parse(atob(data.replaceAll('-','+').replaceAll('_','/')));
|
|
17
|
+
return /^[a-z][a-z0-9-]*$/.test(session.app) && /^[a-z][a-z0-9-]*$/.test(session.role) && (!session.scope || /^[a-zA-Z0-9-]+$/.test(session.scope)) ? session : null;
|
|
18
|
+
} catch { return null; }
|
|
19
|
+
}
|
package/worker/mcp-tools.mjs
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import {toolDatabase} from './tool-database.mjs';
|
|
1
2
|
import { selling } from './extensions.mjs';
|
|
2
3
|
import { withToolIdentity } from './tool-identity.mjs';
|
|
3
4
|
import { officeRoutes } from './office.mjs';
|
|
@@ -13,7 +14,7 @@ export async function allowed(audience, caller, env) {
|
|
|
13
14
|
if (audience === 'signed-in') return true;
|
|
14
15
|
if (audience === 'owner') return caller.owner === true;
|
|
15
16
|
if (audience && typeof audience === 'object' && audience.app && audience.role) {
|
|
16
|
-
return caller.owner || Boolean(await env.DB.prepare(
|
|
17
|
+
return caller.owner || Boolean(await env.DB.prepare("SELECT player FROM app_roles WHERE app IN (?1,'*') AND role=?2 AND player=?3").bind(audience.app,audience.role,caller.id).first());
|
|
17
18
|
}
|
|
18
19
|
return false;
|
|
19
20
|
}
|
|
@@ -35,7 +36,7 @@ export async function currentCaller(env, props) {
|
|
|
35
36
|
export function services(env, cat, origin, caller, namespace, definition = {}) {
|
|
36
37
|
const records = async ({app,role,collection,id,operation='read',data,version}) => {
|
|
37
38
|
const meta = cat.games.find(g=>g.id===app && g.kind==='app');
|
|
38
|
-
if (!meta || !/^[a-z][a-z0-9-]{0,39}$/.test(collection) || (id!==undefined && !/^[A-Za-z0-9_-]{1,64}$/.test(id))) throw new Error('
|
|
39
|
+
if (!meta || !/^[a-z][a-z0-9-]{0,39}$/.test(collection) || (id!==undefined && !/^[A-Za-z0-9_-]{1,64}$/.test(id))) throw Object.assign(new Error('Unknown app or collection; collection names start with a lowercase letter and use lowercase letters, digits and hyphens (1–40). Record id: 1–64 letters, digits, underscore or hyphen.'),{name:'StudioOperationError'});
|
|
39
40
|
if (!caller.owner && launchOf(meta,await settingsOf(env,{fresh:true}),env)!=='public') throw new Error('app is private');
|
|
40
41
|
const url = new URL(`/${app}/api/app/records/${collection}${id ? '/'+id : ''}`,origin);if(role)url.searchParams.set('role',role);
|
|
41
42
|
const method = {read:'GET',create:'POST',update:'PUT',delete:'DELETE'}[operation];if (!method) throw new Error('unsupported operation');
|
|
@@ -57,10 +58,7 @@ export function services(env, cat, origin, caller, namespace, definition = {}) {
|
|
|
57
58
|
caller:Object.freeze({...caller,roles:Object.freeze(Object.fromEntries(Object.entries(caller.roles??{}).map(([app,roles])=>[app,Object.freeze([...roles])])) )}), records, office,
|
|
58
59
|
rooms:Object.freeze({send:args=>import('./tool-events.mjs').then(m=>m.sendToolEvent(env,caller,definition,args))}),
|
|
59
60
|
shop:()=>office('/_studio/api/shop'),
|
|
60
|
-
database:
|
|
61
|
-
async get(k){const r=await env.DB.prepare('SELECT value FROM tool_data WHERE namespace=?1 AND key=?2').bind(namespace,key(k)).first();return r?JSON.parse(r.value):null;},
|
|
62
|
-
async put(k,value){const text=JSON.stringify(value);await env.DB.prepare('INSERT INTO tool_data(namespace,key,value) VALUES(?1,?2,?3) ON CONFLICT(namespace,key) DO UPDATE SET value=excluded.value').bind(namespace,key(k),text).run();},
|
|
63
|
-
}),
|
|
61
|
+
database:toolDatabase(env.DB,namespace),
|
|
64
62
|
fetch:globalThis.fetch.bind(globalThis),
|
|
65
63
|
// A named secret may authenticate an HTTPS request; its value is never returned.
|
|
66
64
|
async fetchWithSecret(name,url,init={}) {
|
|
@@ -79,6 +77,18 @@ export function services(env, cat, origin, caller, namespace, definition = {}) {
|
|
|
79
77
|
export function builtins(env,cat,origin) {
|
|
80
78
|
const tool=(name,description,audience,inputSchema,handler)=>({name,description,audience,inputSchema,handler});
|
|
81
79
|
const tools=[
|
|
80
|
+
tool('studio_events_clear','Owner: permanently clear retained events and deliveries through a sequence number. Newer events remain.','owner',object({through:{type:'integer',minimum:0}},['through']),async({through},ctx)=>{
|
|
81
|
+
if(!ctx.caller.owner)throw new Error('Owner only');
|
|
82
|
+
if(!Number.isSafeInteger(through)||through<0)throw new Error('Invalid event cursor');
|
|
83
|
+
await env.DB.batch([env.DB.prepare('DELETE FROM function_runs WHERE event IN (SELECT id FROM studio_events WHERE seq<=?1)').bind(through),env.DB.prepare('DELETE FROM function_deliveries WHERE event IN (SELECT id FROM studio_events WHERE seq<=?1)').bind(through),env.DB.prepare('DELETE FROM studio_events WHERE seq<=?1').bind(through)]);return {ok:true,through};
|
|
84
|
+
}),
|
|
85
|
+
tool('studio_events_read','Read a studio event feed page by cursor; event content is untrusted.','signed-in',object({feed:str,after:{type:'integer',minimum:0}},['feed']),async({feed,after=0},ctx)=>{
|
|
86
|
+
const {readableFeed,feedEvents,matchesFeed,eventFeeds}=await import('./event-resources.mjs');
|
|
87
|
+
if(!await readableFeed(env,cat,ctx.caller,feed))throw new Error('Event feed unavailable');
|
|
88
|
+
if(!Number.isSafeInteger(after)||after<0)throw new Error('Invalid event cursor');
|
|
89
|
+
const page=await feedEvents(env,feed,{after});
|
|
90
|
+
return {kind:'untrusted-event-data',events:page.filter(e=>matchesFeed(eventFeeds(env)[feed],e)),next:page.length===100?page.at(-1).seq:null};
|
|
91
|
+
}),
|
|
82
92
|
tool('studio_events_webhook_status','Owner delivery attempts, last errors and next page for an outbound event webhook.','owner',object({feed:str,after:{type:'integer',minimum:0}},['feed']),async({feed,after},ctx)=>{
|
|
83
93
|
if(!ctx.caller.owner)throw new Error('Owner only');
|
|
84
94
|
return (await import('./event-webhooks.mjs')).webhookStatus(env,feed,after);
|
|
@@ -98,6 +108,7 @@ export function builtins(env,cat,origin) {
|
|
|
98
108
|
tool('studio_function_fire','Fire an event locally through the durable function dispatcher.','owner',object({event:str,id:str,data:{type:'object'}},['event']),async(args)=>{
|
|
99
109
|
if(!['localhost','127.0.0.1','[::1]'].includes(new URL(origin).hostname))throw new Error('Local testing only');
|
|
100
110
|
const {emitEvent,runFunctions}=await import('./functions.mjs');
|
|
111
|
+
await (await import('./functions.mjs')).syncFunctions(env);
|
|
101
112
|
const id=await emitEvent(env,args.event,args.data??{},{id:args.id});await runFunctions(env,cat,origin);return {id};
|
|
102
113
|
}),
|
|
103
114
|
tool('studio_info','The public studio, apps, games and posts.','public',object(),async()=>{const pub=publicCatalogue(cat,await settingsOf(env),env);return {studio:pub.studio?.name,games:pub.games.map(g=>({id:g.id,name:g.name,kind:g.kind??'game'})),posts:pub.posts};}),
|
|
@@ -106,7 +117,7 @@ export function builtins(env,cat,origin) {
|
|
|
106
117
|
tool('app_role','Grant or revoke a staff role by verified email or exact sign-in name.','owner',object({app:str,role:str,signIn:{type:'string',maxLength:254},revoke:{type:'boolean'}},['app','role','signIn']),async({app,role,signIn,revoke},ctx)=>{
|
|
107
118
|
if(!ctx.caller.owner)throw new Error('Owner only');
|
|
108
119
|
const meta=cat.games.find(g=>g.id===app&&g.kind==='app');
|
|
109
|
-
if(!meta?.roles?.[role]?.signIn)throw new Error('Name a sign-in role');
|
|
120
|
+
if(!/^[a-z][a-z0-9-]{0,39}$/.test(role) || (app!=='*' && !meta?.roles?.[role]?.signIn))throw new Error('Name a sign-in role');
|
|
110
121
|
const people=signIn.includes('@')
|
|
111
122
|
? (await env.DB.prepare('SELECT p.id FROM players p JOIN player_emails e ON e.player=p.id WHERE e.email=?1 AND e.verified_at IS NOT NULL AND p.guest=0').bind(signIn.trim().toLowerCase()).all()).results
|
|
112
123
|
: (await env.DB.prepare('SELECT id FROM players WHERE name=?1 AND guest=0').bind(signIn).all()).results;
|
|
@@ -125,7 +136,7 @@ export function builtins(env,cat,origin) {
|
|
|
125
136
|
const collections=new Set(Object.values(roles).flatMap(r=>r.can.map(cap=>cap.split(':')[1])));
|
|
126
137
|
return {app,roles,collections:Object.fromEntries(Object.entries(meta.records?.collections??{}).filter(([name])=>collections.has(name)))};
|
|
127
138
|
}),
|
|
128
|
-
tool('app_records','List, read, create, update or delete app records with the app role and record version.','public',object({app:str,role:str,collection:
|
|
139
|
+
tool('app_records','List, read, create, update or delete app records with the app role and record version.','public',object({app:str,role:str,collection:{type:'string',pattern:'^[a-z][a-z0-9-]{0,39}$'},id:{type:'string',pattern:'^[A-Za-z0-9_-]{1,64}$',description:'1–64 letters, digits, underscore or hyphen'},operation:{enum:['read','create','update','delete']},data:{type:'object',propertyNames:{pattern:'^[a-z][a-z0-9_]*$'},description:'Field names start with a lowercase letter and use lowercase letters, digits and underscore; discover fields with app_schema.'},version:{type:'integer',minimum:1}},['app','collection']), (args,ctx)=>ctx.records(args)),
|
|
129
140
|
];
|
|
130
141
|
for(const [name,path,description] of [
|
|
131
142
|
['room_announce','announce','Announce to a room.'],['room_kick','kick','Ask the owner to remove a player.'],['room_mute','mute','Ask the owner to mute a player.'],['room_close','close','Ask the owner to close a room.'],['studio_game','game','Ask the owner to change game access or room size.'],['shop_refund','shop/refund','Ask the owner to refund an order.'],
|
package/worker/mcp.mjs
CHANGED
|
@@ -1,4 +1,10 @@
|
|
|
1
|
-
|
|
1
|
+
function freshErrorMessage(error,env,caller) {
|
|
2
|
+
let message=String(error?.message??error);
|
|
3
|
+
for(const [key,value] of Object.entries(env))if(typeof value==='string'&&/SECRET|KEY|TOKEN|PASSWORD/.test(key)&&value.length)message=message.replaceAll(value,'[redacted]');
|
|
4
|
+
return message;
|
|
5
|
+
}
|
|
6
|
+
import {localCaller} from './local-test.mjs';
|
|
7
|
+
import {eventFeeds,feedUri,readableFeed,feedEvents,matchesFeed,subscribeFeed,eventHub,pushToolList} from './event-resources.mjs';
|
|
2
8
|
import { selling } from './extensions.mjs';
|
|
3
9
|
import OAuthProvider from '@cloudflare/workers-oauth-provider';
|
|
4
10
|
import { createMcpHandler } from 'agents/mcp/server';
|
|
@@ -29,15 +35,19 @@ export async function remoteMcp(request,env,ctx,{catalogueOf,definitions=[]}) {
|
|
|
29
35
|
if(request.headers.has('origin') && request.headers.get('origin')!==origin)return json({error:'origin refused'},403);
|
|
30
36
|
if(path.startsWith('/hooks/tools/')) return (await import('./tool-webhook.mjs')).toolWebhook(request,env,{cat:await catalogueOf(),definitions});
|
|
31
37
|
const scoped={...env,OAUTH_KV:oauthStorage(env.DB)};
|
|
38
|
+
const localIdentity=await localCaller(request,env);
|
|
32
39
|
const api=async(request,env,ctx)=>{
|
|
40
|
+
const identity=()=>localIdentity ?? currentCaller(env,ctx.props);
|
|
33
41
|
let caller;
|
|
34
|
-
try{caller=await
|
|
42
|
+
try{caller=await identity();}catch{return new Response(JSON.stringify({error:'connection revoked'}),{status:401,headers:{'content-type':'application/json','cache-control':'no-store','www-authenticate':`Bearer resource_metadata="${origin}/.well-known/oauth-protected-resource/mcp", error="invalid_token"`}});}
|
|
35
43
|
const cat=await catalogueOf();
|
|
36
|
-
|
|
44
|
+
const tools=[...builtins(env,cat,origin),...definitions];
|
|
45
|
+
await pushToolList(env,cat,tools,origin);
|
|
46
|
+
if(request.method==='GET'&&env.LOBBY) {
|
|
37
47
|
if(!request.headers.get('accept')?.includes('text/event-stream'))return json({error:'Accept text/event-stream required'},406);
|
|
38
|
-
|
|
48
|
+
const connection=caller.connection??'public:'+crypto.randomUUID();
|
|
49
|
+
return eventHub(env,connection).fetch('https://events/__studio_events',{method:'POST',body:JSON.stringify({op:'stream',connection,lastId:request.headers.get('last-event-id'),cat,origin,tools:tools.map(({name,description,inputSchema,audience,kind})=>({name,description,inputSchema,audience,kind}))})});
|
|
39
50
|
}
|
|
40
|
-
const tools=[...builtins(env,cat,origin),...definitions];
|
|
41
51
|
if(request.method==='POST') {
|
|
42
52
|
const body=await request.clone().json().catch(()=>null);
|
|
43
53
|
if(body?.method==='tools/call') {
|
|
@@ -53,7 +63,7 @@ export async function remoteMcp(request,env,ctx,{catalogueOf,definitions=[]}) {
|
|
|
53
63
|
};
|
|
54
64
|
const read=async(name,handler)=>{
|
|
55
65
|
let outcome='error';
|
|
56
|
-
try{const fresh=await
|
|
66
|
+
try{const fresh=await identity();await rate(fresh);const result=await handler(fresh);outcome='ok';return result;}
|
|
57
67
|
finally{
|
|
58
68
|
try{await audit(env,caller,name,outcome);}
|
|
59
69
|
finally{const delivery=import('./functions.mjs').then(m=>m.runFunctions(env,cat,origin));if(ctx?.waitUntil)ctx.waitUntil(delivery);else await delivery;}
|
|
@@ -61,7 +71,7 @@ export async function remoteMcp(request,env,ctx,{catalogueOf,definitions=[]}) {
|
|
|
61
71
|
};
|
|
62
72
|
const server=async()=>{
|
|
63
73
|
const capabilities=await selling.machineCapabilities(env);
|
|
64
|
-
const s=new McpServer({name:cat.studio?.name??'Homie studio',version:'1.0.0'},{jsonSchemaValidator:validator,capabilities:{
|
|
74
|
+
const s=new McpServer({name:cat.studio?.name??'Homie studio',version:'1.0.0'},{jsonSchemaValidator:validator,capabilities:{tools:{listChanged:true},...(Object.keys(eventFeeds(env)).length?{resources:{subscribe:true}}:{}),experimental:{payment:{methods:capabilities.machine?.card?{stripe:{intents:['charge']}}:{}}}}});
|
|
65
75
|
const dispatch=new Map();
|
|
66
76
|
for(const name of Object.keys(eventFeeds(env))) {
|
|
67
77
|
if(!await readableFeed(env,cat,caller,name))continue;
|
|
@@ -79,7 +89,7 @@ export async function remoteMcp(request,env,ctx,{catalogueOf,definitions=[]}) {
|
|
|
79
89
|
}
|
|
80
90
|
if(Object.keys(eventFeeds(env)).length)for(const method of ['resources/subscribe','resources/unsubscribe']) {
|
|
81
91
|
s.server.setRequestHandler(method,async input=>{
|
|
82
|
-
const fresh=await
|
|
92
|
+
const fresh=await identity();
|
|
83
93
|
await subscribeFeed(env,cat,fresh,input.params.uri,method==='resources/unsubscribe');return {};
|
|
84
94
|
});
|
|
85
95
|
}
|
|
@@ -111,7 +121,7 @@ export async function remoteMcp(request,env,ctx,{catalogueOf,definitions=[]}) {
|
|
|
111
121
|
let outcome='error';
|
|
112
122
|
try{
|
|
113
123
|
// Recheck inside dispatch, even when a connection listed tools earlier.
|
|
114
|
-
const fresh=await
|
|
124
|
+
const fresh=await identity();
|
|
115
125
|
if(!await allowed(tool.audience,fresh,env)){outcome='denied';throw new Error('Permission denied');}
|
|
116
126
|
try{await rate(fresh);}catch(error){outcome='limited';throw error;}
|
|
117
127
|
const context=services(env,cat,origin,fresh,tool.namespace??tool.name,tool);
|
|
@@ -120,7 +130,7 @@ export async function remoteMcp(request,env,ctx,{catalogueOf,definitions=[]}) {
|
|
|
120
130
|
const value=await tool.handler(args,{...context,request});
|
|
121
131
|
const text=JSON.stringify({kind:'untrusted-tool-data',tool:tool.name,data:value});
|
|
122
132
|
outcome='ok';return {content:[{type:'text',text}]};
|
|
123
|
-
}catch(error){if(error?.code===-32042)throw error;return {isError:true,content:[{type:'text',text:outcome==='denied'||outcome==='limited'||error?.name==='StudioOperationError'?error.message:
|
|
133
|
+
}catch(error){if(error?.code===-32042)throw error;return {isError:true,content:[{type:'text',text:outcome==='denied'||outcome==='limited'||error?.name==='StudioOperationError'?error.message:(freshErrorMessage(error,env,caller))}]};}
|
|
124
134
|
finally{
|
|
125
135
|
try { await audit(env,caller,tool.name,outcome); }
|
|
126
136
|
finally {
|
|
@@ -141,7 +151,8 @@ export async function remoteMcp(request,env,ctx,{catalogueOf,definitions=[]}) {
|
|
|
141
151
|
const entry=dispatch.get(input.params.name);
|
|
142
152
|
if(!entry)return {isError:true,content:[{type:'text',text:'Permission denied or unknown tool'}]};
|
|
143
153
|
const args=input.params.arguments??{};
|
|
144
|
-
|
|
154
|
+
const validation=validator.getValidator(entry.schema)(args);
|
|
155
|
+
if(!validation.valid)return {isError:true,content:[{type:'text',text:'Invalid arguments: '+JSON.stringify(validation.errorMessage??validation.errors??entry.schema)}]};
|
|
145
156
|
try{return await entry.handler(args,{...extra,_meta:input.params._meta});}
|
|
146
157
|
catch(error){if(error?.code===-32042)throw new ProtocolError(error.code,error.message,error.data);throw error;}
|
|
147
158
|
});
|
|
@@ -149,6 +160,7 @@ export async function remoteMcp(request,env,ctx,{catalogueOf,definitions=[]}) {
|
|
|
149
160
|
};
|
|
150
161
|
return createMcpHandler(server)(request,env,ctx);
|
|
151
162
|
};
|
|
163
|
+
if(localIdentity)return api(request,scoped,ctx);
|
|
152
164
|
const provider=new OAuthProvider({
|
|
153
165
|
apiRoute:'/mcp',apiHandler:{fetch:api},
|
|
154
166
|
authorizeEndpoint:'/oauth/authorize',tokenEndpoint:'/oauth/token',clientRegistrationEndpoint:'/oauth/register',
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
import {playerProperties} from './embed.mjs';
|
|
2
|
+
import {appRole} from './app-format.mjs';
|
|
3
|
+
import {esc} from './site.mjs';
|
|
4
|
+
import {SHOP_SHELL_JS, SHOP_SHELL_CSS} from './shop-page.mjs';
|
|
5
|
+
import {APP_SHELL_JS} from './app-shell.mjs';
|
|
6
|
+
/** A records-only app has no room simulation. The shell owns one frame and its authenticated records bridge. */
|
|
7
|
+
export function recordsPage(meta,url,cat={},shop=null) {
|
|
8
|
+
const params=new URLSearchParams(url.search);params.delete('access');params.set('role',appRole(meta,url));if(!params.has('surface'))params.set('surface',url.pathname.endsWith('/tv')?'wall':'phone');
|
|
9
|
+
const tags=Object.entries(playerProperties(cat,meta,{origin:url.origin})).map(([name,content])=>`<meta name="${esc(name)}" content="${esc(content)}">`).join('');
|
|
10
|
+
const boot={kind:'app',game:meta.id,screen:url.pathname.endsWith('/tv'),shop};
|
|
11
|
+
const source=`/${meta.id}/__game/?${params}`;
|
|
12
|
+
return new Response(`<!doctype html><html><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>${esc(meta.name)}</title>${tags}<style>${SHOP_SHELL_CSS}html,body{margin:0;height:100%;background:#10151c;color:white;font:18px system-ui}iframe{border:0;width:100%;height:100%;display:block}[data-arrive]{position:fixed;inset:0;display:grid;place-items:center;background:#10151c}[data-arrive][hidden]{display:none;opacity:0;visibility:hidden}</style></head><body><div data-arrive>Opening ${esc(meta.name)}…</div><iframe class="game" title="${esc(meta.name)}" sandbox="allow-scripts allow-forms allow-modals allow-popups" allow="autoplay *; fullscreen *"></iframe><script>window.__HOMIE_APP=${JSON.stringify(boot).replace(/</g,'\\u003c')};window.__HOMIE_PLAY=window.__HOMIE_APP;window.__shell={room:new URLSearchParams(location.search).get('room')||'main',link:{state:'connecting'},arrival:{phase:'loading'}};</script><script>${APP_SHELL_JS}</script><script>${SHOP_SHELL_JS}</script><script>const frame=document.querySelector('iframe');frame.addEventListener('load',()=>{if(!frame.getAttribute('src'))return;requestAnimationFrame(()=>requestAnimationFrame(()=>{document.querySelector('[data-arrive]').hidden=true;window.__shell.link.state='online';window.__shell.arrival.phase='done';}));});frame.src=${JSON.stringify(source).replace(/</g,'\\u003c')};</script></body></html>`,{headers:{'content-type':'text/html;charset=utf-8','cache-control':'no-store, no-transform','x-frame-options':'SAMEORIGIN'}});
|
|
13
|
+
}
|
package/worker/shop-links.mjs
CHANGED
|
@@ -2,9 +2,9 @@
|
|
|
2
2
|
import { KEY_SHAPE, modeOf } from './stripe.mjs';
|
|
3
3
|
// Current documented Payment Link fields, isolated from the existing keyed transport version.
|
|
4
4
|
export const LINKS_STRIPE_VERSION = '2026-09-30.endive';
|
|
5
|
-
export const hasStripeKey = (env) => KEY_SHAPE.test(String(env?.STRIPE_KEY ?? ''));
|
|
5
|
+
export const hasStripeKey = (env) => env?.STRIPE_CHECKOUT_MODE === 'sessions' && KEY_SHAPE.test(String(env?.STRIPE_KEY ?? ''));
|
|
6
6
|
export function linkConfig(env) {
|
|
7
|
-
try { const c = JSON.parse(
|
|
7
|
+
try { const raw=env?.STRIPE_SHOP_LINKS??'null'; const c = JSON.parse(raw.startsWith('base64:') ? new TextDecoder().decode(Uint8Array.from(atob(raw.slice(7)),c=>c.charCodeAt(0))) : raw); return c?.v === 1 && ['test', 'live'].includes(c.mode) && Array.isArray(c.items) ? c : null; } catch { return null; }
|
|
8
8
|
}
|
|
9
9
|
export const shopMode = (env) => hasStripeKey(env) ? modeOf(env.STRIPE_KEY) : linkConfig(env)?.mode ?? 'test';
|
|
10
10
|
export function linkSettings(shop) {
|
|
@@ -12,6 +12,7 @@ export function linkSettings(shop) {
|
|
|
12
12
|
cap: shop.capPerPlayerMonth, checkoutMinutes: shop.checkoutMinutes, items: shop.items });
|
|
13
13
|
}
|
|
14
14
|
export function fullerReason(shop) {
|
|
15
|
+
if (shop.items.some(item => item.interval && item.price === 'choose')) return 'A giver-chosen monthly amount needs a Stripe Checkout connection.';
|
|
15
16
|
if (shop.capPerPlayerMonth !== null) return 'Reserving a spending allowance before payment needs the fuller checkout connection.';
|
|
16
17
|
if (shop.checkoutMinutes !== 1440) return 'Choosing when abandoned checkouts expire needs the fuller checkout connection.';
|
|
17
18
|
return null;
|
package/worker/shop-page.mjs
CHANGED
|
@@ -61,7 +61,7 @@ export const WAY_LINES = Object.freeze({
|
|
|
61
61
|
export function shopPage(cat, shop, { origin = '', game = null, item = null, open = false, mode = null } = {}) {
|
|
62
62
|
const studio = cat.studio?.name ?? 'Studio';
|
|
63
63
|
const g = game ? (cat.games ?? []).find((x) => x.id === game) : null;
|
|
64
|
-
const preview = !open
|
|
64
|
+
const preview = !open;
|
|
65
65
|
const giving = Boolean(shop.giving);
|
|
66
66
|
const previewItems = preview ? shop.items.map(i => `<article class="item"><h2>${esc(i.name)}</h2><span class="price">${esc(money(i.price === 'choose' ? i.min : i.price, shop.currency))}${i.interval ? ' / month' : ''}</span><p>${esc(i.blurb ?? '')}</p><button disabled>Preview only</button></article>`).join('') : '';
|
|
67
67
|
const boot = { giving, preview, game: g ? g.id : null, item: ITEM_ID.test(String(item ?? '')) ? item : null, studio };
|
|
@@ -72,7 +72,7 @@ ${mode === 'test' ? '<p class="test">TEST MODE: no real money moves (Stripe test
|
|
|
72
72
|
<p class="lead">${giving ? `Support ${esc(studio)} with a one-time or monthly gift. Gifts go directly to the organisation through its own Stripe account.` : `Things you can buy here, in real money, from ${esc(studio)} itself. Paid purchases open Stripe's checkout page. Guest purchases stay in this browser; keep its cookies or sign in to keep access.`} ${g ? `<a href="/${esc(g.id)}/">Back to ${esc(g.name)}</a>` : ''}</p>
|
|
73
73
|
<noscript><p class="lead">This page needs JavaScript.</p></noscript>
|
|
74
74
|
<div data-shop-age hidden></div>
|
|
75
|
-
<div class="items" data-shop-items><p>${open ? 'Reading the shop…' : preview ? 'PREVIEW —
|
|
75
|
+
<div class="items" data-shop-items><p>${open ? 'Reading the shop…' : preview ? 'PREVIEW — not taking payments yet. Checkout is disabled. Connect Stripe to open.' : 'Connect the organisation’s Stripe account to open giving or sales.'}</p>${previewItems}</div>
|
|
76
76
|
${giving && shop.giving.receiptText ? `<p class="note">${esc(shop.giving.receiptText)}</p>` : ''}
|
|
77
77
|
<p class="msg" data-shop-msg role="status" aria-live="polite"></p>
|
|
78
78
|
<p class="note">${giving ? `${esc(studio)} receives your gift through its own Stripe account. Stripe emails the payment receipt.` : `${esc(studio)} is the seller: it sets these prices, answers for refunds and receives the money through its own Stripe account. Homie takes nothing. ${refundWords(shop)} <a href="/shop/refunds/">refunds</a>. Stripe keeps your card and email and sends your receipt; ${esc(studio)} never sees them.`}</p>
|
|
@@ -207,6 +207,14 @@ export const SHOP_JS = String.raw`(function () {
|
|
|
207
207
|
age.appendChild(sel); age.appendChild(ok);
|
|
208
208
|
}
|
|
209
209
|
}
|
|
210
|
+
(s.menus || []).forEach(function (menu, index) {
|
|
211
|
+
var button = el('button', {type:'button',class:'buy'}, s.menus.length > 1 ? 'Choose items on Stripe · Menu ' + (index+1) : 'Choose several items on Stripe');
|
|
212
|
+
button.onclick = function () {
|
|
213
|
+
button.disabled=true;
|
|
214
|
+
api('POST','/api/shop/buy',{lines:menu.items.map(function(item){return {item:item,quantity:1};}),game:boot.game||undefined}).then(function(r){if(!r.ok||!r.url)throw new Error(r.message||'Could not open menu');location.href=r.url;}).catch(function(e){button.disabled=false;say(e.message,true);});
|
|
215
|
+
};
|
|
216
|
+
list.appendChild(button);
|
|
217
|
+
});
|
|
210
218
|
s.items.forEach(function (i) {
|
|
211
219
|
var card = el('article', { class: 'item', id: 'item-' + i.id });
|
|
212
220
|
card.appendChild(el('h2', null, i.name));
|
|
@@ -236,7 +244,7 @@ export const SHOP_JS = String.raw`(function () {
|
|
|
236
244
|
var add = el('button', { type: 'button', class: 'plain' }, 'Add to cart');
|
|
237
245
|
add.onclick = function () { cart.push({ item: i.id, name: i.name, quantity: Number(quantity.value), amount: amount ? units(amount.value, s.currencyScale) : undefined }); drawCart(); };
|
|
238
246
|
if (!s.keyless) { act.appendChild(quantity); act.appendChild(add); }
|
|
239
|
-
else if (i.price !== 0) act.appendChild(el('p', { class: 'note' }, i.price === 'choose' ? 'Choose your amount on Stripe.' : boot.giving ? 'Confirm your gift on Stripe.' : 'Confirm quantity on Stripe
|
|
247
|
+
else if (i.price !== 0) act.appendChild(el('p', { class: 'note' }, i.price === 'choose' ? 'Choose your amount on Stripe.' : boot.giving ? 'Confirm your gift on Stripe.' : 'Confirm quantity on Stripe, or use a menu above for several items.'));
|
|
240
248
|
} else if (i.way === 'owned') {
|
|
241
249
|
act.appendChild(el('span', { class: 'owned' }, 'Yours ✓'));
|
|
242
250
|
} else if (i.way === 'make-an-account') {
|
|
@@ -261,6 +269,14 @@ export const SHOP_JS = String.raw`(function () {
|
|
|
261
269
|
list.appendChild(card);
|
|
262
270
|
});
|
|
263
271
|
if (s.nextCursor) { var next = el('button', { type: 'button' }, 'Next items'); next.onclick = function () { load(s.nextCursor); }; list.appendChild(next); }
|
|
272
|
+
if(s.portalLogin)list.appendChild(el('a',{href:s.portalLogin,target:'_blank',rel:'noopener',class:'plain'},'Manage monthly giving or membership on Stripe'));
|
|
273
|
+
if (!s.portalLogin && s.items.some(function(i) { return i.interval; })) api('GET', '/api/shop/subscriptions').then(function(r) {
|
|
274
|
+
(r.subscriptions || []).forEach(function(sub) {
|
|
275
|
+
var manage = el('button', { type: 'button', class: 'plain' }, 'Manage subscription on Stripe');
|
|
276
|
+
manage.onclick = function() { var tab = window.open('', '_blank'); api('POST', '/api/shop/portal', { subscription: sub.id }).then(function(result) { if (!result.ok || !result.url) throw new Error(result.message || 'Portal unavailable'); if (tab) tab.location.href = result.url; else location.href = result.url; }).catch(function(e) { if (tab) tab.close(); say(e.message, true); }); };
|
|
277
|
+
list.appendChild(manage);
|
|
278
|
+
});
|
|
279
|
+
}).catch(function() {});
|
|
264
280
|
if (!s.items.length) list.appendChild(el('p', null, boot.giving ? 'No gift options are available right now.' : 'Nothing for sale here right now.'));
|
|
265
281
|
if (boot.item) { var at = document.getElementById('item-' + boot.item); if (at && at.scrollIntoView) at.scrollIntoView({ block: 'center' }); }
|
|
266
282
|
}
|
|
@@ -282,7 +298,7 @@ export const SHOP_JS = String.raw`(function () {
|
|
|
282
298
|
(function poll() {
|
|
283
299
|
tries += 1;
|
|
284
300
|
api('GET', '/api/shop/order?session=' + encodeURIComponent(boot.thanks)).then(function (r) {
|
|
285
|
-
if (r.status === 'paid') { returnToPage(false); $('[data-thanks-title]').textContent = boot.giving ? 'Thank you for your gift' : 'It\'s yours'; $('[data-thanks-line]').textContent = boot.giving ? 'Your gift is confirmed. Your receipt comes from Stripe by email.' : 'Paid. It is on your account now, in every game of ' + boot.studio + ' that uses it.'; return; }
|
|
301
|
+
if (r.status === 'paid') { returnToPage(false); $('[data-thanks-title]').textContent = boot.giving ? 'Thank you for your gift' : 'It\'s yours'; $('[data-thanks-line]').textContent = boot.giving ? 'Your gift is confirmed. Your receipt comes from Stripe by email.' : 'Paid. It is on your account now, in every game of ' + boot.studio + ' that uses it.'; if (r.simulated) { $('[data-thanks-title]').textContent = 'Simulation confirmed'; $('[data-thanks-line]').textContent = 'This was an offline test. No money moved and no Stripe receipt was sent.'; } return; }
|
|
286
302
|
if (tries < 40) setTimeout(poll, 1500);
|
|
287
303
|
else $('[data-thanks-line]').textContent = boot.giving ? 'Stripe has not confirmed your gift yet. Check your email for its receipt.' : 'Stripe has not confirmed it yet. It will be on your account as soon as it does; your receipt comes by email.';
|
|
288
304
|
}).catch(function () { if (tries < 40) setTimeout(poll, 3000); });
|
|
@@ -485,13 +501,19 @@ export const SHOP_SHELL_JS = String.raw`(function () {
|
|
|
485
501
|
if (!cfg || kids) return reply({ ok: true, open: false, kids: kids, screen: screen, owns: [] });
|
|
486
502
|
return refresh().then(function () { reply({ ok: true, open: true, kids: false, screen: screen, owns: owns }); });
|
|
487
503
|
}
|
|
504
|
+
if (m.op === 'catalog') {
|
|
505
|
+
if (kids) return reply({ ok: true, open: false, items: [] });
|
|
506
|
+
return api('GET', '/api/shop?game=' + encodeURIComponent(GAME) + '&server=' + encodeURIComponent(SERVER) + '&cursor=' + encodeURIComponent(typeof m.cursor === 'string' ? m.cursor : '')).then(function (r) {
|
|
507
|
+
reply({ ok: r.ok, open: r.open, currency: r.currency, currencyScale: r.currencyScale, items: r.items, nextCursor: r.nextCursor });
|
|
508
|
+
}).catch(function () { reply({ ok: false }); });
|
|
509
|
+
}
|
|
488
510
|
if (m.op === 'owns') return refresh().then(function () { reply({ ok: true, owns: owns }); });
|
|
489
511
|
if (m.op === 'open') { open(typeof m.item === 'string' ? m.item : null); return reply({ ok: true, shown: true }); }
|
|
490
512
|
if (m.op === 'close') { close(); return reply({ ok: true }); }
|
|
491
513
|
if (m.op === 'checkout') {
|
|
492
514
|
if (!cfg || kids || screen) return reply({ ok: false, error: 'policy' });
|
|
493
515
|
var checkoutTab = window.open('', '_blank');
|
|
494
|
-
return api('POST', '/api/shop/buy', { lines: m.lines, reference: m.reference, game: GAME, server: SERVER }).then(function (r) {
|
|
516
|
+
return api('POST', '/api/shop/buy', { lines: m.lines, reference: m.reference, booking: m.booking, game: GAME, server: SERVER }).then(function (r) {
|
|
495
517
|
if (r.ok && r.url) { leaveForCheckout(checkoutTab, r); watch(); } else if (checkoutTab) checkoutTab.close();
|
|
496
518
|
reply(r);
|
|
497
519
|
}).catch(function () { if (checkoutTab) checkoutTab.close(); reply({ ok: false }); });
|
package/worker/shop-rules.mjs
CHANGED
|
@@ -49,6 +49,7 @@ export function amountError(amount, currency) {
|
|
|
49
49
|
|
|
50
50
|
/** Checkout wording is shared by validation and the request path. */
|
|
51
51
|
export function checkoutMessage(shop, studio = 'this studio', parent = false) {
|
|
52
|
+
if (shop.giving?.receiptText) return shop.giving.receiptText;
|
|
52
53
|
return `${parent ? "This is for your child's account. " : ''}Delivered at once to the player's account on ${studio}. ${shop.policy.withdrawalAcknowledgement ? 'By paying you ask for it now, which ends the 14-day withdrawal right;' : 'Your statutory rights still apply.'} ${shop.refundDays === null ? (shop.policy.withdrawalAcknowledgement ? 'ask the studio about refunds.' : 'Ask the studio about refunds.') : `${shop.policy.refundUsedItems ? 'an item' : 'an unused item'} can still be refunded within ${shop.refundDays} days.`}`;
|
|
53
54
|
}
|
|
54
55
|
|
|
@@ -157,7 +158,7 @@ export function checkShop(raw, { games = null, audience = 'general', studioName
|
|
|
157
158
|
if (why) err(`${at}.price`, why);
|
|
158
159
|
else price = numeric(it.price);
|
|
159
160
|
}
|
|
160
|
-
if (it.interval !== undefined &&
|
|
161
|
+
if (it.interval !== undefined && it.interval !== 'month') err(`${at}.interval`, 'interval must be month');
|
|
161
162
|
const charge = chosen ? min : price;
|
|
162
163
|
if (currencyScale(currency) === 1000 && charge % 10) warnings.push({ at: `${at}.price`, message: 'This three-decimal amount uses the smallest minor unit. Confirm support with your Stripe payment method; Stripe decides acceptance (https://docs.stripe.com/currencies).' });
|
|
163
164
|
if (charge > 0 && charge < minimumCharge(currency)) warnings.push({ at: `${at}.price`, message: `Stripe's minimum for settlement in ${currency.toUpperCase()} is ${money(minimumCharge(currency), currency)}. Your settlement currency and payment method determine the actual minimum at checkout.` });
|
package/worker/shop-store.mjs
CHANGED
|
@@ -286,7 +286,7 @@ export function forgetPlayerShop(env, player) {
|
|
|
286
286
|
/** Grant an order's entitlements (idempotent on player, key and order). */
|
|
287
287
|
export function grantStatements(env, order, item, now = Date.now(), line = { id: order.id + '_0', quantity: 1 }) {
|
|
288
288
|
const starts = item.starts ? Math.max(now, Date.parse(item.starts)) : now;
|
|
289
|
-
const ends = item.ends ? Date.parse(item.ends) : item.days !== undefined && item.days !== null ? starts + Math.round(item.days * DAY) : null;
|
|
289
|
+
const ends = item.interval ? starts : item.ends ? Date.parse(item.ends) : item.days !== undefined && item.days !== null ? starts + Math.round(item.days * DAY) : null;
|
|
290
290
|
return (item.gives ?? []).flatMap((key) => [
|
|
291
291
|
env.DB.prepare("INSERT INTO entitlements (player, key, item, order_id, starts_at, ends_at, state, used_at) VALUES (?1, ?2, ?3, ?4, ?5, ?6, 'active', NULL) ON CONFLICT(player, key, order_id) DO UPDATE SET starts_at = MIN(starts_at, excluded.starts_at), ends_at = CASE WHEN ends_at IS NULL OR excluded.ends_at IS NULL THEN NULL ELSE MAX(ends_at, excluded.ends_at) END").bind(order.player, key, item.id, order.id, starts, ends),
|
|
292
292
|
env.DB.prepare("INSERT INTO shop_entitlement_lines (player, key, item, order_id, line_id, quantity, starts_at, ends_at, state) SELECT ?1, ?2, ?3, ?4, ?7, ?8, ?5, ?6, 'active' WHERE NOT EXISTS (SELECT 1 FROM entitlements WHERE player = ?1 AND key = ?2 AND order_id = ?4 AND state != 'active') ON CONFLICT DO NOTHING").bind(order.player, key, item.id, order.id, starts, ends, line.id, line.quantity),
|