The galaxy
ORBIT I · CONSTELLATION

Builders

Ship product

Turning an idea into something that loads, looks good and deploys. Astro, components, multi-tenant, white-label and i18n. The craft behind Espejo and the XNLAB sites.

Powers EspejoXNLAB
6 modules · 6 lessons
CONSTELLATION MODULES Open each module for the full class
BD-01

Astro & Vite that load fast

lesson

Set up and understand an Astro site that generates static HTML per route and locale, plus a Vite SPA project, knowing exactly what JS reaches the browser and why it loads fast.

90% of modern web slowness is JavaScript that never needed to be shipped. Astro and Vite give you speed by default, but only if you understand where each thing runs: build-time vs runtime. Confusing the two is the root cause of static sites that suddenly carry 400KB of JS.

THE LESSON

Start with the distinction that governs everything: in Astro the code of a .astro component runs at BUILD time (on your machine or in CI), not in the browser. The output is plain HTML. MOONKEY is exactly this: `astro.config.mjs` declares `i18n` with `fallbackType: 'rewrite'` and does NOT use SSR. When you run `npm run build`, Astro walks `src/pages/`, executes each .astro once per locale, and writes HTML to `dist/`. There's no server in production: Cloudflare Pages serves files. That's what makes `moonkeylab.pages.dev` load in milliseconds.

Build the skeleton from scratch to see it without the magic: `npm create astro@latest mi-sitio -- --template minimal --no-install`, then `cd mi-sitio && npm install && npm run dev`. Open `http://localhost:4321`. Edit `src/pages/index.astro` and watch how a `const hoy = new Date()` printed with `{hoy}` freezes at build time when you run `npm run build`. That's the aha moment: the frontmatter JS (between the `---`) runs once, not on every visit.

Now the fine-grained control: islands. By default a framework component (React/Svelte) in Astro renders to static HTML WITHOUT its JS. Only if you add a `client:*` directive does it hydrate. `client:load` hydrates on load, `client:visible` when it enters the viewport, `client:idle` when the thread is free. Rule of the trade: start with NO directive. Only add `client:visible` when the component genuinely NEEDS real interactivity. In MOONKEY the reveal `IntersectionObserver` lives in `Layout.astro` as a global `<script>`, not as a per-page island — that's the right call: a 15-line script instead of hydrating an entire framework.

Vite is the engine underneath Astro and also the one behind Espejo (which is a pure React SPA: `vite.config.ts`, `@vitejs/plugin-react`). The difference: Espejo DOES ship React to the browser because it needs live interactive state (camera readings with MediaPipe, navigation with react-router). That's the real dividing line of the craft: is the content mostly reading (XNLAB's site, MOONKEY) or is it an app with dense state (Espejo)? The former calls for Astro/SSG; the latter for Vite+SPA. Don't mix religions: don't cram an entire SPA inside Astro when 95% of it is content.

Measure, don't opine. Run `npm run build` and look at the size of `dist/`. In MOONKEY a content-page build should ship ~0KB of page JS (only the shared reveal script). Open DevTools → Network, reload with an empty cache and filter by JS: if you see a framework bundle on a page that's just text, you've added one `client:*` directive too many, or an import that drags a heavy library to the client. The command `npx astro build --verbose` lists what gets prerendered.

Classic failure mode: importing a date/markdown/icon library in the frontmatter thinking it's build-only, but then using it inside a client `<script>` or passing it to an island — and suddenly it ships in the bundle. Another: forgetting that `import.meta.env` in Astro distinguishes `PUBLIC_*` (goes to the client) from the rest (stays in build). If a variable you thought was private shows up in `dist/`, you prefixed it wrong. And the Vite trap: anything under `public/` is copied verbatim, unprocessed — don't put secrets there or expect Vite to optimize those assets.

EXERCISE

Build a minimal Astro site with two pages: `/` (HTML only, zero JS) and `/contador` with a React component `<Counter client:visible />`. Run `npm run build`. Open `dist/` and prove with DevTools Network that `/` downloads 0KB of JS and `/contador` only hydrates when you scroll down to the button. Document the exact size of both bundles.

DELIVERABLE

An `astro-islas/` folder with the repo plus a `MEDICIONES.md` listing: size of `dist/`, KB of JS per page (Network screenshot), and one sentence explaining why `/` ships nothing and `/contador` does.

KEY INSIGHT

