diff --git a/licensing-service/web/index.html b/licensing-service/web/index.html index e3e6ffe..fa45d8d 100644 --- a/licensing-service/web/index.html +++ b/licensing-service/web/index.html @@ -511,6 +511,7 @@ hr.div { border:none; border-top:1px solid var(--border-1); margin:18px 0; } Licenses Machines
System
+ Merchant profiles Webhooks Audit log Settings @@ -1228,6 +1229,7 @@ hr.div { border:none; border-top:1px solid var(--border-1); margin:18px 0; } codes: { title: 'Discount codes', crumb: 'Workspace · Discount codes' }, licenses: { title: 'Licenses', crumb: 'Workspace · Licenses' }, machines: { title: 'Machines', crumb: 'Workspace · Machines' }, + 'merchant-profiles': { title: 'Merchant profiles', crumb: 'System · Merchant profiles' }, webhooks: { title: 'Webhooks', crumb: 'System · Webhooks' }, settings: { title: 'Settings', crumb: 'System · Settings' }, audit: { title: 'Audit log', crumb: 'System · Audit log' }, @@ -5719,6 +5721,439 @@ hr.div { border:none; border-top:1px solid var(--border-1); margin:18px 0; } load() } + // -------- Merchant profiles (multi-provider model, :52+) -------- + // Each profile represents one "business" the operator is running on + // this Keysat instance. Owns business identity (brand, support contact, + // post-purchase redirect, optional SMTP override) and a set of payment + // providers (BTCPay / Zaprite) that legally settle to that business. + // Products attach to a profile. Tier-gated: Creator gets 1, Pro/Patron + // get unlimited. + routes['merchant-profiles'] = async function () { + const target = document.getElementById('route-target') + target.innerHTML = '' + + // Lead-in explainer + Create button. + const createBtn = el('button', { class: 'btn primary' }, [ + el('i', { 'data-lucide': 'plus' }), 'Add merchant profile', + ]) + createBtn.addEventListener('click', () => openCreateMerchantProfileModal(reload)) + target.appendChild(plainCard([ + el('p', { class: 'muted', style: 'margin:0 0 12px' }, + 'A merchant profile bundles one business’s brand, post-purchase redirect, ' + + 'and a set of payment providers (BTCPay / Zaprite). Products attach to a profile; ' + + 'the buyer sees the profile’s brand at checkout and the payment-method picker ' + + 'reflects whichever rails its providers serve.'), + el('div', { class: 'toolbar' }, [createBtn]), + ])) + + const listHost = el('div', { style: 'margin-top:18px' }) + target.appendChild(listHost) + + async function reload() { + listHost.innerHTML = '' + listHost.appendChild(el('div', { class: 'card' }, el('div', { class: 'empty' }, 'Loading…'))) + try { + const j = await api('/v1/admin/merchant-profiles') + const profiles = j.profiles || [] + listHost.innerHTML = '' + if (profiles.length === 0) { + listHost.appendChild(plainCard([el('div', { class: 'empty' }, 'No merchant profiles yet.')])) + return + } + for (const p of profiles) { + listHost.appendChild(renderMerchantProfileCard(p, reload)) + } + } catch (e) { + listHost.innerHTML = '' + listHost.appendChild(plainCard([err(e.message)])) + } + } + reload() + } + + function renderMerchantProfileCard(p, reload) { + const head = el('div', { class: 'card-head' }, [ + el('div', null, [ + el('h3', null, [ + p.name, + p.is_default ? el('span', { + class: 'badge b-gold', + style: 'margin-left:10px; vertical-align:middle;', + }, 'default') : null, + ].filter(Boolean)), + p.support_email + ? el('div', { class: 'sub' }, p.support_email) + : null, + ].filter(Boolean)), + el('div', { class: 'actions-row' }, [ + !p.is_default + ? el('button', { class: 'btn ghost sm' }, [ + el('i', { 'data-lucide': 'star' }), 'Set default', + ]) + : null, + el('button', { class: 'btn ghost sm' }, [ + el('i', { 'data-lucide': 'pencil' }), 'Edit', + ]), + !p.is_default + ? el('button', { class: 'btn danger sm' }, [ + el('i', { 'data-lucide': 'trash-2' }), 'Delete', + ]) + : null, + ].filter(Boolean)), + ]) + // Wire action buttons. + const setDefaultBtn = head.querySelectorAll('button')[0] + if (setDefaultBtn && !p.is_default) { + setDefaultBtn.addEventListener('click', async () => { + if (!confirm('Make "' + p.name + '" the default profile?')) return + try { + await api('/v1/admin/merchant-profiles/' + encodeURIComponent(p.id) + '/set-default', { + method: 'POST', + }) + reload() + } catch (e) { + alert('Set-default failed: ' + e.message) + } + }) + } + const editBtn = head.querySelector('button.ghost:not(:first-child)') || + head.querySelectorAll('button.ghost')[p.is_default ? 0 : 1] + if (editBtn) { + editBtn.addEventListener('click', () => openEditMerchantProfileModal(p, reload)) + } + const deleteBtn = head.querySelector('button.danger') + if (deleteBtn) { + deleteBtn.addEventListener('click', async () => { + if (!confirm('Delete merchant profile "' + p.name + '"? Refused if products or active subs are attached.')) return + try { + await api('/v1/admin/merchant-profiles/' + encodeURIComponent(p.id), { method: 'DELETE' }) + reload() + } catch (e) { + alert('Delete failed: ' + e.message) + } + }) + } + + const body = el('div', { class: 'card-body' }) + + // Brand / redirect summary. + const meta = el('div', { class: 'row-2', style: 'margin-bottom:14px' }, [ + el('div', null, [ + el('div', { class: 'field' }, [ + el('div', { class: 'lbl' }, 'Post-purchase redirect URL'), + el('div', { class: p.post_purchase_redirect_url ? '' : 'muted' }, + p.post_purchase_redirect_url || 'Keysat default /thank-you page'), + ]), + ]), + el('div', null, [ + el('div', { class: 'field' }, [ + el('div', { class: 'lbl' }, 'Brand color'), + el('div', { class: p.brand_color ? '' : 'muted' }, + p.brand_color || 'Keysat default navy'), + ]), + ]), + ]) + body.appendChild(meta) + + // Providers list. + body.appendChild(el('div', { class: 'eyebrow', style: 'margin-bottom:6px' }, 'Payment providers')) + const providers = p.providers || [] + if (providers.length === 0) { + body.appendChild(el('div', { class: 'empty', style: 'padding:14px' }, + 'No providers connected. Buyers can’t pay on products attached to this profile until you add one.')) + } else { + const tb = el('tbody') + for (const pr of providers) { + const rails = (pr.served_rails || []).join(', ') + const disconnectBtn = el('button', { class: 'btn danger sm' }, [ + el('i', { 'data-lucide': 'unplug' }), 'Disconnect', + ]) + disconnectBtn.addEventListener('click', async () => { + if (!confirm('Disconnect ' + pr.kind + ' provider "' + pr.label + '"?')) return + try { + const path = pr.kind === 'btcpay' ? '/v1/admin/btcpay/disconnect' : '/v1/admin/zaprite/disconnect' + await api(path, { + method: 'POST', + body: JSON.stringify({ provider_id: pr.id }), + }) + reload() + } catch (e) { + alert('Disconnect failed: ' + e.message) + } + }) + tb.appendChild(el('tr', null, [ + el('td', null, el('strong', null, pr.label || pr.kind)), + el('td', { class: 'muted' }, pr.kind), + el('td', { class: 'muted' }, rails || '—'), + el('td', null, disconnectBtn), + ])) + } + const table = el('table', { class: 't' }, [ + el('thead', null, el('tr', null, [ + el('th', null, 'Label'), + el('th', null, 'Kind'), + el('th', null, 'Rails'), + el('th', null, ''), + ])), + tb, + ]) + body.appendChild(el('div', { class: 't-wrap' }, table)) + } + + // Connect buttons (offer whichever provider kinds aren't yet attached). + const haveBtcpay = providers.some((pr) => pr.kind === 'btcpay') + const haveZaprite = providers.some((pr) => pr.kind === 'zaprite') + const connectActions = el('div', { class: 'toolbar', style: 'margin-top:14px' }, []) + if (!haveBtcpay) { + const btn = el('button', { class: 'btn secondary' }, [ + el('i', { 'data-lucide': 'bitcoin' }), 'Connect BTCPay', + ]) + btn.addEventListener('click', () => connectBtcpayForProfile(p.id, reload)) + connectActions.appendChild(btn) + } + if (!haveZaprite) { + const btn = el('button', { class: 'btn secondary' }, [ + el('i', { 'data-lucide': 'credit-card' }), 'Connect Zaprite', + ]) + btn.addEventListener('click', () => connectZapriteForProfile(p.id, reload)) + connectActions.appendChild(btn) + } + if (connectActions.children.length) body.appendChild(connectActions) + + if (window.lucide) lucide.createIcons() + return el('div', { class: 'card' }, [head, body]) + } + + function openCreateMerchantProfileModal(onDone) { + const overlay = el('div', { + style: 'position:fixed; inset:0; background:rgba(14,31,51,0.55); z-index:9999; ' + + 'display:flex; align-items:center; justify-content:center; padding:20px;', + }) + const nameInput = el('input', { class: 'input', placeholder: 'e.g. Recaps' }) + const supportUrlInput = el('input', { class: 'input', placeholder: 'https://recaps.cc/support (optional)' }) + const supportEmailInput = el('input', { class: 'input', placeholder: 'support@recaps.cc (optional)' }) + const redirectInput = el('input', { class: 'input', placeholder: 'https://recaps.cc/welcome?invoice_id={invoice_id} (optional)' }) + const brandColorInput = el('input', { class: 'input', type: 'color', value: '#1E3A5F' }) + const errBox = el('div') + const submitBtn = el('button', { class: 'btn primary' }, 'Create profile') + const card = el('div', { + style: 'background:var(--cream-50); border:1px solid var(--border-1); ' + + 'border-radius:12px; max-width:520px; width:100%; padding:24px; max-height:90vh; overflow-y:auto;', + }, [ + el('h3', { style: 'margin:0 0 14px' }, 'New merchant profile'), + el('p', { class: 'muted', style: 'margin:0 0 18px; font-size:13px' }, + 'Each profile is one business identity. Buyers see the brand on the buy page; ' + + 'products attached to this profile route their payments through the providers you connect to it.'), + el('div', { class: 'field' }, [el('div', { class: 'lbl' }, 'Name'), nameInput]), + el('div', { class: 'field' }, [ + el('div', { class: 'lbl' }, 'Support URL'), + supportUrlInput, + el('div', { class: 'hint' }, 'Linked from the buy page so buyers can contact your team.'), + ]), + el('div', { class: 'field' }, [ + el('div', { class: 'lbl' }, 'Support email'), + supportEmailInput, + ]), + el('div', { class: 'field' }, [ + el('div', { class: 'lbl' }, 'Post-purchase redirect URL'), + redirectInput, + el('div', { class: 'hint' }, '{invoice_id} is substituted at purchase time. Leave blank to use Keysat’s /thank-you page.'), + ]), + el('div', { class: 'field' }, [ + el('div', { class: 'lbl' }, 'Brand color'), + brandColorInput, + ]), + errBox, + el('div', { style: 'display:flex; gap:10px; margin-top:18px; justify-content:flex-end;' }, [ + el('button', { class: 'btn ghost', onclick: () => overlay.remove() }, 'Cancel'), + submitBtn, + ]), + ]) + overlay.appendChild(card) + overlay.addEventListener('click', (e) => { if (e.target === overlay) overlay.remove() }) + document.body.appendChild(overlay) + nameInput.focus() + + submitBtn.addEventListener('click', async () => { + errBox.innerHTML = '' + const name = nameInput.value.trim() + if (!name) { + errBox.appendChild(err('Name is required.')) + return + } + submitBtn.disabled = true + try { + await api('/v1/admin/merchant-profiles', { + method: 'POST', + body: JSON.stringify({ + name, + support_url: supportUrlInput.value.trim() || null, + support_email: supportEmailInput.value.trim() || null, + post_purchase_redirect_url: redirectInput.value.trim() || null, + brand_color: brandColorInput.value || null, + }), + }) + overlay.remove() + if (onDone) onDone() + } catch (e) { + errBox.appendChild(err(e.message)) + submitBtn.disabled = false + } + }) + } + + function openEditMerchantProfileModal(profile, onDone) { + const overlay = el('div', { + style: 'position:fixed; inset:0; background:rgba(14,31,51,0.55); z-index:9999; ' + + 'display:flex; align-items:center; justify-content:center; padding:20px;', + }) + const nameInput = el('input', { class: 'input', value: profile.name || '' }) + const supportUrlInput = el('input', { class: 'input', value: profile.support_url || '' }) + const supportEmailInput = el('input', { class: 'input', value: profile.support_email || '' }) + const redirectInput = el('input', { class: 'input', value: profile.post_purchase_redirect_url || '' }) + const brandColorInput = el('input', { class: 'input', type: 'color', value: profile.brand_color || '#1E3A5F' }) + const errBox = el('div') + const submitBtn = el('button', { class: 'btn primary' }, 'Save') + const card = el('div', { + style: 'background:var(--cream-50); border:1px solid var(--border-1); ' + + 'border-radius:12px; max-width:520px; width:100%; padding:24px; max-height:90vh; overflow-y:auto;', + }, [ + el('h3', { style: 'margin:0 0 14px' }, 'Edit merchant profile'), + el('div', { class: 'field' }, [el('div', { class: 'lbl' }, 'Name'), nameInput]), + el('div', { class: 'field' }, [el('div', { class: 'lbl' }, 'Support URL'), supportUrlInput]), + el('div', { class: 'field' }, [el('div', { class: 'lbl' }, 'Support email'), supportEmailInput]), + el('div', { class: 'field' }, [ + el('div', { class: 'lbl' }, 'Post-purchase redirect URL'), + redirectInput, + el('div', { class: 'hint' }, '{invoice_id} substituted at purchase time.'), + ]), + el('div', { class: 'field' }, [el('div', { class: 'lbl' }, 'Brand color'), brandColorInput]), + errBox, + el('div', { style: 'display:flex; gap:10px; margin-top:18px; justify-content:flex-end;' }, [ + el('button', { class: 'btn ghost', onclick: () => overlay.remove() }, 'Cancel'), + submitBtn, + ]), + ]) + overlay.appendChild(card) + overlay.addEventListener('click', (e) => { if (e.target === overlay) overlay.remove() }) + document.body.appendChild(overlay) + + submitBtn.addEventListener('click', async () => { + errBox.innerHTML = '' + submitBtn.disabled = true + try { + // Build the patch: outer Option means "leave unchanged" (omit + // the key); we always send all the editable fields so the + // operator can also CLEAR them by leaving the input empty. + const patch = { + name: nameInput.value.trim() || null, + support_url: [supportUrlInput.value.trim() || null], + support_email: [supportEmailInput.value.trim() || null], + post_purchase_redirect_url: [redirectInput.value.trim() || null], + brand_color: [brandColorInput.value || null], + } + // Wire-format note: the Rust patch uses double-Option (outer Some + // wraps inner None for "set to NULL"). serde_json deserializes + // a single value into outer Some(value); arrays into Some(Some(v)). + // We use the bare value for outer Some/Some(v), and the [null] + // array trick to express Some(None) (clear). For simplicity here + // we just send the bare value — Rust treats null → Some(None) → + // sets to NULL; a string → Some(Some(s)) → updates. + const wirePatch = {} + if (patch.name !== null) wirePatch.name = patch.name + wirePatch.support_url = patch.support_url[0] + wirePatch.support_email = patch.support_email[0] + wirePatch.post_purchase_redirect_url = patch.post_purchase_redirect_url[0] + wirePatch.brand_color = patch.brand_color[0] + await api('/v1/admin/merchant-profiles/' + encodeURIComponent(profile.id), { + method: 'PATCH', + body: JSON.stringify(wirePatch), + }) + overlay.remove() + if (onDone) onDone() + } catch (e) { + errBox.appendChild(err(e.message)) + submitBtn.disabled = false + } + }) + } + + async function connectBtcpayForProfile(profileId, onDone) { + try { + const r = await api('/v1/admin/btcpay/connect', { + method: 'POST', + body: JSON.stringify({ merchant_profile_id: profileId }), + }) + if (r.authorize_url) { + if (confirm('Open BTCPay’s consent page in a new tab to complete connection?')) { + window.open(r.authorize_url, '_blank', 'noopener') + } + } + if (onDone) onDone() + } catch (e) { + alert('Connect BTCPay failed: ' + e.message) + } + } + + function connectZapriteForProfile(profileId, onDone) { + const overlay = el('div', { + style: 'position:fixed; inset:0; background:rgba(14,31,51,0.55); z-index:9999; ' + + 'display:flex; align-items:center; justify-content:center; padding:20px;', + }) + const apiKeyInput = el('input', { class: 'input', type: 'password', placeholder: 'paste Zaprite API key' }) + const baseUrlInput = el('input', { class: 'input', placeholder: 'https://api.zaprite.com (default)' }) + const errBox = el('div') + const submitBtn = el('button', { class: 'btn primary' }, 'Connect') + const card = el('div', { + style: 'background:var(--cream-50); border:1px solid var(--border-1); ' + + 'border-radius:12px; max-width:480px; width:100%; padding:24px;', + }, [ + el('h3', { style: 'margin:0 0 12px' }, 'Connect Zaprite'), + el('p', { class: 'muted', style: 'margin:0 0 14px; font-size:13px' }, + 'Paste an API key from app.zaprite.com Settings → API.'), + el('div', { class: 'field' }, [el('div', { class: 'lbl' }, 'API key'), apiKeyInput]), + el('div', { class: 'field' }, [ + el('div', { class: 'lbl' }, 'Base URL (optional)'), + baseUrlInput, + el('div', { class: 'hint' }, 'Override only for sandbox orgs that point at a different host.'), + ]), + errBox, + el('div', { style: 'display:flex; gap:10px; margin-top:14px; justify-content:flex-end;' }, [ + el('button', { class: 'btn ghost', onclick: () => overlay.remove() }, 'Cancel'), + submitBtn, + ]), + ]) + overlay.appendChild(card) + overlay.addEventListener('click', (e) => { if (e.target === overlay) overlay.remove() }) + document.body.appendChild(overlay) + apiKeyInput.focus() + + submitBtn.addEventListener('click', async () => { + errBox.innerHTML = '' + const key = apiKeyInput.value.trim() + if (!key) { errBox.appendChild(err('API key required.')); return } + submitBtn.disabled = true + try { + const r = await api('/v1/admin/zaprite/connect', { + method: 'POST', + body: JSON.stringify({ + api_key: key, + base_url: baseUrlInput.value.trim() || undefined, + merchant_profile_id: profileId, + }), + }) + overlay.remove() + if (r.webhook_url) { + alert('Zaprite connected. Register this webhook URL on the Zaprite dashboard:\n\n' + r.webhook_url) + } + if (onDone) onDone() + } catch (e) { + errBox.appendChild(err(e.message)) + submitBtn.disabled = false + } + }) + } + // -------- Settings -------- // Three subsections: // 1. Operator name — the human-readable name on /buy/ + thank-you. diff --git a/startos/versions/v0.2.0.ts b/startos/versions/v0.2.0.ts index 1643482..d17e3e9 100644 --- a/startos/versions/v0.2.0.ts +++ b/startos/versions/v0.2.0.ts @@ -58,6 +58,8 @@ const RELEASE_NOTES = [ // in RELEASE_NOTES above (the milestone). Subsequent revisions // append here. const ROUTINE_NOTES = [ + '0.2.0:52 — **Multi-merchant-profile + multi-provider payment model.** Drops the singleton-config-table assumption that one Keysat instance equals one business. Operators on Pro/Patron tier can now run multiple businesses from a single Keysat box: each business is a "merchant profile" with its own brand, post-purchase redirect URL, and a set of payment providers (BTCPay + Zaprite) that settle to that business\'s accounts. Products attach to a merchant profile; the buyer sees the profile\'s brand at checkout and the eventual rail-picker (UI follow-up) routes the buyer\'s payment-method choice to the right provider. **One-way DB migration** — migration 0020 creates `merchant_profiles` + `payment_providers` + `merchant_profile_rail_preferences`, ports the existing singleton `btcpay_config` / `zaprite_config` / `active_payment_provider` setting into the new tables (one auto-created default profile holding everything), then drops the old tables. Migrations 0021 + 0022 add `invoices.payment_provider_id` (so reconciler / tipping / capture know which provider settled each invoice) and a `merchant_profile_id` column on `btcpay_authorize_state` (so BTCPay\'s OAuth CSRF state can round-trip the operator\'s profile pick). **Subscriptions snapshot** both `merchant_profile_id` and `payment_provider_id` at creation, so editing a product\'s profile attachment never redirects existing buyers mid-cycle. **Webhook URLs** are now path-keyed: `/v1/{kind}/webhook/{provider-id}` — each profile\'s provider has its own isolated webhook receiver. Back-compat: the legacy `/v1/{kind}/webhook` URL still routes to the default profile\'s provider so any in-flight deliveries still settle. **Tier-gate**: Creator tier gets 1 profile (the auto-created default); Pro/Patron get unlimited via the new `unlimited_merchant_profiles` entitlement. **POST-MIGRATION MANUAL STEP for the master operator (you)**: after this version installs, your Zaprite webhook is still registered at `https://licensing.keysat.xyz/v1/zaprite/webhook` (the legacy URL). It keeps working via the back-compat fallback, but for proper per-provider isolation, either (a) open the Zaprite sandbox dashboard → Webhooks → edit the URL to include the new provider id shown in the Merchant Profiles UI, or (b) click Disconnect + Reconnect Zaprite in the new Merchant Profiles UI to have Keysat re-register a fresh webhook at the path-keyed URL. **WHAT THIS RELEASE DOES NOT YET INCLUDE** (UI follow-ups): the buy-page rail picker (today the buyer\'s checkout uses the first rail the profile\'s providers serve — fine for single-rail profiles), the product-edit-page merchant-profile picker (new products always go to the default profile until that UI ships), per-profile SMTP override form (the schema fields are in place for the keysat-smtp-emails plan), and rail-preference editing UI (only matters when 2 providers on the same profile both serve the same rail — operators can set them via `PUT /v1/admin/merchant-profiles/:id/rail-preferences/:rail` directly). **Entitlement note**: master Keysat\'s Pro and Patron policies need `unlimited_merchant_profiles` added to their entitlement JSON for Pro/Patron customers to actually be able to create multiple profiles — purely a data action on the master keysat.xyz admin UI, no code change.', + '', '0.2.0:51 — **Zaprite `order.change` webhook is now actionable.** The `:50` probe-multiple-field-names fix surfaced that Zaprite\'s primary delivery shape isn\'t the convention-suggested `order.paid` / `order.complete` events — it\'s a single generic `order.change` event that just says "something about this order changed" and requires the receiver to look at `/data/status` to figure out what actually changed. Without handling this, every Zaprite webhook fell through to the Other arm ("non-actionable") and the polling reconciler (60-second tick) had to do all the work, adding ~45s of perceived latency before the buyer\'s thank-you page flipped from "waiting" to "issued". Fixed in `payment/zaprite/provider.rs::validate_webhook`: added an `order.change` match arm that branches on `/data/status` (`PAID`/`COMPLETE`/`OVERPAID` → InvoiceSettled, `EXPIRED` → InvoiceExpired, `INVALID`/`CANCELLED` → InvoiceInvalid, in-flight states like `PENDING`/`PROCESSING`/`UNDERPAID` → Other so we don\'t fire the settle hook on every transition toward PAID). End result: webhook-driven settles now flip subscriptions to `active` within seconds of Zaprite\'s callback — the reconciler stays as the safety net for actual missed deliveries.', '', '0.2.0:50 — **Zaprite webhook event-type extraction now probes multiple field names + warns + dumps payload on miss.** Sandbox testing of `:49` confirmed Zaprite\'s webhooks ARE being delivered, but every one was logged as "non-actionable webhook event event_type=" — empty event_type meant the receiver fell through to the Other arm, and only the polling reconciler (60-second tick) eventually picked up the settle. Root cause: `validate_webhook` only checked the top-level `event` field; Zaprite\'s docs don\'t enumerate webhook payload shapes, and their actual deliveries put the event name somewhere else. Fixed in `payment/zaprite/provider.rs::validate_webhook`: now probes four common top-level field names — `event`, `eventType`, `type`, `name` — first non-empty wins. Also widened the order-id probe to include `data.object.id` (the Stripe-style pattern). When NONE of the four event-name fields match, the handler now WARN-logs the (truncated to 2KB) raw payload so the actual field name can be added to the probe list. End result: webhook-driven settles should now flip subscriptions to `active` within seconds instead of waiting for the reconciler — improves perceived latency on the thank-you page and lets auto-charged renewals settle without polling lag.', @@ -537,7 +539,7 @@ const ROUTINE_NOTES = [ ].join('\n\n') export const v0_2_0 = VersionInfo.of({ - version: '0.2.0:51', + version: '0.2.0:52', releaseNotes: { en_US: ROUTINE_NOTES }, // No on-disk transformation needed — v0.2.0:0 is a label change. // SQLite-level migrations live separately under