| 1 | // First-run setup — Eleven walks a fresh instance's admin through naming |
| 2 | // themselves and the space, contact email, server photo, member invites, and — |
| 3 | // most importantly — backing up their key. Chat-first: the whole thing is a |
| 4 | // conversation (chips + typed answers), not a console. See |
| 5 | // docs/conversational-onboarding.md. |
| 6 | // |
| 7 | // The conversation itself is DATA: setup.flow.js, interpreted by the flow |
| 8 | // engine (flow.js — docs/conversation-scripts.md). This file is the app shell |
| 9 | // + the host binding: the bridge-verb table (the closed set of side effects |
| 10 | // the flow may name) and the io wiring the engine to eleven.js's feel (pacing, |
| 11 | // persistence) and shell.js's setup bridge (setSetupBridge). Like /tutorial it |
| 12 | // must work with no AI and no bot at all (a fresh instance has neither). |
| 13 | import { defineApp, namespace, setupBridge, awaitAnswer, cancelAnswer, hasAnswerWaiter, appStateFor, emitAppEvent, spaceInfo, openAppThread } from "../apps.js"; |
| 14 | import { makeSay, makeLiveSay, sayEvents, sayBubble } from "../eleven.js"; |
| 15 | import { runFlow } from "../flow.js"; |
| 16 | import SETUP_FLOW from "../setup.flow.js"; |
| 17 | import { startTour } from "../tutorial.js"; |
| 18 | |
| 19 | // One declaration of the app's prefix — N.ev("step") → the "setup.step" wire type. |
| 20 | const N = namespace("setup"); |
| 21 | |
| 22 | export const app = defineApp({ |
| 23 | name: "setup", |
| 24 | title: "Eleven", |
| 25 | glyph: "🕚", |
| 26 | avatar: "/static/icon-192.png", // Eleven wears the app icon, not a clock glyph |
| 27 | // The sidebar badges this thread BOT, not APP — it presents as a conversation |
| 28 | // with Eleven, and the app machinery underneath is an implementation detail. |
| 29 | senderKind: "bot", |
| 30 | chatIntro: "This is the start of your chat with Eleven.", |
| 31 | // Chat-only: the conversation IS the thread — no Interface view, no toggle. |
| 32 | // (The old setup progress card was superseded by the Admin panel.) |
| 33 | chatOnly: true, |
| 34 | // System scope: never offered in the Apps menu — the host mounts it for the |
| 35 | // admin on first run (maybeStartSetup in shell.js), and only for them. |
| 36 | places: [], // host-mounted conversation |
| 37 | initialState: () => ({ steps: {}, said: {} }), |
| 38 | events: { |
| 39 | // A completed step. `label` is the human line the chat view shows; `value` |
| 40 | // (optional) remembers the answer, for steps that resume on it. |
| 41 | [N.ev("step")]: (s, e) => (e.step ? { ...s, steps: { ...s.steps, [e.step]: { done: true, value: e.value || "" } } } : s), |
| 42 | // Eleven's own lines persist too (via makeSay), so the conversation is |
| 43 | // real history after a reload, not just the ✓ action lines. |
| 44 | ...sayEvents(N.ev("say")), |
| 45 | }, |
| 46 | chatMessage(e) { |
| 47 | const line = sayBubble(e, N.ev("say")); |
| 48 | if (line) return line; |
| 49 | if (e.type !== N.ev("step") || !e.label) return null; |
| 50 | return { text: e.label, action: true }; |
| 51 | }, |
| 52 | // A step-record from ANOTHER client (a second device answering this same |
| 53 | // conversation) landing while OUR engine sits parked on a question — the |
| 54 | // 2026-07-19 stale-composer bug: the transcript streamed on live but the |
| 55 | // armed "Your name:" chip never moved. Re-derive: tear the parked run down |
| 56 | // and boot the flow again — every say/record dedups against the folded |
| 57 | // log, so the restart lands parked on the first genuinely-unanswered step. |
| 58 | // Own records are filtered by the ownRecords ledger (their socket echo is |
| 59 | // not news); env.reentry suppresses the "Welcome back" re-greeting. |
| 60 | folded(threadId, event) { |
| 61 | if (event.type !== N.ev("step") || !event.step) return; |
| 62 | const env = runningEnvs.get(threadId); |
| 63 | if (!env || !hasAnswerWaiter(threadId)) return; // not parked — mid-run or ended |
| 64 | const own = ownRecords.get(threadId); |
| 65 | if (own && own.has(event.step)) return; |
| 66 | cancelAnswer(threadId); |
| 67 | running.delete(threadId); |
| 68 | clearRunTimers(threadId); // the old run's settle/probe timers die with it |
| 69 | env.reentry = true; |
| 70 | startSetup(env); |
| 71 | }, |
| 72 | }); |
| 73 | |
| 74 | // ---- the host binding ------------------------------------------------------- |
| 75 | |
| 76 | // One live run per thread (a reload starts a fresh one; the engine's step |
| 77 | // guards and say-dedupe handle everything within). |
| 78 | const running = new Set(); |
| 79 | // The live run's env, kept so the folded hook can reboot the flow in place — |
| 80 | // and the steps THIS client recorded, so its own echoes never trigger one. |
| 81 | const runningEnvs = new Map(); // threadId → env |
| 82 | const ownRecords = new Map(); // threadId → Set(step) |
| 83 | const say = makeSay("setup", N.ev("say")); |
| 84 | |
| 85 | // ---- run-scoped timers (#1377) ---------------------------------------------- |
| 86 | // flow.js documents io.delay as cancellable; a bare setTimeout let a torn-down |
| 87 | // run's settle/probe timers keep walking the old step graph beside the new |
| 88 | // run — two engines on one thread. Every timer is ledgered per thread and |
| 89 | // cleared on re-derive, on end, and at pagehide (the same-tab sign-in |
| 90 | // navigation: an in-flight probe was persisting an orphan "I'll wait here…"). |
| 91 | const runTimers = new Map(); // threadId → Set(timeout id) |
| 92 | export function delayFor(threadId) { |
| 93 | return (ms, fn) => { |
| 94 | let set = runTimers.get(threadId); |
| 95 | if (!set) runTimers.set(threadId, (set = new Set())); |
| 96 | const id = setTimeout(() => { |
| 97 | set.delete(id); |
| 98 | fn(); |
| 99 | }, ms); |
| 100 | set.add(id); |
| 101 | }; |
| 102 | } |
| 103 | export function clearRunTimers(threadId) { |
| 104 | const set = runTimers.get(threadId); |
| 105 | if (!set) return; |
| 106 | for (const id of set) clearTimeout(id); |
| 107 | runTimers.delete(threadId); |
| 108 | } |
| 109 | // Guarded for Node (the flow tests import this module without a DOM). |
| 110 | if (typeof addEventListener === "function") { |
| 111 | addEventListener("pagehide", () => { |
| 112 | for (const threadId of [...runTimers.keys()]) clearRunTimers(threadId); |
| 113 | }); |
| 114 | } |
| 115 | |
| 116 | // waitForRecordEchoes: records ride the WebSocket, and a same-tab redirect can |
| 117 | // outrun their echoes and lose the step answered right before "Sign in" — the |
| 118 | // quick form already waits exactly like this (onboardingui.js). Capped, so a |
| 119 | // lost echo can never wedge the ceremony. |
| 120 | export async function waitForRecordEchoes(threadId, app, own) { |
| 121 | if (!own || !own.size) return; |
| 122 | const settled = (step) => { |
| 123 | const st = appStateFor(threadId, app); |
| 124 | return !!(st && st.steps && st.steps[step] && st.steps[step].done); |
| 125 | }; |
| 126 | for (let i = 0; i < 20 && ![...own].every(settled); i++) await new Promise((r) => setTimeout(r, 100)); |
| 127 | } |
| 128 | |
| 129 | // ---- the plan step's price data (#1374) ------------------------------------- |
| 130 | |
| 131 | // The price book, fetched from home's public /api/pricebook before the flow |
| 132 | // runs (startSetup awaits it, so bridge.plan* are plain sync getters like |
| 133 | // every other bridge field). null = the plan step doesn't happen: a native |
| 134 | // wrapper (no external purchases — App Store rules), a self-hosted or |
| 135 | // already-paid space, billing off, or an unreachable book. Keyed by AUDIENCE; |
| 136 | // the book's tier keys are the ledger's routing keys, mapped here. |
| 137 | let planBook = null; // { team|community|family: { key, name, price } } |
| 138 | |
| 139 | async function fetchPlanBook() { |
| 140 | planBook = null; |
| 141 | if (window.__entitlement !== "free" || !window.__homeOrigin) return; |
| 142 | if (document.documentElement.classList.contains("native")) return; |
| 143 | try { |
| 144 | // Capped so a slow book never delays Eleven's hello — no book, no step. |
| 145 | const r = await fetch(window.__homeOrigin + "/api/pricebook", { signal: AbortSignal.timeout(4000) }); |
| 146 | if (!r.ok) return; |
| 147 | const body = await r.json(); |
| 148 | const audience = { business: "team", community: "community", family: "family" }; |
| 149 | const book = {}; |
| 150 | for (const t of body.tiers || []) { |
| 151 | const kind = audience[t.key]; |
| 152 | if (kind && t.name && t.label) book[kind] = { key: t.key, name: t.name, price: t.label }; |
| 153 | } |
| 154 | if (Object.keys(book).length) planBook = book; |
| 155 | } catch { |
| 156 | // Unreachable book — the conversation simply skips the plan step. |
| 157 | } |
| 158 | } |
| 159 | |
| 160 | // The bridge-verb table: the CLOSED set of side effects the setup flow may |
| 161 | // name (docs/conversation-scripts.md § interpreter contract). A flow is data — |
| 162 | // it can never do anything this table doesn't offer. |
| 163 | function verb(name, args, env) { |
| 164 | const bridge = setupBridge(); |
| 165 | switch (name) { |
| 166 | case "setMyName": |
| 167 | return bridge.setMyName(args[0]); |
| 168 | case "setGroupName": |
| 169 | return bridge.setGroupName(args[0]); |
| 170 | case "setContactEmail": |
| 171 | return bridge.setContactEmail(args[0]); |
| 172 | case "setUserInvites": |
| 173 | return bridge.setUserInvites(args[0]); |
| 174 | case "setFeature": |
| 175 | // The extras steps' gates ("ai" | "bots" | "apps" → the #712 settings). |
| 176 | return bridge.setFeature(args[0], args[1]); |
| 177 | case "adoptServerPhoto": |
| 178 | return bridge.adoptServerPhoto(env.threadId, args[0], args[1]); |
| 179 | case "savePasskey": |
| 180 | // Wait for this run's record echoes before the same-tab navigation — |
| 181 | // the redirect must not outrun the step recorded just before the tap. |
| 182 | return waitForRecordEchoes(env.threadId, "setup", ownRecords.get(env.threadId)).then(() => bridge.savePasskey()); |
| 183 | case "markSetupComplete": |
| 184 | return bridge.markSetupComplete(); |
| 185 | case "deferBackupReminders": |
| 186 | return bridge.deferBackupReminders && bridge.deferBackupReminders(); |
| 187 | case "openAdmin": |
| 188 | return openAppThread("admin"); |
| 189 | case "openPlanCheckout": { |
| 190 | // The finish line's payment chip (#1374): an upgrade of the already- |
| 191 | // created (free) space on the home page — this server has no billing |
| 192 | // surface. The recorded plan step holds the audience; the fetched book |
| 193 | // maps it back to the tier key. Same-tab: setup is complete, and home |
| 194 | // returns you to your list once the payment settles. |
| 195 | const st = appStateFor(env.threadId, "setup"); |
| 196 | const kind = st && st.steps && st.steps.plan && st.steps.plan.value; |
| 197 | const tier = planBook && kind && planBook[kind]; |
| 198 | if (!tier || !window.__homeOrigin) return; |
| 199 | location.href = |
| 200 | window.__homeOrigin + "/#upgrade=" + encodeURIComponent(location.host) + "&tier=" + encodeURIComponent(tier.key); |
| 201 | return; |
| 202 | } |
| 203 | case "openQuickForm": |
| 204 | // The name step's fast path — the host owns the form sheet (web: |
| 205 | // openSetupQuickForm in shell.js; native: the quick-form sheet). |
| 206 | return env.openQuickForm && env.openQuickForm(); |
| 207 | case "startTour": |
| 208 | // Reached by tapping "Take the tour" at the end of setup — the user has |
| 209 | // just spent minutes with Eleven, so skip the tour's own "Hi, fancy a |
| 210 | // tour?" intro and drop straight into it. |
| 211 | return startTour(env.respond, env.typing, { skipIntro: true }); |
| 212 | default: |
| 213 | throw new Error(`setup: unknown verb ${name}`); |
| 214 | } |
| 215 | } |
| 216 | |
| 217 | // startSetup(env): env = { threadId, respond, typing } — respond appends one |
| 218 | // Eleven notice to the setup thread's feed. The host guarantees we're the |
| 219 | // admin and setup isn't complete before calling. |
| 220 | export function startSetup(env) { |
| 221 | const bridge = setupBridge(); |
| 222 | if (!bridge || running.has(env.threadId)) return; |
| 223 | running.add(env.threadId); |
| 224 | runningEnvs.set(env.threadId, env); |
| 225 | env.sender = { name: "Eleven", kind: "bot" }; |
| 226 | env.bridge = { |
| 227 | myName: () => setupBridge().myName(), |
| 228 | enrolled: () => setupBridge().enrolled(), |
| 229 | // Did this admin arrive from their Eleven sign-in page (home)? If so the key |
| 230 | // step offers to ADD this space to their existing passkey (keys-home) rather |
| 231 | // than pitching passkeys from scratch. Must be forwarded here — the flow's |
| 232 | // bridge.* only sees what env.bridge exposes. |
| 233 | fromHome: () => setupBridge().fromHome(), |
| 234 | // Whether signing in is possible at all — self-hosted spaces have no home |
| 235 | // server, and the flow skips the un-actionable pitch (#1377). |
| 236 | canEnrol: () => !!window.__homeOrigin, |
| 237 | // The plan step's price book (#1374) — absent means the step is skipped. |
| 238 | planPrices: () => !!planBook, |
| 239 | planTeam: () => !!(planBook && planBook.team), |
| 240 | planCommunity: () => !!(planBook && planBook.community), |
| 241 | planFamily: () => !!(planBook && planBook.family), |
| 242 | planNameTeam: () => (planBook && planBook.team && planBook.team.name) || "", |
| 243 | planPriceTeam: () => (planBook && planBook.team && planBook.team.price) || "", |
| 244 | planNameCommunity: () => (planBook && planBook.community && planBook.community.name) || "", |
| 245 | planPriceCommunity: () => (planBook && planBook.community && planBook.community.price) || "", |
| 246 | planNameFamily: () => (planBook && planBook.family && planBook.family.name) || "", |
| 247 | planPriceFamily: () => (planBook && planBook.family && planBook.family.price) || "", |
| 248 | }; |
| 249 | env.onEnd = () => { |
| 250 | running.delete(env.threadId); |
| 251 | runningEnvs.delete(env.threadId); |
| 252 | ownRecords.delete(env.threadId); |
| 253 | clearRunTimers(env.threadId); |
| 254 | if (env.live) env.live.flowState(false); // un-mute the real Eleven |
| 255 | }; |
| 256 | if (env.live) env.live.flowState(true); // the script has the floor (one Eleven) |
| 257 | const io = { |
| 258 | say: env.live ? makeLiveSay(env.live) : say, |
| 259 | ask: awaitAnswer, |
| 260 | cancelAnswer, |
| 261 | verb, |
| 262 | record: (step, label, value) => { |
| 263 | // Ledger our own record BEFORE the emit — its socket echo folds back |
| 264 | // through the `folded` hook and must read as ours, not a foreign answer. |
| 265 | let own = ownRecords.get(env.threadId); |
| 266 | if (!own) ownRecords.set(env.threadId, (own = new Set())); |
| 267 | own.add(step); |
| 268 | return emitAppEvent(env.threadId, { type: N.ev("step"), step, label, value: value || "" }); |
| 269 | }, |
| 270 | done: (step) => { |
| 271 | const st = appStateFor(env.threadId, "setup"); |
| 272 | return !!(st && st.steps && st.steps[step] && st.steps[step].done); |
| 273 | }, |
| 274 | imgSource: (token) => (token === "$serverAvatar" ? "/api/server-avatar?t=" + Date.now() : token), |
| 275 | delay: delayFor(env.threadId), |
| 276 | }; |
| 277 | // The plan step's price book rides alongside the space info — both settle |
| 278 | // before the flow starts, so every bridge getter stays synchronous. |
| 279 | Promise.all([ |
| 280 | spaceInfo() |
| 281 | .then((info) => { |
| 282 | env.space = info || {}; |
| 283 | }) |
| 284 | .catch(() => { |
| 285 | env.space = {}; |
| 286 | }), |
| 287 | fetchPlanBook(), |
| 288 | ]).finally(() => runFlow(SETUP_FLOW, env, io)); |
| 289 | } |
| 290 | |