The right question is never 'which framework do I use?' but 'what JS MUST exist in the browser?'. Astro inverts the web's default: it starts from zero JS and forces you to justify every byte of interactivity with an explicit directive. The day you internalize that the frontmatter runs at build and not at runtime, you stop shipping junk.

MISTAKES TO AVOID

  • ×Putting `client:load` on everything 'just in case' — you hydrate the whole site and lose Astro's advantage; start with no directive and add `client:visible` only where there's real interaction.
  • ×Believing the frontmatter JS (between `---`) runs in the browser; it runs at build, exactly once, and its output is frozen into the HTML.
  • ×Putting a secret variable in `import.meta.env.PUBLIC_*` — the PUBLIC prefix exposes it in `dist/`; non-public values stay in build.
  • ×Confusing when to use Astro vs Vite-SPA: reading content → Astro/SSG; an app with dense, live state like Espejo → Vite+React.
  • ×Assuming `public/` gets optimized: it's copied verbatim, with no hashing or minification, so it's not the place for assets you want Vite to process.
BD-02

Components and design tokens

lesson

Build a coherent visual system with design tokens (CSS custom properties) and reusable components, instead of loose repeated CSS, so that a brand change is one line and not a hunt.

Loose CSS doesn't scale: the day the client asks for 'the violet a little darker' you end up searching `#7c3aed` across 40 files and miss three. Tokens turn that change into a single-variable edit. It's the difference between a maintainable product and visual debt.

THE LESSON

A design token is a variable with a semantic name, not a raw value repeated. MOONKEY defines its own in `src/styles/global.css`: `--surface-base: #f8fafc`, `--text-primary: #0f172a`, plus the emerald/violet/amber palette. The golden rule: components NEVER use the raw hex, they use the token. That way `--surface-base` appears once; if the palette changes, you change one place. The antipattern is writing `color: #0f172a` in every card — you've coupled 30 files to a value that should have a name.

The most instructive case in the craft is in Espejo, because there the tokens are DYNAMIC per tenant. Look at `src/brand/BrandProvider.tsx`: the `applyTheme(brand)` function does `root.style.setProperty('--brand', brand.accent)` and derives `--brand-soft`, `--brand-line`, `--brand-glow` with a `withAlpha(hex, a)` function that converts the brand hex into rgba with transparency. Espejo's entire public UI paints with `var(--brand)`. The result: the same code serves Luna Rosa (pink `#e58fb0`) and any influencer, by changing ONE `accent` field. That's a design system that is also the white-label engine.

Component vs loose class: a component encapsulates structure + tokens + behavior under a name. MOONKEY has `PromptBlock`, `AnimalIcon`, `MoonkeyLogo`, `FlowDiagram` in `src/components/`. The signal that you need a component: you copy the same block of markup+classes a third time. Up to twice, duplicate; on the third, extract. Don't abstract earlier — the premature abstraction of a component with 8 props 'just in case' is worse than honest duplication.

With Tailwind there's a specific trap that MOONKEY documents explicitly: NEVER interpolate classes like `bg-${theme}`. Tailwind does tree-shaking by scanning the code for COMPLETE class strings at build; a class built at runtime doesn't exist in the generated CSS and renders unstyled. That's why `galaxy.ts` has a `THEME` object mapping each constellation to its literal classes (`violet`, `amber`, `emerald`…) already written out. The lesson: dynamic classes are resolved with a MAP of literal strings, not with interpolation.

Typography as a system token: MOONKEY fixes `font-display` = Space Grotesk for h1–h3, Inter for body, JetBrains Mono for labels (`.label` = mono uppercase tracking-widest emerald-600). That's not loose decoration: it's a decision encoded once that the whole app inherits. If every heading picked its own font, you wouldn't have a brand, you'd have noise. The system lives in the tokens/utilities layer, not in each `<h1>`.

How to verify coherence: grep for raw values that should NOT exist outside the tokens file. `grep -rn '#[0-9a-fA-F]\{6\}' src/components/` in MOONKEY should return almost nothing — if a hex appears in a component, it's a leaked token to replace with `var(--token)`. That grep is your hand-rolled linter for visual coherence, and you should run it before every merge.

EXERCISE

Take a landing page with 3 sections that uses repeated hex values. Extract ALL the colors and radii to tokens in a `:root` (`--surface`, `--text`, `--accent`, `--radius`). Then replicate Espejo's pattern: add an `applyTheme(accent)` function that changes `--accent` at runtime and prove that with ONE call the whole page rebrands. Verify with `grep` that no hex survives outside the tokens block.

