Phantasia — Living Breakdown

The Studio every Appolis app taps into · phantasia.appolis.app
← Appolis · the city of apps

PHANTASIA — The Studio Service · Living Breakdown

phantasia (φαντασία) — Greek: imagination, the faculty that forms images. The standalone Studio every Appolis app taps into.
This is a living document. Update it with every change that lands — same rule as every project.

1. WHAT IT IS

The AI Studio extracted out of FLIPPER (batch 39, 2026-07-14) into its own Cloudflare Worker so every business/app on the account — FLIPPER today, Kosmos next, anything after — taps the same generation engine while keeping its own brain: tools, learnings (prefs), keys, billing, assets, files. Grown inside FLIPPER across batches 27–38; everything below moved here verbatim.

The honest performance story: same-account service bindings are zero-latency RPC — extraction does not speed up single requests. What it buys: Studio D1 traffic no longer shares a database with 274k orders and CS queue caches; a Studio deploy no longer redeploys the live CS app; new apps get the whole Studio by adding one binding; per-business brains come free because every table was already tenant-scoped.

2. ARCHITECTURE


Flip CMS (Hermes) ──service binding──▶ phantasia-engine ──▶ D1 "phantasia"
kosmos            ──service binding──▶        │          ──▶ R2 "flipper-studio"
appolis hub (🔌)  ──service binding──▶        │              (/internal/mcp — the AI room)
public            ── phantasia.appolis.app ───┤              (standalone UI + /f/ + /onset)
appolis (🪪 ID)   ◀──service binding────────── ┘              (/id/check · /id/resolve)
                  ◀──service binding────────── ┘  hermes (/internal/overview — company brains)

