@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.
Files changed (56) hide show
  1. package/CHANGELOG.md +62 -0
  2. package/app-starters/giving/README.md +25 -2
  3. package/app-starters/giving/app.json +16 -49
  4. package/app-starters/giving/index.html +1 -1
  5. package/app-starters/giving/src/rules.ts +1 -1
  6. package/app-starters/giving/src/view.ts +57 -59
  7. package/apps/APPS.md +70 -2
  8. package/apps/apps.ts +1 -1
  9. package/bin/homie-studio.mjs +8 -4
  10. package/functions/FUNCTIONS.md +39 -2
  11. package/guides/app/SKILL.md +61 -5
  12. package/guides/functions/SKILL.md +30 -0
  13. package/guides/office/SKILL.md +20 -0
  14. package/guides/servers/SKILL.md +18 -0
  15. package/guides/shop/SKILL.md +41 -20
  16. package/lib/app-needs.mjs +31 -11
  17. package/lib/app-ready.mjs +49 -0
  18. package/lib/check.mjs +51 -35
  19. package/lib/dev-stripe-fixture.mjs +37 -0
  20. package/lib/dev-stripe-sandbox.mjs +80 -0
  21. package/lib/dev.mjs +19 -17
  22. package/lib/events-cli.mjs +5 -1
  23. package/lib/local-test.mjs +18 -0
  24. package/lib/mcp-tools.mjs +15 -1
  25. package/lib/office.mjs +1 -1
  26. package/lib/scaffold.mjs +22 -1
  27. package/lib/shop-links.mjs +79 -7
  28. package/lib/shop.mjs +4 -2
  29. package/lib/stripe-connect.mjs +1 -0
  30. package/lib/template-history.json +84 -0
  31. package/lib/tools-cli.mjs +3 -0
  32. package/package.json +1 -1
  33. package/shop/SELLING.md +2 -2
  34. package/shop/SHOP.md +120 -5
  35. package/shop/shop.ts +29 -0
  36. package/tools/tools.ts +8 -1
  37. package/worker/app-format.mjs +8 -1
  38. package/worker/app-frame.mjs +9 -0
  39. package/worker/app-records.mjs +39 -10
  40. package/worker/booking-store.mjs +79 -0
  41. package/worker/build-id.mjs +1 -1
  42. package/worker/event-resources.mjs +30 -3
  43. package/worker/functions.mjs +5 -2
  44. package/worker/index.mjs +17 -3
  45. package/worker/local-test.mjs +19 -0
  46. package/worker/mcp-tools.mjs +19 -8
  47. package/worker/mcp.mjs +23 -11
  48. package/worker/records-page.mjs +13 -0
  49. package/worker/shop-links.mjs +3 -2
  50. package/worker/shop-page.mjs +27 -5
  51. package/worker/shop-rules.mjs +2 -1
  52. package/worker/shop-store.mjs +1 -1
  53. package/worker/shop-subscriptions.mjs +198 -0
  54. package/worker/shop.mjs +195 -29
  55. package/worker/tool-database.mjs +36 -0
  56. package/worker/version.mjs +1 -1