DELIVERABLE

A `tokens-tenant/` folder with `styles/tokens.css` (all the variables), the components using `var(--token)`, and a button that calls `applyTheme()` with 3 different colors. Plus a `grep` log proving 0 loose hex in components.

KEY INSIGHT

A design system isn't a library of pretty components: it's the indirection that separates 'what value' from 'where it's used'. The definitive test is Espejo's — if you can rebrand an entire white-label product with a single `setProperty`, you have a system; if you have to touch several files, you have CSS in disguise.

MISTAKES TO AVOID

  • ×Interpolating Tailwind classes (`bg-${theme}`) — the JIT doesn't see them at build and they render unstyled; use a map of literal strings like galaxy.ts's `THEME`.
  • ×Writing raw hex in components instead of `var(--token)` — you couple N files to a value that should have a single point of change.
  • ×Abstracting a component with 8 props 'just in case' before you have 3 real uses; honest duplication beats premature abstraction.
  • ×Letting each heading pick its font instead of fixing the typographic hierarchy once in the tokens layer.
  • ×Forgetting to derive the brand color's states (soft/line/glow): a single accent without its alpha variants produces incoherent shadows and borders — copy Espejo's `withAlpha` pattern.
BD-03

Multi-tenant & white-label

lesson

Design a real multi-tenant white-label product: a single codebase serving many clients with their own brand, data and configuration, using the Store seam pattern that isolates the UI from storage.

White-label is the business model that multiplies one build by N clients without rewriting anything. But it only works if the isolation between tenants is real: one client's data leaking to another kills the product. Espejo's Store seam is the architecture that makes it possible and, at the same time, prepares the migration to a backend.

THE LESSON

Multi-tenant means: one instance of the software, many clients (tenants) who perceive it as their own. White-label adds: each tenant sees it with THEIR brand, with no trace of yours. Espejo is the canonical case — `src/brand/types.ts` says it in the comment: 'each influencer is a tenant with their brand'. The tenant is identified by a `slug` in the URL (`/r/<slug>`) and everything — name, accent, glyph, voice, which readings are offered, the upsell CTA — comes from a `Brand` object. The entire public app is painted from that object.

The architectural heart is the data SEAM. Look at `src/brand/store.ts`: it defines a `Store` interface with async signatures (`getBrand`, `listBrands`, `saveBrand`, `addSubscriber`, `listSubscribers`) and a `LocalStore` implementation over localStorage. The comment makes it explicit: 'Today: localStorage. Tomorrow: Supabase (same signatures). The UI never touches storage directly, only this interface.' That interface is the seam: the UI depends on the ABSTRACTION, not the implementation. Switching backends = writing a new class that implements `Store`, without touching a single component.

How the tenant reaches the UI: `BrandProvider.tsx` receives the `slug`, calls `store.getBrand(slug)`, and if it exists it runs `applyTheme(brand)` and puts the brand into a React Context (`useBrand()`). If it doesn't exist, it marks `notFound`. Clean pattern: one entry point resolves the tenant, applies its theme, and provides it to the whole tree. Any component calls `const brand = useBrand()` and paints its part. No one else touches storage or decides the theme.

Isolation between tenants is the red line of white-label, and here's the real risk: `listSubscribers(slug)` filters by `brandSlug`. In localStorage that's trivial but FRAGILE — everything lives in the same browser, there's no real security boundary. The critical moment is the migration to Supabase: there the isolation MUST move to Postgres RLS (a subscriber row is only visible to its tenant), exactly the discipline MOONKEY applies with `is_admin()` and self-or-admin policies. In real multi-tenant, the `WHERE slug = ...` filter in the client is NOT security; security is RLS in the database.

Per-tenant configuration without code branches: notice that Espejo has no `if (tenant === 'lunarosa')` anywhere. The variation is DATA (`Brand.readings`, `Brand.voice`, `Brand.offer`), not conditional logic. That's the rule of healthy white-label: the differences between clients live in configuration (data), never in code branches. The moment you write the first `if (client X)` you've started forking the product and losing the model's economics.