Which door a request came through decides what it may do — that is now load-bearing, not incidental (§7). ctx.via is one of binding (a host app's embedded Studio: a human), session / appolis (the standalone UI: a human), connector (the AI room: a machine — may stage a generation, never spend, never approve), or dev. Identity headers are only honoured on a non-public hostname.

Four front doors:

  1. Service bindings — the host app authenticates its own user, then forwards with x-studio-internal (must equal INTERNAL_KEY) + x-studio-tenant/-user/-user-name/-user-email/-role. The profiles table mirrors identities from these headers on every request, so job/asset labels never need the host's user table.
  2. Standalone UI — profile logins (PBKDF2, phuser cookie). One-time first-admin bootstrap is APP_KEY-gated (?key=…), same pattern as FLIPPER.
  3. 🪪 Appolis ID (P2 adoption, v0.5.0) — one login for the whole city, same locked rules as Kosmos's P1. Dual-auth: the standalone login form tries local profiles first, then the same credentials against appolis /id/check over the APPOLIS_ID service binding; an account entitled to phantasia (or the master wildcard *) signs in right here and gets the shared appolis_id cookie (Domain=.appolis.app, 7d) so every other suite app opens too. SSO fallback: no binding headers + no phuser → the shared cookie verifies locally (HMAC vs ID_SECRET, cheap reject) then resolves over /id/resolve; entitlement checked on EVERY resolve, so a revoked grant locks the door immediately. Lazy provisioning: an Appolis identity maps onto a profile by profiles.appolis_id, else adopts an existing password-bearing login by email (Tyler's master email → his Spartan Studios admin; mirror-only connector rows never match), else mints a fresh isolated t_ws_… workspace with the newcomer as its admin — the v0.3.0 isolation rule holds. Sign-out clears BOTH cookies (the suite cookie IS an SSO user's session — leaving it would sign them straight back in). The any-door rule stands: this login screen keeps working forever; the portal is never the gatekeeper.
  1. 🔌 The Appolis Connector room (v0.6.5, POST /internal/mcp) — the suite hub forwards raw JSON-RPC here over its binding under ID_SECRET, naming the person by email. Every tool is a thin wrapper over the same REST routes the UI calls (the door re-enters handleFetch with binding-trust headers), so key resolution, billing, tenant isolation and 🏛 house-pipeline sealing apply unchanged — no duplicated permission logic, ever. Two rules this room enforces that the rest of the app cannot: it cannot spend (generation is staged for a human — todo_1061) and it cannot outlive access (brains are re-resolved from Hermes on every call — todo_1062). list_brains shows which brains are reachable right now; brain on any tool picks one, defaulting to the person's own workspace.

Public surface: exactly GET /f/:id?e=<epoch>&t=<hmac> — signed share links (SIGN_KEY HMAC over fileId.expiry) — plus the 🎬 On-Set slate at /onset and its /api/onset/* session + shared-clock endpoints (crew phones, no login by design; every field length-capped). Everything else 401s without an identity.

Tenancy: tenants (id, name, brand JSON). FLIPPER = t_verdant (kept from before the extraction so every migrated row works unchanged). Each tenant's tools/prefs/keys/billing/assets/files are fully separate — the per-business brain.

3. THE AI CORE (moved verbatim from FLIPPER batches 27–38)

Host-app endpoints (internal)

4. MY FILES (batch 39, new)

Per-profile private storage (user_files): streaming upload (POST /api/myfiles/upload?name=&folder=), folder labels, rename, authed download, hard delete (no trash for personal files), and POST /:id/share → HMAC link (default 7 days, max 30). The share link is how a file rides into a Gorgias reply (FLIPPER's 📎 composer button) or gets handed to the Flexport claims portal. Strictly own-files-only — admins included.

5. STANDALONE UI

web/studio.html is generated by scripts/build-studio-html.js; build.js runs it first, then precompiles JSX → studio.<hash>.js + studio-compiled.html (served at /), self-hosted React, immutable caching, keeping the last 2 old bundles as a deploy grace window (v0.6.1). Edit the generator, never the generated shell.

Four tabs: ✨ AI Tools · 🪄 Pipeline · 🎬 Editor · 🎥 Video Projects. Two halves with different owners (see §7): StudioAI / PipelineView / StudioEditor are sliced verbatim out of Hermes's page — change them there — while the App shell, the login/bootstrap screens and VideoProjects (including the 🤝 shared_from badge) live inline in the generator and are edited here.

🎬 On-Set mode is not part of the React shell at all: web/onset.html is served as a real 200 at /onset by worker.js (bypassing both the assets .html→clean redirect and the SPA shell). It's the crew's slate + shot logger — digital slate, rolling timecode, a sync clap, take logging with circle/keep/NG — on a shared free-run clock (GET /api/onset/time + client offset sync, verified live at ±9ms) so every device that joins a session code reads the same timecode. Sessions are per-shoot (/onset?s=vp-<projectId>), so concurrent shoots never collide. Public by design (crew phones have no login); every session field is length-capped because of it.

Note: the ad-lander panel needs product context that lives in Hermes — build landers from Hermes's embedded tab; everything else is fully functional standalone.

6. SECRETS (wrangler.jsonc vars)

⚠️ These are all vars, i.e. plaintext in this repo. Moving them to wrangler secret put + rotating is tracked as todo_1059 and has to be coordinated across the five workers that share them (this one, appolis, agora, Flip CMS/Hermes, AWS Motor Club) — INTERNAL_KEY must keep matching each host's STUDIO_KEY, and ID_SECRET must match appolis. Don't rotate one repo alone.

7. RULES THAT CARRY OVER + THE DRIFT RULE

Generation approval (nothing auto-fires), exact-twins-only sourcing, House/Own two-state billing, never store cards, coral→gold = negative actions only.

UI drift rule: StudioAI / PipelineView / StudioEditor are shared with Hermes's page BY COPY — scripts/build-studio-html.js slices them out of it on every build, so an edit made here is silently overwritten. Change those in Hermes. The App shell, login/bootstrap and VideoProjects are inline in the generator and are edited here. (§5.)

🔑 The door test (todo_1063): identity headers (x-studio-) and /internal/ are binding-only. Cloudflare routes by Host, and nothing reaches this Worker under another app's hostname except a service binding — Kosmos and Hermes forward their own hostname, the suite hub uses https://internal, dev is localhost. So arriving at one of our client-facing hostnames (PUBLIC_HOSTS / *.workers.dev) means the public internet: identity is ignored there even with a valid key. A leaked key is no longer an identity.

💸 The approval rule has a server-side home (todo_1061): the rule used to live only in the browser, so it didn't bind the AI. Now the connector door cannot spend — it can only stage an ai_stages row for a person to approve, and an approval is single-use. Any NEW door that can reach /api/studio/ai/generate must be classified: a human click may spend, a machine may only stage.

🏢 Access is resolved live, never cached into a row (todo_1062): both doors compute reachable brains from Hermes per request, so revocation lands on the next call and the company role is always the live one. Never re-introduce "read the tenant/role off the local profile row" — that is precisely what let an ex-employee keep a company brain on their assistant.

8. MIGRATION RECORD (2026-07-14)

ai_tools 13 · ai_prefs 6 · ai_catalog 1 · everything else 0 rows (all generation history was local-dev only — production had never generated). Parity verified per table. R2 bytes untouched. House keys: none existed in prod /connections yet — when Tyler saves them there, the passthrough mirrors them here. Chardizy's ai_* tables still exist (empty of readers) as the rollback path; drop them in a later cleanup batch after soak.

9. CHANGELOG

git init + a baseline commit (77e2841, 34 files). Until now Phantasia had no git history at all — no diff, no revert, no blame for the entire Studio backend, while every sibling app had one. It also meant the suite's house rule that a dirty tree signals another session is mid-flight was silently inoperable here: the collision check returned "clean" for a repo it could not see.

⚠️ AND THE FIRST COMMIT IS EXACTLY WHERE IT WOULD HAVE BITTEN. The .gitignore rule protecting the credential file was broken from the day it was written (2026-07-26): it read wrangler.jsonc # INTERNAL_KEY / SIGN_KEY / … in vars. Git honours # as a comment only at the START of a line, so the trailing note became part of the pattern, the rule matched nothing, and wrangler.jsonc was never ignored. Harmless only because there was no repo — a git add . would have committed ID_SECRET. Fixed, and proven with git check-ignore -v rather than eyeballed (wrangler.jsonc and data/ both confirmed ignored before staging). Staged content was scanned too, not just filenames: no staged file contains the live secret, none carries an sk-/aid_/amt_/apg_ literal. Excluded by design: wrangler.jsonc, data/ (phantasia.db stores provider API keys in plaintext in house_keys), .wrangler/, node_modules.

② The build reported success while doing half its job — for three weeks. scripts/build-studio-html.js slices the Studio components out of Hermes and writes web/studio.html, so the standalone shell cannot drift from the copy Hermes embeds. It read …/chardizy/web/flip-my-life-retention.html — which stopped existing when FLIPPER was renamed to Hermes and the file became hermes.html. That rename enumerated its own consumers and missed this one, because it lives in a different repo. So the script threw ENOENT, build.js caught it, printed studio.html regen skipped and exited 0. web/studio.html last regenerated 2026-07-26; its source last changed 2026-08-18. Regenerated now: 155,636 → 171,043 bytes.

THE FIX HAS TWO HALVES, and the second is the one that matters. SRC repointed to web/hermes.html — and the two failures that used to look identical are now separated: if the Hermes repo is not checked out at all (a CI box, someone else's machine) the script says so and exits 0, which is the intended fallback; if the repo is present but the source is missing — a move, a rename, a typo — it exits non-zero with instructions for finding the new file. build.js no longer wraps it in a catch that downgrades everything to a warning, because a build step that cannot do its job must fail the build rather than report success. Deployed; phantasia 200, gated Studio paths 307 to auth as expected, whole suite 200.

Move, not change — identical values, so no /f/ share link was invalidated. That is the whole reason Step 0 exists as a separate step: rotating SIGN_KEY would 403 every outstanding signed share link, and those get pasted into CUSTOMER emails with up to a 30-day life. Moving it costs nothing; changing it is a customer-visible event and is still NOT done.

⚠️ THE RUNBOOK'S STATED ORDER IS IMPOSSIBLE. It says put the secret first, then delete the vars line. The Cloudflare API refuses: "Binding name already in use" [code: 10053] — a secret cannot take the name of an existing plaintext binding. The var must be stripped and deployed first, which opens a brief window where the binding is undefined. It fails CLOSED, so it is unavailability and never exposure, but for this app that window includes /f/ signing — so it was done as ONE window for all three keys, off-hours, with the values copied out-of-repo first (stripping the config destroys the only copy).

⚠️ Worth knowing for this repo specifically: wrangler.jsonc is gitignored here, so these three were on disk only and never entered git history — unlike Kosmos, whose config is tracked. That does not make disk-plaintext safe, but it does mean there is nothing to purge.

ID_SECRET stays in vars on purpose — it is the only credential the weekly Appolis tripwires can read, and no session can read a secret. Verified after: phantasia 200, whole suite 200.

① The shared SSO cookie is no longer pre-verified here. The Appolis-ID branch used to gate its /id/resolve call on auth.verifyIdSession(m[1], deps.idSecret) and skip everything if that failed. That made Phantasia a second, silent authority on a signature only Appolis may judge — and it pinned the suite to one key: the instant Appolis signed sessions with its own ID_SESSION_KEY, every valid cookie would have failed here and Studio SSO would have died without the request ever reaching /id/resolve. Because /api/hub-apps swallows failures into {apps:[]}, it would have died silently. Removed and deployed before Appolis flipped its key. Behaviour is unchanged: a bad token used to be rejected locally, now /id/resolve rejects it.

⚠️ The near-miss worth remembering: the helper here is named verifyIdSession, while its twins in Kosmos and Agora are verifySession. A grep for the sibling name returns nothing in this repo. The cutover was enumerated app-by-app rather than pattern-matched, which is the only reason this file was included at all.

/api/my-connector now forwards the person's own signed cookie (x-id-token, from the headers.cookie regex already used elsewhere in this file). That route returns a credential, not information: the amt_ token acts fully as the person across every licensed app, needs no header, and survives every key rotation. Appolis prefers the token and echoes proven: true; the ?email= stays until its phase C, because a local (non-SSO) Studio session has no such cookie.

🔧 Two repo-health problems found while doing this — see the phantasia board (todo_1707): this repo is not a git repository at all (no history, no revert, and the suite's "dirty tree means someone is mid-flight" check silently cannot work here), and node build.js half-fails while reporting successscripts/build-studio-html.js reads a Hermes-repo file that has moved, so studio.html has not regenerated in some time while every deploy looked clean. Also worth knowing: worker.js is a ~5 KB shim that imports server/api.js and wrangler bundles it at deploy, so editing the server file IS sufficient — grepping the built worker for your change finds nothing and looks like a failed edit.

💸 The AI room could spend money on its first tool call (todo_1061). generate mapped straight onto the paid route: no staging, no pending state, no confirmation, no ceiling. The generation-approval rule — "nothing fires until a person approves" — lived only in the browser, in React state, so a prompt injected into a note the assistant merely READ could bill a provider, on House billing that's the company's key, in a loop. Fixed by giving the rule a server-side home: the connector door stamps x-studio-via: connectorctx.via==='connector', and that door cannot spend at all. generate/stage_generation write an ai_stages row (pending, with the resolved prompt and a real cost estimate from the same estimateCents maths the runner bills) and return 202 {needs_approval, stage_id, est_cost_cents} having contacted no provider. Only a human door can POST /stages/:id/approve (the AI gets a 403 — it can ask, never approve), and only an approved row can runsingle-use, so an approval can't be replayed into repeat spend. A 30-day per-connector cent ceiling (CONNECTOR_CAP_CENTS, default 2000¢) backstops even approved runs, and ai_jobs.via now records which door spent, so machine spend is auditable beside human spend. The Studio's own click path is untouched: a human click still generates immediately — that click is the approval.

🏢 The connector pinned the wrong brain and never heard about revocation (todo_1062). One Appolis ID legitimately owns a profile row per company brain it has opened, and with no index and no ORDER BY the lookup returned the lowest-rowid row — whichever tenant the person was provisioned into first, often a COMPANY brain, with no argument to choose otherwise. Worse, the AI door made no Hermes, entitlement or membership call at all, so an ex-employee kept a company's gallery, other members' job prompts, pipelines and house-key spend on their assistant indefinitely while the website had already cut them off — and the local role, written once at first contact, let a stale admin outrank Hermes's live answer (re-stamped on every call, self-reinforcing). Now the connector resolves brains through the same live accessibleBrains() the cookie door uses, on every call: revocation lands on the next tool use, the default is the person's own workspace, a validated brain argument + a list_brains tool make the choice explicit, and the live company role is what gets stamped. accessibleBrains also stopped keying "your own brain" off t_ws_ alone — that missed a standalone workspace with a real login (Tyler's t_spartan), leaving the master account with no own brain and defaulting it into a company.

🔑 x-studio-internal was honoured on the public internet (todo_1063). Identity came purely from headers — tenant, user and role all taken from the request with no origin check — so anyone holding the key could read any tenant's gallery, download any member's My Files, or spend the house keys straight from the internet. Not reachable through the connector tools, but it was the floor the whole tenancy model stood on. Fixed with the door test: Cloudflare routes by Host, and nothing reaches this Worker under another app's hostname except a service binding — Kosmos and Hermes each forward their own hostname, the suite hub uses https://internal, dev is localhost. So a request that arrived at one of our client-facing hostnames (PUBLIC_HOSTS, default phantasia.appolis.app, plus any .workers.dev — which covers preview URLs) is by definition the public door: x-studio- is ignored there even with a valid key, and /internal/ 404s, closing the public /internal/mcp door too. ⚠️ Bind a new custom domain and you must add it to PUBLIC_HOSTS. Found in passing: the dev server never routed /internal/ to the handler at all, so the connector room could not be exercised or tested locally — which is why it had no coverage; the node path now mirrors the Worker.

Then the fixes were adversarially reviewed, and three of them had holes — all reproduced, all fixed here:

· Revocation PROMOTED (critical, introduced by this batch). Widening "your own brain" to any row not currently in the company list inverts on the exact event the system exists to handle: when Hermes drops the business — or is merely unreachable — the company list empties, and an ordinary employee's only profile row is a mirror inside that company. It was relabelled as their own brain at admin, letting a just-removed member read and overwrite the company's house keys. A home brain is now personal by construction (a real login or a workspace we minted), and its role comes from the row instead of being hardcoded. An outage must never promote.

· A trailing-dot Host walked through the whole door test (critical). phantasia.appolis.app. is the same host to DNS and to Cloudflare's router but a different string to includes(), and WHATWG URL keeps the dot — so identity headers were honoured and /internal/* re-opened on the public domain. Hostnames are normalized before comparison now.

· One approval bought N runs (high). /stages/:id/run read the row, awaited a provider round-trip, then wrote — so parallel run_staged calls on a single approval each passed the check and each billed (measured: 8 parallel → 8 charges, audited as one run), and the same read-then-act shape walked through the ceiling. The stage is now claimed with a compare-and-swap before any provider I/O, the loser gets a 409, and a claimed-but-unbilled stage reserves its estimate so a burst of separate approvals can see each other. The double-clicked approve-and-run path got the same treatment.

Still owed (not this batch): the shared machine-trust secrets still sit in vars in five workers and want wrangler secret put + a coordinated rotation (todo_1059); the hub still forwards identity as a bare email header rather than a signed envelope (todo_1060); the ceiling is per (tenant, user), so someone with several reachable brains has one ceiling per brain; and the ⏳ AI-requests approval panel is not in the shipped shell yet — the API is live and a human can approve with POST /api/studio/ai/stages/:id/approve (optionally {"run":true}), but the in-app surface is still to build.

26/26 tests — they assert the todos' own verify criteria (50 staging calls → zero provider requests, one approval → exactly one, a revoked Hermes membership vanishing from the connector without touching the web, the header set landing 401 on the public domain while both binding doors keep working) plus a regression apiece for the three review findings.

Rendered from phantasia-engine/APP_BREAKDOWN.md · this page is generated — edit the markdown, not the HTML
print this page for a PDF