@@ -0,0 +1,198 @@
1
+ import { linkConfig, hasStripeKey } from './shop-links.mjs';
2
+ import { KEY_SHAPE, stripeCall } from './stripe.mjs';
3
+ /** Only verified shop Checkout sessions establish ownership. Provider customer IDs
4
+ * stay private, and are never accepted from a browser. */
5
+ export const SUBSCRIPTIONS_MIGRATION_FILE = '0026_shop_subscriptions.sql';
6
+ export const SUBSCRIPTIONS_MIGRATION = `
7
+ CREATE TABLE shop_subscriptions (
8
+ id TEXT PRIMARY KEY, order_id TEXT NOT NULL UNIQUE, customer TEXT NOT NULL,
9
+ state TEXT NOT NULL DEFAULT 'active', event_at INTEGER NOT NULL DEFAULT 0
10
+ );
11
+ CREATE TABLE shop_subscription_events (
12
+ id TEXT PRIMARY KEY, subscription TEXT NOT NULL, type TEXT NOT NULL,
13
+ invoice TEXT, amount INTEGER, currency TEXT, at INTEGER NOT NULL
14
+ );
15
+ CREATE TRIGGER shop_subscription_event AFTER INSERT ON shop_subscription_events BEGIN
16
+ INSERT OR IGNORE INTO studio_events(id,type,data,at)
17
+ SELECT 'subscription:' || NEW.id,NEW.type,
18
+ json_object('subscription',NEW.subscription,'order',s.order_id,'app',o.game,
19
+ 'reference',(SELECT value FROM meta WHERE key='shop-reference:' || o.id),
20
+ 'invoice',NEW.invoice,'amount',NEW.amount,'currency',NEW.currency,'mode',o.mode),NEW.at
21
+ FROM shop_subscriptions s JOIN shop_orders o ON o.id=s.order_id
22
+ WHERE s.id=NEW.subscription AND EXISTS(SELECT 1 FROM function_subscriptions WHERE type=NEW.type);
23
+ END;
24
+ `;
25
+ const idOf = x => typeof x === 'string' ? x : x?.id;
26
+ export async function bindSubscription(env, order, session, shop) {
27
+ const subscription=idOf(session.subscription), customer=idOf(session.customer);
28
+ if (!subscription || !customer || !/^sub_[A-Za-z0-9]+$/.test(subscription) || !/^cus_[A-Za-z0-9]+$/.test(customer)) throw new Error('Stripe subscription identity missing');
29
+ await env.DB.batch([
30
+ env.DB.prepare('INSERT OR IGNORE INTO shop_subscriptions(id,order_id,customer) VALUES(?1,?2,?3)').bind(subscription,order.id,customer),
31
+ env.DB.prepare(`INSERT OR IGNORE INTO shop_subscription_events(id,subscription,type,invoice,amount,currency,at)
32
+ VALUES(?1,?2,'subscription.started',NULL,?3,?4,?5)`).bind(`started:${subscription}`,subscription,order.amount,order.currency,order.paid_at),
33
+ ]);
34
+ await reconcileMembership(env, subscription, shop);
35
+ if(!hasStripeKey(env)) {
36
+ const invoice=typeof session.invoice==='string'?session.invoice:session.invoice?.id;
37
+ if(invoice) {
38
+ const saved=await env.DB.prepare('SELECT value FROM meta WHERE key=?1').bind(`shop-invoice:${invoice}`).first();
39
+ if(saved)await invoiceMembership(env,subscription,JSON.parse(saved.value));
40
+ }
41
+ }
42
+ }
43
+
44
+ /** Stripe retries unknown subscriptions until their Checkout has established ownership.
45
+ * Ignore unrelated invoices, and never infer a successful renewal from a status update. */
46
+ export async function subscriptionEvent(env, event, shop) {
47
+ if (!['invoice.paid','invoice.payment_failed','customer.subscription.updated','customer.subscription.deleted'].includes(event.type)) return null;
48
+ const obj=event.data.object;
49
+ const subscription=event.type.startsWith('customer.subscription.') ? obj.id : idOf(obj.parent?.subscription_details?.subscription ?? obj.subscription);
50
+ if (!subscription) return 'ignored';
51
+ let row;
52
+ try {
53
+ row=await env.DB.prepare('SELECT s.*,o.mode FROM shop_subscriptions s JOIN shop_orders o ON o.id=s.order_id WHERE s.id=?1').bind(subscription).first();
54
+ } catch (error) {
55
+ // Existing studios have no subscription ownership until they opt in and migrate.
56
+ // Preserve retries for database failures other than an absent opt-in table.
57
+ if (!/no such table: (?:main\.)?shop_subscriptions\b/.test(String(error?.message))) throw error;
58
+ }
59
+ if (!row) {
60
+ const metadata=obj.parent?.subscription_details?.metadata ?? obj.subscription_details?.metadata ?? obj.metadata;
61
+ if (metadata?.homie === 'shop-v1' || metadata?.homie === 'shop-links-v1') throw new Error('Subscription Checkout has not arrived; retry');
62
+ return 'ignored';
63
+ }
64
+ if (event.livemode !== (row.mode === 'live')) return 'ignored';
65
+ await reconcileMembership(env, subscription, shop);
66
+ if(event.type==='invoice.paid' && !hasStripeKey(env))await invoiceMembership(env,subscription,obj);
67
+ const at=(event.created ?? 0)*1000;
68
+ let type, state;
69
+ if (event.type === 'customer.subscription.deleted') { type='subscription.cancelled'; state='cancelled'; }
70
+ else if (event.type === 'invoice.paid') { if (obj.billing_reason === 'subscription_create') return 'initial'; type='subscription.renewed'; state='active'; }
71
+ else if (event.type === 'invoice.payment_failed') { type='subscription.failed'; state='past_due'; }
72
+ else { return 'updated'; } // Scheduling cancellation is not cancellation; Stripe sends deleted when it ends.
73
+ const eventId=type === 'subscription.renewed' ? `paid:${obj.id}` : event.id;
74
+ await env.DB.batch([
75
+ env.DB.prepare(`UPDATE shop_subscriptions SET state=?2,event_at=?3 WHERE id=?1 AND state!='cancelled' AND event_at<=?3 AND ?4=0`).bind(subscription,state,at,hasStripeKey(env)?1:0),
76
+ env.DB.prepare(`INSERT OR IGNORE INTO shop_subscription_events(id,subscription,type,invoice,amount,currency,at)
77
+ VALUES(?1,?2,?3,?4,?5,?6,?7)`).bind(eventId,subscription,type,event.type.startsWith('invoice.')?obj.id:null,obj.amount_paid??obj.amount_due??null,obj.currency??null,at),
78
+ ...(state === 'cancelled' ? [env.DB.prepare(`UPDATE shop_entitlement_lines SET ends_at=MIN(COALESCE(ends_at,?2),?2)
79
+ WHERE order_id=?1 AND line_id IN (SELECT id FROM shop_order_lines WHERE order_id=?1 AND json_extract(snapshot,'$.interval') IS NOT NULL)`)
80
+ .bind(row.order_id,at)] : []),
81
+ ]);
82
+ return type;
83
+ }
84
+ export async function subscriptionPortal(env, player, subscription, origin) {
85
+ const row=await env.DB.prepare(`SELECT s.customer FROM shop_subscriptions s JOIN shop_orders o ON o.id=s.order_id WHERE s.id=?1 AND o.player=?2`).bind(subscription,player).first();
86
+ if (!row) return null;
87
+ if(!hasStripeKey(env)) {
88
+ const url=linkConfig(env)?.portal;
89
+ return typeof url==='string' && /^https:\/\/billing\.stripe\.com\/p\/login\//.test(url)?url:null;
90
+ }
91
+ const portal=await stripeCall(env,'POST','/v1/billing_portal/sessions',{customer:row.customer,return_url:`${origin}/shop/`});
92
+ if (typeof portal?.url !== 'string' || new URL(portal.url).origin !== 'https://billing.stripe.com') throw new Error('Invalid Stripe portal response');
93
+ return portal.url;
94
+ }
95
+
96
+ /** Reconcile membership access from current provider truth, not delivery order.
97
+ * A subscription item stays attached to its original order line across portal plan
98
+ * changes. Only catalogued products can supply replacement grants. Unknown plans
99
+ * grant nothing. Financial order snapshots remain immutable.
100
+ */
101
+ export async function reconcileMembership(env, subscription, shop) {
102
+ if (!hasStripeKey(env)) return;
103
+ const row = await env.DB.prepare(`SELECT s.*,o.player,o.status AS order_status,o.mode
104
+ FROM shop_subscriptions s JOIN shop_orders o ON o.id=s.order_id WHERE s.id=?1`).bind(subscription).first();
105
+ if (!row) return;
106
+ const lines = (await env.DB.prepare(`SELECT * FROM shop_order_lines WHERE order_id=?1
107
+ AND json_extract(snapshot,'$.interval') IS NOT NULL`).bind(row.order_id).all()).results;
108
+ if (!lines.length) return;
109
+ const revision = crypto.randomUUID(), syncKey = `subscription-sync:${subscription}`;
110
+ await env.DB.prepare('INSERT INTO meta(key,value) VALUES(?1,?2) ON CONFLICT(key) DO UPDATE SET value=excluded.value').bind(syncKey,revision).run();
111
+ const current = await stripeCall(env,'GET',`/v1/subscriptions/${subscription}`,{
112
+ expand:['items.data.price.product','latest_invoice'],
113
+ });
114
+ if (current.id !== subscription || idOf(current.customer) !== row.customer || current.livemode !== (row.mode === 'live')) throw new Error('Subscription identity mismatch');
115
+ if (current.items?.has_more) current.items = await allPages(env,'/v1/subscription_items',{subscription,expand:['data.price.product']});
116
+ const invoice = current.latest_invoice;
117
+ if (invoice?.lines?.has_more) invoice.lines = await allPages(env,`/v1/invoices/${invoice.id}/lines`);
118
+ // Every mutation is conditional on the read generation. A slower request cannot
119
+ // overwrite a newer reconciliation, including when Stripe events share a second.
120
+ const guard = `EXISTS(SELECT 1 FROM meta WHERE key=?1 AND value=?2)`;
121
+ const writes = [env.DB.prepare(`UPDATE shop_subscriptions SET state=?3 WHERE id=?4 AND ${guard}`)
122
+ .bind(syncKey,revision,current.status,subscription)];
123
+ const active = ['active','trialing','past_due'].includes(current.status) && row.order_status === 'paid';
124
+ const used = new Set();
125
+ for (const item of current.items?.data ?? []) {
126
+ const mappingKey = `subscription-item:${subscription}:${item.id}`;
127
+ const saved = await env.DB.prepare('SELECT value FROM meta WHERE key=?1').bind(mappingKey).first();
128
+ const product = item.price?.product;
129
+ let line = lines.find(l => l.id === saved?.value);
130
+ if (!line) line = lines.find(l => !used.has(l.id) && (product?.metadata?.line === l.id ||
131
+ [product?.metadata?.item,product?.metadata?.homie_item].includes(l.item) ||
132
+ (idOf(product) && JSON.parse(l.snapshot).stripeProduct === idOf(product))));
133
+ if (!line) continue;
134
+ used.add(line.id);
135
+ writes.push(env.DB.prepare(`INSERT INTO meta(key,value) SELECT ?3,?4 WHERE ${guard} ON CONFLICT(key) DO NOTHING`).bind(syncKey,revision,mappingKey,line.id));
136
+ const original = JSON.parse(line.snapshot);
137
+ const originalProduct = product?.metadata?.item === original.id || product?.metadata?.homie_item === original.id || (idOf(product) && original.stripeProduct === idOf(product));
138
+ const plan = originalProduct ? original : shop?.items?.find(p => p.interval && ((idOf(product) && p.stripeProduct === idOf(product)) || [product?.metadata?.item,product?.metadata?.homie_item].includes(p.id)));
139
+ // A paid invoice line identifies both the price and subscription item. A
140
+ // pending invoice/portal upgrade never creates an unpaid entitlement period.
141
+ const paidLine = invoice?.status === 'paid' && invoice.lines?.data?.find(l =>
142
+ (idOf(l.subscription_item ?? l.parent?.subscription_item_details?.subscription_item) === item.id) &&
143
+ idOf(l.price ?? l.pricing?.price_details?.price) === item.price.id &&
144
+ (l.amount ?? 0) >= 0);
145
+ const paidEnd = Number(paidLine?.period?.end) * 1000;
146
+ const previous = await env.DB.prepare('SELECT value FROM meta WHERE key=?1').bind(`subscription-period:${subscription}:${item.id}`).first();
147
+ const prior = previous ? JSON.parse(previous.value) : null;
148
+ const end = Number.isSafeInteger(paidEnd) && paidEnd > 0 ? paidEnd : prior?.price === item.price.id ? prior.end : 0;
149
+ const allowedEnd = active && plan && line.status !== 'refunded' ? Math.min(end, plan.ends ? Date.parse(plan.ends) : Infinity) : 0;
150
+ writes.push(env.DB.prepare(`INSERT INTO meta(key,value) SELECT ?3,?4 WHERE ${guard} ON CONFLICT(key) DO UPDATE SET value=excluded.value`)
151
+ .bind(syncKey,revision,`subscription-period:${subscription}:${item.id}`,JSON.stringify({price:item.price.id,end})));
152
+ // Retain old rows with zero access: removing them would activate the legacy
153
+ // aggregate fallback used by pre-line studios.
154
+ writes.push(env.DB.prepare(`UPDATE shop_entitlement_lines SET ends_at=0 WHERE line_id=?3 AND order_id=?4 AND ${guard}`).bind(syncKey,revision,line.id,row.order_id));
155
+ for (const key of row.player ? plan?.gives ?? [] : []) {
156
+ writes.push(env.DB.prepare(`INSERT INTO entitlements(player,key,item,order_id,starts_at,ends_at,state,used_at)
157
+ SELECT ?3,?4,?5,?6,0,?7,'active',NULL WHERE ${guard}
158
+ ON CONFLICT(player,key,order_id) DO UPDATE SET state=CASE WHEN ?8 THEN 'active' ELSE entitlements.state END`)
159
+ .bind(syncKey,revision,row.player,key,plan.id,row.order_id,allowedEnd,active?1:0));
160
+ writes.push(env.DB.prepare(`INSERT INTO shop_entitlement_lines(player,key,item,order_id,line_id,quantity,starts_at,ends_at,state)
161
+ SELECT ?3,?4,?5,?6,?7,?8,0,?9,'active' WHERE ${guard}
162
+ ON CONFLICT DO UPDATE SET item=excluded.item,quantity=excluded.quantity,ends_at=excluded.ends_at`)
163
+ .bind(syncKey,revision,row.player,key,plan.id,row.order_id,line.id,item.quantity,allowedEnd));
164
+ }
165
+ }
166
+ for (const line of lines) if (!used.has(line.id)) writes.push(env.DB.prepare(`UPDATE shop_entitlement_lines SET ends_at=0 WHERE line_id=?3 AND order_id=?4 AND ${guard}`).bind(syncKey,revision,line.id,row.order_id));
167
+ await env.DB.batch(writes);
168
+ }
169
+
170
+ async function allPages(env, path, params = {}) {
171
+ const data=[];let after;
172
+ for (;;) {
173
+ const page=await stripeCall(env,'GET',path,{...params,limit:100,starting_after:after});
174
+ data.push(...page.data);
175
+ if (!page.has_more) return {data,has_more:false};
176
+ const next=page.data.at(-1)?.id;
177
+ if (!next || next===after) throw new Error('Stripe pagination did not advance');
178
+ after=next;
179
+ }
180
+ }
181
+
182
+ /** Paid signed invoices grant only their stated periods. Failure never extends access. */
183
+ async function invoiceMembership(env,subscription,invoice) {
184
+ if(invoice.status!=='paid' || invoice.lines?.has_more)throw new Error('Complete paid invoice required');
185
+ const row=await env.DB.prepare('SELECT s.*,o.player,o.status order_status FROM shop_subscriptions s JOIN shop_orders o ON o.id=s.order_id WHERE s.id=?1').bind(subscription).first();
186
+ if(!row || row.state==='cancelled' || row.order_status!=='paid')return;
187
+ const lines=(await env.DB.prepare('SELECT * FROM shop_order_lines WHERE order_id=?1').bind(row.order_id).all()).results;
188
+ const writes=[];
189
+ for(const l of lines) {
190
+ const item=JSON.parse(l.snapshot);
191
+ if(!item.interval)continue;
192
+ const paid=invoice.lines.data.find(x=>(x.price?.id??x.pricing?.price_details?.price)===item.stripePrice && x.quantity===l.quantity && x.amount>=0);
193
+ const end=Number(paid?.period?.end)*1000;
194
+ if(!Number.isSafeInteger(end)||end<=0)continue;
195
+ writes.push(env.DB.prepare('UPDATE shop_entitlement_lines SET ends_at=MAX(COALESCE(ends_at,0),?2) WHERE line_id=?1').bind(l.id,end));
196
+ }
197
+ if(writes.length)await env.DB.batch(writes);
198
+ }