There's a specific ethical guardrail in Espejo that is not optional: the project memory states that NO covert lead siphoning or sale of personal/biometric data is to be built — only consented first-party data and anonymized aggregates. In a multi-tenant product that handles your clients' end-users' data, the consent model (`types.ts` has the version of the accepted consent text and `Brand.legal.responsible`) is part of the architecture, not a legal add-on bolted on later. The data controller is the influencer-tenant, and that must be modeled in the data.

EXERCISE

Build a mini white-label product: a `Store` interface with `getTenant(slug)`/`listItems(slug)`, a `LocalStore` over localStorage with 2 seed tenants (different accent, different name). A `TenantProvider` that resolves the slug from the URL, applies the theme and provides it via Context. Prove that by changing only the slug in the URL the entire app rebrands, without a single per-client `if`.

DELIVERABLE

A `white-label-seam/` folder with `store.ts` (interface + LocalStore), `TenantProvider`, and two routes `/t/<slug>` that render two different brands from the SAME code. A `README` pointing out where the seam is and what you'd change to migrate to a backend.

KEY INSIGHT

Espejo's Store seam is the most valuable piece of engineering in the product: it separates 'what the app does' from 'where the data lives' behind a 5-signature interface. That indirection is what turns a localStorage demo into a multi-tenant SaaS — and what ensures the client-side slug filter can never be mistaken for real security (that lives in RLS).

MISTAKES TO AVOID

  • ×Filtering by tenant only in the client (`WHERE slug=...` in JS) and believing that isolates data — on the backend, isolation MUST be RLS in Postgres, not an application filter.
  • ×Putting `if (client === 'X')` in the code: the moment you branch by client you've forked the product; variation goes in data/config, never in logic.
  • ×Coupling the UI directly to localStorage/Supabase instead of to the `Store` interface — you lose the seam and the migration becomes a rewrite.
  • ×Forgetting the consent model and the per-tenant data controller: in multi-tenant with end-user data, that's architecture, not paperwork (Espejo's ethical boundary: no covert data siphoning).
  • ×Not defining the `tenant not found` case: without an explicit `notFound`, an invalid slug breaks the app instead of showing a clean state.
BD-04

i18n: one galaxy in 6 languages

lesson

Architect internationalization for 6 languages without duplicating pages, understanding translation patterns, fallback, and why hrefs must be locale-aware.

i18n done by brute force (one page folder per language) multiplies maintenance by 6 and guarantees the translations drift out of sync. The right architecture has ONE template and the languages are data, not copies. It's the difference between scaling to 6 locales or drowning in them.

THE LESSON

MOONKEY runs 6 locales — `['es','en','th','fr','it','ch']` (config in `src/i18n/config.ts`). ES lives at the root; the rest under a prefix (`/en/`, `/th/`, `/fr/`, `/it/`, `/ch/`). The piece that avoids duplicating pages is Astro i18n with `fallbackType: 'rewrite'` in `astro.config.mjs`: during the build, Astro REWRITES each page with each locale's context, so a single `index.astro` generates 6 translated HTML files. There's no `pages/en/index.astro`, `pages/fr/index.astro`… there's ONE. That's the central rule: one template, N outputs.

The fallback is what makes the system robust without translating everything at once: `astro.config.mjs` declares `fallback: { en:'es', th:'es', fr:'es', it:'es', ch:'es' }`, and the `useTranslations` function in `src/i18n/utils.ts` implements the same chain: `dict[key] ?? base[key] ?? key`. That is: if a key is missing in French, it falls back to Spanish; if it's not there either, it returns the key itself (visible, easy to spot). This lets you SHIP with partial translations without breaking anything — French shows Spanish where you haven't translated yet, not a broken page.

MOONKEY uses THREE i18n patterns and CLAUDE.md warns about the inconsistency, which is a lesson of the craft in itself: (1) a `src/i18n/ui.ts` dictionary with `t('key')` for short chrome/nav (~240 keys); (2) inline content objects with `cLocale = (es|th) ? locale : 'en'` that collapses FR/IT/CH to EN, for heavy content like the curriculum; (3) `src/pages/en/*.astro` pages that override ONLY in English. Pattern 3 is debt: there are 5 root pages hardcoded in Spanish, so `/th/fr/it/ch` show ES on them. Lesson: pick ONE dominant pattern (the dictionary with fallback) and treat the others as debt to pay down, not as healthy variety.

