// Pure helpers shared between onboard.js (CLI + createSignup), workflow.js // (the Restate OnboardingWorkflow), and server.js (the self-serve form). No // Restate import here and no side effects at module load beyond resolving // __dirname — this file has to stay safe to import from a plain script. import { readFile } from 'node:fs/promises'; import { fileURLToPath } from 'node:url'; import path from 'node:path'; import { generateNKeysBetween } from 'fractional-indexing'; const __dirname = path.dirname(fileURLToPath(import.meta.url)); // CalVer (vYYYY.MM.N), matching this repo's convention (see // scripts/chatto-realtime-demo/saito-scaffold-agent.mjs and `git tag -n`) — // bump on any change to onboard.js/workflow.js/server.js/shared.js worth // telling apart in a `systemctl status`/journal line, and tag the commit // to match. const SCRIPT_VERSION = 'v2026.08.4'; function substitute(value, vars) { if (typeof value === 'string') { return value.replace(/\{\{(\w+)\}\}/g, (_, name) => vars[name] ?? ''); } if (Array.isArray(value)) { return value.map((v) => substitute(v, vars)); } if (value && typeof value === 'object') { return Object.fromEntries(Object.entries(value).map(([k, v]) => [k, substitute(v, vars)])); } return value; } async function xrpc(pdsUrl, nsid, { token, body } = {}) { const res = await fetch(`${pdsUrl}/xrpc/${nsid}`, { method: 'POST', headers: { 'Content-Type': 'application/json', ...(token ? { Authorization: `Bearer ${token}` } : {}) }, body: body ? JSON.stringify(body) : undefined }); const json = await res.json().catch(() => ({})); if (!res.ok) { throw new Error(`${nsid} failed (${res.status}): ${JSON.stringify(json)}`); } return json; } function resolveCardsToCreate(theme, vars) { return theme.cards.filter((card) => { if (!card.requiresInput) return true; if (vars[card.requiresInput]) return true; if (card.defaultValue !== undefined) return true; if (card.optional) { console.log(`Skipping optional "${card.cardType}" card — no ${card.requiresInput} given.`); return false; } throw new Error(`Missing --${card.requiresInput} (required, card is not optional)`); }); } async function loadTheme(name) { return JSON.parse(await readFile(path.join(__dirname, 'themes', `${name}.json`), 'utf8')); } // One journey for every member, regardless of their signup theme — no // filtering (deliberately simplified 2026-08-04; musician/haiku-poet/general // only still matters for the blento starter profile itself, not this list). async function loadJourney() { const { steps } = JSON.parse(await readFile(path.join(__dirname, 'journey.json'), 'utf8')); return steps; } // tranquil-pds's own internal naming, not a documented ATProto error (createRecord's // official lexicon only defines InvalidSwap) — checked its actual create_record // handler (crates/tranquil-api/src/repo/record/write.rs): a duplicate rkey returns // a generic ApiError::InvalidRequest("Record already exists at {key}"), not a // dedicated error code. Matched on message text for the same reason isAccountNotVerified // does — there's nothing more standard to match on. function isRecordAlreadyExists(err) { return typeof err.message === 'string' && err.message.includes('Record already exists'); } function isAccountNotVerified(err) { return typeof err.message === 'string' && err.message.includes('AccountNotVerified'); } // Pure record builders — no network, no vars/{{}} substitution (callers that // need that, like writeBlentoProfile below, do it before calling). Extracted // so any caller with its own already-concrete card list (e.g. a one-off // showcase page, not a signup theme) can produce the exact same record shapes // writeBlentoProfile does, instead of re-deriving this from scratch — see // scripts/publish-community-showcase.mjs. function buildBlentoContainerRecord({ rank, page }) { return { kind: 'container', parent: null, rank, page, content: { $type: 'app.blento.defs#container', containerType: 'grid' }, version: 1 }; } function buildBlentoLeafRecord({ parent, rank, page, cardType, cardData, layout }) { return { kind: 'leaf', parent, rank, page, content: { ...cardData, $type: 'app.blento.defs#card', cardType }, layout: { $type: 'app.blento.defs#gridCell', ...layout }, version: 1 }; } // Writes one container plus N already-concrete leaf cards under it. "Already- // concrete" means every card's cardData/layout is final — no {{var}} tokens // left unresolved. Sibling order via real fractional-indexing (see // themes/SCHEMA.md), same as the rest of this file. async function writeBlentoCards({ did, callXrpc, page, cards }) { const [containerRank] = generateNKeysBetween(null, null, 1); const containerRes = await callXrpc('com.atproto.repo.createRecord', { repo: did, collection: 'app.blento.node', record: buildBlentoContainerRecord({ rank: containerRank, page }) }); const containerRkey = containerRes.uri.split('/').pop(); const ranks = generateNKeysBetween(null, null, cards.length); for (const [i, card] of cards.entries()) { await callXrpc('com.atproto.repo.createRecord', { repo: did, collection: 'app.blento.node', record: buildBlentoLeafRecord({ parent: containerRkey, rank: ranks[i], page, cardType: card.cardType, cardData: card.cardData, layout: { x: card.x, y: card.y, w: card.w, h: card.h, mobileX: card.mobileX, mobileY: card.mobileY, mobileW: card.mobileW, mobileH: card.mobileH } }) }); } return { containerRkey }; } // `callXrpc(nsid, body)` does the actual authenticated POST and returns parsed // JSON, throwing on failure — deliberately generic so this works with either a // plain bearer token (`xrpc(pdsUrl, nsid, {token, body})`) or an OAuth session's // DPoP-signed fetchHandler (see oauth.js), which isn't a bearer token at all and // can't be represented as one. // // Idempotent: if the page record already exists (a repeat call after a prior // success, or after a member re-clicks "finish profile"), treats that as already // provisioned rather than a hard error — see isRecordAlreadyExists above. async function writeBlentoProfile({ did, theme, vars, callXrpc, publicUrl }) { // journeyUrl is every theme's own {{journeyUrl}} card, not a signup-collected // variable — computed here rather than by the caller, so every caller gets // the card automatically instead of each one having to remember to inject // it. Plain ?handle= route (server.js), not a signed token — keeps working // forever, no token to expire or go stale against a rotated/redeployed // .oauth-state-secret (that one's oauth.js's own journeyUrl(), a different // thing: a one-time signed redirect right after finish-profile). vars = { ...vars, journeyUrl: `${publicUrl}/journey?handle=${encodeURIComponent(vars.handle)}` }; const cardsToCreate = resolveCardsToCreate(theme, vars); const page = 'blento.self'; const pub = substitute(theme.publication, vars); const pageRecord = { $type: 'app.blento.page', version: 1, ...(pub.name ? { name: pub.name } : {}), ...(pub.description ? { description: pub.description } : {}), ...(pub.preferences?.accentColor || pub.preferences?.baseColor ? { style: { colors: { ...(pub.preferences.accentColor ? { accent: pub.preferences.accentColor } : {}), ...(pub.preferences.baseColor ? { base: pub.preferences.baseColor } : {}) } } } : {}) }; try { await callXrpc('com.atproto.repo.createRecord', { repo: did, collection: 'app.blento.page', rkey: page, record: pageRecord }); } catch (err) { if (isRecordAlreadyExists(err)) return { alreadyProvisioned: true, profileUrl: pub.url }; throw err; } const concreteCards = cardsToCreate.map((card) => { const cardVars = card.requiresInput && !vars[card.requiresInput] && card.defaultValue !== undefined ? { ...vars, [card.requiresInput]: card.defaultValue } : vars; return { ...card, cardData: substitute(card.cardData, cardVars) }; }); await writeBlentoCards({ did, callXrpc, page, cards: concreteCards }); return { alreadyProvisioned: false, profileUrl: pub.url }; } export { xrpc, substitute, resolveCardsToCreate, loadTheme, loadJourney, writeBlentoProfile, buildBlentoContainerRecord, buildBlentoLeafRecord, writeBlentoCards, isAccountNotVerified, isRecordAlreadyExists, SCRIPT_VERSION, __dirname as onboardDir };