The most expensive bug in prefixed i18n is the raw href. If you write `<a href='/cuenta'>` from a page under `/fr/`, you send the user out of their language. That's why MOONKEY has `localizedPath(pathname, target)` in `utils.ts`: it strips the current locale prefix, rebuilds the path and prepends the target (`/fr/cuenta`), leaving ES at the root. CLAUDE.md marks it as mandatory: 'use `localizedPath('/ruta', locale)`, never raw `/ruta`'. Internalize this: on a site with language prefixes, EVERY link passes through the localization function, no exceptions.

Detecting the active locale has a subtlety that `utils.ts` resolves: `resolveLocale(currentLocale, pathname)` prefers `Astro.currentLocale` over URL-based detection, because during a fallback rewrite the URL may have lost the prefix and only Astro's context knows the real locale. If you detect locale solely by `pathname.split('/')` you get it wrong precisely on the pages that use fallback. Rule: trust the framework context first, the URL as a backup.

The language selector needs two separate dictionaries: `LOCALE_LABELS` (ES, EN, TH…) for the compact switcher and `LOCALE_NAMES` (Español, ไทย, Italiano…) for the full dropdown. And watch the odd cases: 'ch' here is Deutsch (CH), Swiss German, not Chinese — a comment in `config.ts` clarifies it. That kind of ambiguity (a code that looks like another language) is exactly what you document in the code so nobody translates into the wrong language six months later.

EXERCISE

Set up an Astro site with 3 locales (es at root, en/fr with prefix). Create ONE `index.astro` that uses `t('hero.title')` from a `ui.ts` dictionary. Implement `useTranslations` with the fallback `dict[key] ?? base[key] ?? key` and `localizedPath`. Leave one key UNtranslated in FR and prove it falls back to ES (doesn't break). Add a language selector that uses `localizedPath` so it doesn't drop the user out of their locale.

DELIVERABLE

An `i18n-3locales/` folder with `i18n/config.ts`, `ui.ts`, `utils.ts` (useTranslations + localizedPath), one single `index.astro` that generates 3 HTML files, and a screenshot showing FR falling back to ES on the untranslated key.

KEY INSIGHT

i18n done right turns language into a data parameter over ONE template, not copies of pages. The chained fallback (`dict ?? base ?? key`) is what lets you ship with half-finished translations without broken pages — and `localizedPath` on every href is the invisible difference between a user who stays in their language and one who falls back to the root on the first click.

MISTAKES TO AVOID

  • ×Duplicating pages per language (`pages/fr/index.astro`, `pages/en/index.astro`…) instead of one template with rewrite — you multiply maintenance by 6 and the translations drift apart.
  • ×Writing raw hrefs (`/cuenta`) under a locale prefix: you drop the user out of their language; every link goes through `localizedPath`.
  • ×Detecting the locale from the URL only: during a fallback rewrite the URL loses the prefix; prefer `Astro.currentLocale` (`resolveLocale`).
  • ×Mixing three i18n patterns without a dominant one — MOONKEY already carries debt because of it (5 pages hardcoded in ES that show up for th/fr/it/ch).
  • ×Assuming the meaning of a locale code: 'ch' here is Swiss German, not Chinese; document the ambiguous ones in the config or someone will mistranslate.
BD-05

Deploy: Cloudflare, domains, env

lesson

Take a site from localhost to a real URL on Cloudflare Pages with a custom domain and environment variables managed securely, distinguishing what env goes to the client and what never does.

A project that isn't deployed doesn't exist for anyone. And the deploy is where most secrets leak: the line between a public and a private variable is invisible until your service_role key shows up in the production bundle. Knowing how to deploy well is half of shipping.

THE LESSON

MOONKEY deploys on Cloudflare Pages (`moonkeylab.pages.dev`) from the repo `garciafradepablo-pixel/xop`. The base flow: you connect the GitHub repo to Cloudflare Pages, set the build command (`npm run build`) and the output directory (`dist/` in Astro). Every push to main triggers a build in CI and publishes. You don't upload files by hand: the deploy is a consequence of git push. That gives you reproducibility — what's in production is exactly what's in the commit.

For a pure SSG like MOONKEY, Cloudflare just serves the static HTML from the build; there's no server runtime. This is key to the security model (you'll see it in Ops): since there's no server-side handler, the only env that matters in production is what was injected AT BUILD time. If your app needs server logic (not the case for MOONKEY or content-XNLAB), that's where Cloudflare Functions/Workers come in — but don't add them if you don't need them.

The secrets boundary is THE lesson of this module. In Astro/Vite, `PUBLIC_*` (Astro) or `VITE_*` (pure Vite like Espejo) variables are injected into the client bundle — they're VISIBLE to anyone who opens DevTools. Everything else stays in build. MOONKEY puts the Supabase anon key as public on purpose: CLAUDE.md says 'anon key only' in `src/lib/supabase.ts`, because the anon key is DESIGNED to be public (its power is limited by RLS, not by secrecy). The absolute rule: the `service_role` key NEVER carries a public prefix, NEVER goes to a static site. If you put it there, it's in the bundle, and it's game over.

On Cloudflare you configure the env vars in the dashboard (Settings → Environment variables), separating Production from Preview. The ones your build needs to expose to the client carry the correct prefix; the ones only the build process uses (third-party API tokens to generate content) don't carry it and don't end up in `dist/`. Mandatory post-deploy verification: download your production JS and run `grep -ri 'service_role\|secret\|sk_'` over `dist/`. If anything shows up, you have a leak to rotate immediately.

Custom domain: in Cloudflare Pages, Custom domains → add your domain, Cloudflare creates the DNS records (if the domain is on Cloudflare it's automatic; if not, you add a CNAME to the `*.pages.dev`). HTTPS is automatic. The `.pages.dev` keeps working as a backup URL. For previews, each PR/branch generates its own ephemeral URL — use them to review changes before merging to main, without touching production.

Real deploy failure modes: (1) the build passes locally but fails in CI because a dependency was in your local `node_modules` but not in `package.json` — run `npm ci` (not `install`) on a clean tree to reproduce CI. (2) Variables that exist locally (`.env`) but you didn't configure in the dashboard → the build passes but the app fails at runtime with undefined. (3) The cosmetic Astro warning about `Astro.request.headers` in static builds with i18n rewrite — `astro.config.mjs` documents it as harmless, the build passes. Don't chase warnings the config already flagged as expected.

EXERCISE

Deploy an Astro site to Cloudflare Pages from a GitHub repo: configure build command `npm run build`, output `dist/`, and one `PUBLIC_API_BASE` variable and a NON-public `BUILD_TOKEN`. After the deploy, download the production JS and prove with `grep` that `PUBLIC_API_BASE` appears in the bundle and `BUILD_TOKEN` does NOT. Add a domain (or subdomain) and verify HTTPS.

DELIVERABLE

A live public URL plus a `DEPLOY.md` with: the build config, a table of which variable is public and why, and the `grep` log proving the build token didn't leak to the client.

KEY INSIGHT

The deploy isn't 'uploading files', it's freezing a commit at a URL with reproducible CI. And the only irreparable error is about secrets: since a static site has no server, everything it needs in production is injected at build — that's why the anon key (limited by RLS) can be public and service_role never. The `grep` over `dist/` is your last net before leaking.

MISTAKES TO AVOID

  • ×Putting a private key (service_role, an API secret) with a `PUBLIC_`/`VITE_` prefix or on a static site — it lands in the visible bundle; only keys designed to be public (an anon key limited by RLS) go to the client.
  • ×Uploading the build by hand instead of connecting the repo: you lose reproducibility and don't know which commit is in production.
  • ×Configuring env vars in local `.env` but forgetting them in the Cloudflare dashboard → the build passes and the app blows up at runtime.
  • ×Using `npm install` to reproduce CI; use `npm ci` on a clean tree, which respects the lockfile and reproduces the real dependency failure.
  • ×Not verifying with `grep` over `dist/` after the deploy: a secret leak is silent until someone finds it.
BD-06

From localStorage to backend

lesson

Migrate the data layer from localStorage to a backend (Supabase) without rewriting the application, leveraging the Store seam so the change is an implementation swap, not a refactor.

Every app starts with local data to move fast, but localStorage doesn't share across devices, doesn't truly persist, and has no real security. The jump to a backend is inevitable — and if your architecture didn't anticipate it, that jump is a painful rewrite. With the right seam, it's an afternoon.

THE LESSON

Recall the seam from BD-03: in Espejo `src/brand/store.ts` defines a `Store` interface with async signatures and `LocalStore` implements it over localStorage. The comment is the module's entire thesis: 'Today: localStorage. Tomorrow: Supabase (same signatures).' Because the interface is ALREADY async (`Promise<Brand | null>`), the UI doesn't notice the difference between reading from memory or from the network — the `await` is already there. A synchronous API would have made the migration impossible without touching every call. Designing the seam async from day one is the decision that pays off here.

The concrete swap: you write a `SupabaseStore implements Store` class. `getBrand(slug)` goes from reading localStorage to `await supabase.from('brands').select().eq('slug', slug).single()`. `addSubscriber` goes from `write()` to `.insert()`. The signatures are IDENTICAL. In the end, one line changes the world: `export const store: Store = new SupabaseStore()` instead of `new LocalStore()`. Zero components touched. That's what 'changing the data layer without rewriting the app' means: the rest of the code depends on `Store`, not on localStorage.

But migrating the storage is the easy half. The serious half is SECURITY, and here the whole model changes. In localStorage there are no boundaries: everything lives in the user's browser. In Supabase, since the client only carries the anon key (MOONKEY: `src/lib/supabase.ts`, 'anon key only'), the authority is NOT the client — it's Postgres RLS. MOONKEY puts it without ambiguity: 'authority = RLS, not the client'. When Espejo migrates the subscribers, the `WHERE brandSlug = slug` filter it does today in JS MUST become an RLS policy guaranteeing that a tenant only reads ITS subscribers. The client filter was convenience; RLS is security.

Reference RLS pattern in MOONKEY: the `profiles/progress/feedback/proofs` tables have a 'self-or-admin' SELECT via `is_admin()` (a SECURITY DEFINER function), verified by impersonation — a non-admin can't read other people's rows. `proofs` INSERT requires a session and ties `user_id = auth.uid()` (the `proofs_insert_self` policy). Sensitive columns (`role`, `founder_badge`) are immutable for non-admins via a trigger guard, so no one self-escalates. That's the mental template for any migration: every operation (SELECT/INSERT/UPDATE) needs a policy that holds EVEN IF the attacker controls the client.

Data-migration hygiene: you need a schema (a `brands` table, a `subscribers` table with `brand_slug`), applied as a versioned MIGRATION (not loose SQL in the console — MOONKEY: 'RLS/security changes: versioned migration + `get_advisors` afterward'). Then a one-time script that reads the existing localStorage and does a bulk `insert` to the backend, idempotent (so you can re-run it without duplicating). And an optional double-write period if you can't stop the app: you write to both until you confirm parity, then cut over off localStorage.

Watch the ethical boundary Espejo carries: the project memory forbids covert siphoning of personal/biometric data; only consented first-party data and anonymized aggregates. Migrating to a backend INCREASES the power of the data (now it persists, gets cross-referenced, gets exported), so the consent model that in localStorage was almost theoretical becomes real and auditable. The technical migration and the responsibility over the data rise together — don't migrate the storage without also migrating the privacy guarantees.

EXERCISE

Take the `white-label-seam` from BD-03 (Store interface + LocalStore). Write a `SupabaseStore implements Store` with the SAME signatures against a real Supabase table. Migrate by changing ONE line (`new LocalStore()` → `new SupabaseStore()`) without touching components. Apply an RLS policy guaranteeing that `listItems(slug)` only returns rows of the correct tenant, and verify it by impersonating another user.

DELIVERABLE

A `store-migration/` folder with `LocalStore` and `SupabaseStore` side by side, the ONE-line diff that does the swap, the versioned SQL migration with the RLS policy, and a log proving that one tenant CANNOT read another's data (impersonation test).

KEY INSIGHT

The Store seam turns a backend migration from a rewrite into a one-line swap — but only if the interface was async from the start. And the part that really matters isn't moving the bytes: it's that when you move to a client with an anon key, authority shifts from the app to Postgres RLS. The slug filter you had in JS wasn't security; now it has to be, in the database.

MISTAKES TO AVOID

  • ×Designing the Store interface synchronous and discovering at migration time that every call needs `await` — make it async (Promise) from day one even if localStorage doesn't require it.
  • ×Believing that migrating the storage is the work: the serious part is moving the isolation from the JS client filter to RLS policies in Postgres ('authority = RLS, not the client').
  • ×Applying the schema and policies as loose SQL in the console instead of versioned migrations + `get_advisors` afterward.
  • ×A non-idempotent data-migration script: if you can't re-run it without duplicating, an interruption leaves you with corrupted data.
  • ×Migrating to a backend without strengthening consent: persisting and cross-referencing personal data raises the risk; in Espejo the boundary is consented first-party data, no covert siphoning.

Next constellation

Signal

Quant & research