🌙

Dream Catcher

System Architecture & Technical Reference

Version 1.0 June 2026 Dream Catcher

Contents

  1. Architecture Overview
  2. Netlify Hosting
  3. Google SSO & Authentication
  4. Supabase Layers
    • 4.1 Auth & Session tokens
    • 4.2 PostgreSQL database
    • 4.3 Storage bucket
    • 4.4 Edge Functions runtime
  5. Edge Functions in Depth
    • 5.1 transcribe-audio
    • 5.2 extract-scenes
    • 5.3 generate-image
  6. Full API Call Journey
  7. Database Security & RLS
  8. Secrets & Environment Variables
  9. Integration Summary Table

1. Architecture Overview

Dream Catcher is a static web application hosted on Netlify with zero backend servers. All dynamic compute runs inside Supabase Edge Functions — short-lived Deno processes that proxy calls to external AI APIs (Anthropic Claude and OpenAI) while keeping secret keys out of the browser.

┌──────────────────────────────────────────────────────────────────────────────┐ │ DREAM CATCHER SYSTEM │ └──────────────────────────────────────────────────────────────────────────────┘ BROWSER (Netlify CDN) ┌────────────────────────────────────────────────────────────────────────┐ │ index.html style.css app.js api.js auth.js db.js config.js │ │ │ │ MediaRecorder API ──► audio blob │ │ Supabase JS SDK ──► auth tokens, DB queries │ └────────────┬───────────────────────┬──────────────────────────────────┘ │ HTTPS │ HTTPS (Supabase REST) ▼ ▼ ┌────────────────────┐ ┌──────────────────────────────────────────────┐ │ Google OAuth 2.0 │ │ SUPABASE │ │ (consent screen) │ │ │ │ │ │ ┌──────────────┐ ┌──────────────────────┐ │ │ Redirect back │──►│ │ Auth Service│ │ PostgreSQL DB │ │ │ with tokens │ │ │ (JWT issue) │ │ │ │ └────────────────────┘ │ └──────────────┘ │ dreams table │ │ │ │ profiles table │ │ │ ┌──────────────┐ └──────────────────────┘ │ │ │ Storage │ │ │ │ Bucket │ ┌──────────────────────┐ │ │ │ dream-catcher│ │ Edge Functions │ │ │ │ -db/ │ │ (Deno runtime) │ │ │ └──────┬───────┘ │ │ │ │ ▲ │ transcribe-audio │ │ │ images │ │ extract-scenes │ │ │ stored │ │ generate-image │ │ └─────────┼───────────┴──────┬──────────────┘ │ │ │ HTTPS (API keys) │ ┌────────┴──────────────┐ │ │ EXTERNAL AI APIS │ │ │ │ │ │ Anthropic Claude │ │ │ (extract-scenes) │ │ │ │ │ │ OpenAI Whisper │ │ │ (transcribe-audio) │ │ │ │ └─────────│ OpenAI gpt-image-1 │ images │ (generate-image) │ uploaded └───────────────────────┘

Figure 1 — High-level system architecture

Key Design Principles

2. Netlify Hosting

Netlify serves the entire repository root as a static site. There is no build step — the HTML, CSS, and JS files are shipped exactly as authored.

netlify.toml

[build]
  publish = "."          # Serve the repo root directly

[[headers]]
  for = "/*"
  [headers.values]
    X-Frame-Options = "DENY"
    X-Content-Type-Options = "nosniff"
    Referrer-Policy = "strict-origin-when-cross-origin"

What Netlify provides

CapabilityDetail
CDN distributionStatic assets served from edge nodes globally
HTTPSAutomatic TLS certificate via Let's Encrypt
Deploy previewsEvery git push creates a preview URL
Security headersClickjack protection, MIME sniff prevention, referrer control
Redirects / rewritesNot currently used; app is single-page
No Netlify Functions. All serverless compute uses Supabase Edge Functions, not Netlify Functions. This keeps AI API keys inside Supabase's secret management instead of Netlify's environment variables.

3. Google SSO & Authentication

Authentication is delegated entirely to Supabase Auth, which acts as an OAuth 2.0 proxy in front of Google. The browser never handles raw OAuth tokens directly.

Login flow (step by step)

1User clicks Sign in with Google. auth.js calls supabase.auth.signInWithOAuth({ provider: 'google' }).
2Supabase Auth redirects the browser to Google's OAuth consent screen.
3User approves. Google redirects back to window.location.href (the app URL) with an authorization code in the URL fragment.
4Supabase JS SDK intercepts the fragment, exchanges the code for Google access/refresh tokens, then issues its own signed JWT (the Supabase session token).
5The Supabase session is persisted in localStorage automatically. The SDK refreshes it silently before it expires.
6onAuthStateChange fires a SIGNED_IN event. auth.js hides the login screen and shows the app.
7DB.upsertProfile(user) creates or updates the profiles row with display_name and avatar_url from Google's user metadata.
8The app loads the user's dreams and appearance profile from Supabase Postgres.
Browser Supabase Auth Google OAuth │ │ │ │ signInWithOAuth() │ │ │─────────────────────────►│ │ │ │ redirect to consent │ │◄─────────────────────────┤─────────────────────────►│ │ │ │ user approves │ redirect back + code │◄─────────────────────────┤ │◄─────────────────────────┤ │ │ SDK exchanges code │ │ │─────────────────────────►│ │ │ │ issues Supabase JWT │ │◄─────────────────────────┤ │ │ stores JWT in │ │ │ localStorage │ │ │ onAuthStateChange fires │ │

Figure 2 — Google OAuth 2.0 via Supabase Auth proxy

Session token usage

After login, every call to a Supabase Edge Function includes the token in the Authorization header:

Authorization: Bearer eyJhbGciOiJIUzI1NiIs...

The Edge Function passes this token to supabase.auth.getUser(token) to validate it. If invalid or missing, the function returns 401 Unauthorized immediately.

Logout flow

Auth.signOut() calls supabase.auth.signOut(), which clears the stored JWT from localStorage. The onAuthStateChange SIGNED_OUT event then shows the login screen.

4. Supabase Layers

Supabase provides four distinct services that Dream Catcher uses. Each is accessed differently and has its own security boundary.

4.1 Auth Service

Supabase Auth manages JWTs. The browser-side Supabase JS client handles token storage, refresh, and forwarding. The JWT contains the user's sub (UUID), which becomes auth.uid() inside PostgreSQL RLS policies.

4.2 PostgreSQL Database

The database has two application tables:

Table: public.dreams

ColumnTypeDescription
idVARCHAR / UUIDEntry ID, generated client-side
user_idUUIDFK to auth.users — enforces ownership
dateDATEDream date (YYYY-MM-DD)
transcriptTEXTFull dream text, cleaned by Claude
scenesJSONBArray of scene descriptions
style_keyVARCHARVisual style used for generation
created_atTIMESTAMPTZISO 8601 timestamp

Table: public.profiles

ColumnTypeDescription
idUUIDPK, FK to auth.users
display_nameVARCHARFull name from Google
avatar_urlVARCHARProfile photo URL from Google
appearance_promptTEXTUser's visual appearance description (nullable)

The browser queries both tables using the Supabase REST API with the anon key. All reads and writes are filtered by RLS policies (see Section 7).

4.3 Storage Bucket

All generated images are stored in the dream-catcher-db bucket.

dream-catcher-db/ {user_id}/ {entry_id}/ scene-0.png ← generated fresh (no reference) scene-1.png ← generated with scene-0 as reference scene-2.png ← generated with scene-0 as reference ...
OperationWho can do itAuth method
Read (view images)Anyone — bucket is publicNo auth required
Write (upload images)Edge Function onlyService Role Key (bypasses RLS)
DeleteNot currently exposed

4.4 Edge Functions Runtime

Edge Functions run in Supabase's managed Deno environment. They are deployed with the Supabase CLI and execute on-demand. Each function is stateless — no shared memory between invocations.

Three functions are deployed:

Each function is invoked via a POST request to https://<project>.supabase.co/functions/v1/<name>.

5. Edge Functions in Depth

All three functions share a common pattern: validate the Supabase JWT, call an external API using a secret key stored in the Supabase environment, and return a JSON response.

Common auth guard: Every function begins with:
const { data: { user } } = await supabase.auth.getUser(token)
If user is null, the function returns 401 and stops.

5.1 — transcribe-audio

Purpose: Converts a voice recording into text using OpenAI Whisper.

Inputmultipart/form-data with field audio (webm / m4a / ogg)
Output{ "text": "I was walking through a forest..." }
External APIOpenAI POST /v1/audio/transcriptions — model: whisper-1, language: en
Secret usedOPENAI_API_KEY
Error codes401 no auth, 400 no audio file, 500 API failure
Browser transcribe-audio OpenAI Whisper │ │ │ │ POST /functions/v1/ │ │ │ transcribe-audio │ │ │ (FormData: audio file) │ │ │ Authorization: Bearer JWT │ │ │─────────────────────────────►│ │ │ │ validate JWT │ │ │ extract audio file │ │ │ │ │ │ POST /v1/audio/ │ │ │ transcriptions │ │ │ model: whisper-1 │ │ │─────────────────────────►│ │ │ { "text": "..." } │ │ │◄─────────────────────────│ │ { "text": "..." } │ │ │◄─────────────────────────────│ │

Figure 3 — transcribe-audio call flow

5.2 — extract-scenes

Purpose: Uses Claude to extract up to 6 cinematic scenes from a dream transcript and return a cleaned version of the text.

Input{ "transcript": "..." }
Output{ "scenes": ["...", "..."], "cleanedTranscript": "..." }
External APIAnthropic Claude POST /v1/messages — model: claude-opus-4-8
Secret usedCLAUDE_API_KEY
Max scenes6 (Claude decides based on dream complexity)
Error codes401 no auth, 400 no transcript, 500 API failure

Prompt structure

System: You are a visual storyteller and editor. Given a dream transcript, do two things:
  1. Extract the most visually distinct scenes (up to 6). Each scene is one vivid
     cinematic sentence describing setting, subject, light, and mood.
  2. Rewrite the transcript in the dreamer's own voice — fix grammar and spelling
     but preserve their perspective and content.
  Return ONLY a valid JSON object with no markdown or code fences:
  {"scenes": ["...", "..."], "cleanedTranscript": "..."}

User: {transcript}
Browser extract-scenes Anthropic Claude │ │ │ │ POST /functions/v1/ │ │ │ extract-scenes │ │ │ { transcript: "..." } │ │ │ Authorization: Bearer JWT │ │ │─────────────────────────────►│ │ │ │ validate JWT │ │ │ build prompt │ │ │ │ │ │ POST /v1/messages │ │ │ model: claude-opus-4-8 │ │ │─────────────────────────►│ │ │ JSON: scenes + cleaned │ │ │◄─────────────────────────│ │ { scenes, cleanedTranscript}│ │ │◄─────────────────────────────│ │

Figure 4 — extract-scenes call flow

5.3 — generate-image

Purpose: Generates a cinematic image for one scene and uploads it to Supabase Storage. This function is called once per scene.

Input { prompt, entryId, sceneIndex, appearancePrompt, referenceImageUrl? }
Output{ "url": "https://...supabase.co/storage/v1/object/public/..." }
External API (scene 0)OpenAI POST /v1/images/generations — model: gpt-image-1, 1024×1024, quality: low
External API (scenes 1+)OpenAI POST /v1/images/edits — same model, uses scene-0 as visual reference
Secrets usedOPENAI_API_KEY, SUPABASE_SERVICE_ROLE_KEY
Error codes401 no auth, 400 missing params, 500 API/upload failure

Prompt construction (inside the function)

final_prompt = {scene}
             + {appearancePrompt}
             + {style.suffix}

The style.suffix is a multi-sentence cinematic descriptor defined in api.js under the STYLES object. It covers lighting, camera style, artistic mood, and negative constraints specific to the chosen visual style.

Reference image strategy

Scene 0 is always generated fresh (no reference). All subsequent scenes pass scene 0's image as referenceImageUrl and use the /v1/images/edits endpoint with input_fidelity: high. This maintains character and visual consistency across the full dream sequence.

Browser generate-image OpenAI Supabase Storage │ │ │ │ │ scene 0 ─────────►│ │ │ │ (no reference) │ POST /images/ │ │ │ │ generations │ │ │ │─────────────────────►│ │ │ │ base64 PNG │ │ │ │◄─────────────────────│ │ │ │ decode + upload │ │ │ │ (service role key) │ │ │ │─────────────────────────────────────────────►│ │ │ │ public URL │ │ │◄─────────────────────────────────────────────│ │ { url: "..." } ◄─│ │ │ │ │ │ │ │ scene 1+ ────────►│ │ │ │ (reference=scene0) │ POST /images/edits │ │ │ │ input: scene-0 image │ │ │ │─────────────────────►│ │ │ │ base64 PNG │ │ │ │◄─────────────────────│ │ │ │ upload │ │ │ │─────────────────────────────────────────────►│ │ { url: "..." } ◄─│ │ public URL │

Figure 5 — generate-image call flow (scene 0 vs scenes 1+)

6. Full API Call Journey

This section traces a complete dream recording session from microphone tap to rendered notebook page.

┌─────────────────────────────────────────────────────────────────────────────┐ │ COMPLETE DREAM RECORDING SESSION │ └─────────────────────────────────────────────────────────────────────────────┘ PHASE 1 — RECORD ───────────────── User taps Record └► MediaRecorder starts (browser mic) └► Audio chunks accumulate in memory User taps Stop └► Blob assembled (webm/m4a/ogg) └► POST /functions/v1/transcribe-audio [FormData: audio] └► Supabase validates JWT └► OpenAI Whisper → raw transcript text └► Returns { text: "..." } └► Text displayed in bottom sheet for review PHASE 2 — EXTRACT ────────────────── User taps Visualize └► generateEntryId() → UUID stored in state.currentEntryId └► POST /functions/v1/extract-scenes [{ transcript }] └► Supabase validates JWT └► Claude claude-opus-4-8 extracts 1-6 scenes + cleans transcript └► Returns { scenes: [...], cleanedTranscript: "..." } └► Scenes stored in state.currentScenes PHASE 3 — STYLE SELECTION ────────────────────────── First-time user: └► 5 parallel image generations (one per style, scene[0] only) └► User sees 5 preview cards, picks a style └► Preference saved to localStorage: 'preferredDreamStyle' Returning user: └► Read preferredDreamStyle from localStorage → skip picker PHASE 4 — IMAGE GENERATION (per scene, sequential) ─────────────────────────────────────────────────── For scene 0: └► buildPrompt(scene[0], styleKey, appearancePrompt) └► POST /functions/v1/generate-image [{ prompt, entryId, sceneIndex: 0 }] └► Supabase validates JWT └► OpenAI gpt-image-1 POST /v1/images/generations └► Base64 PNG decoded └► Uploaded to Storage: dream-catcher-db/{userId}/{entryId}/scene-0.png (using service role key — bypasses RLS) └► Returns { url: "https://...scene-0.png" } For each remaining scene (1, 2, 3...): └► POST /functions/v1/generate-image [{ ..., sceneIndex: i, referenceImageUrl: scene0url }] └► OpenAI gpt-image-1 POST /v1/images/edits (scene-0 as reference) └► Upload to Storage: dream-catcher-db/{userId}/{entryId}/scene-N.png └► Returns { url: "https://...scene-N.png" } PHASE 5 — PERSIST ────────────────── After last image resolves: └► DB.saveDream({ id, user_id, date, transcript, scenes, style_key, created_at }) └► Supabase REST API UPSERT into dreams table (anon key + RLS: user can only write own rows) └► Dream appears in home feed and calendar

Figure 6 — Complete API call journey for a dream session

Browser-side call chain (code layer)

app.js: stopRecording()
  └► api.js: transcribeAudio(blob, mimeType)
       └► fetch(SUPABASE_URL + '/functions/v1/transcribe-audio', { FormData })

app.js: startVisualization(transcript)
  └► api.js: extractScenes(transcript)
       └► _callEdgeFunction('extract-scenes', { transcript })
  └► app.js: generateSceneRange(scenes, 0, styleKey, appearancePrompt)
       └── for each scene i:
           └► api.js: generateImage(scene, styleKey, entryId, i, appearance, refUrl)
                └► _callEdgeFunction('generate-image', { prompt, entryId, sceneIndex, ... })
  └► db.js: saveDream(entry)
       └► supabase.from('dreams').upsert({ ... })

7. Database Security & Row-Level Security

The Supabase anon key is visible in config.js (a public file). This is safe because Row-Level Security (RLS) policies enforce data isolation at the database level — not the application level. Even if someone extracts the anon key, they can only access data that the policies permit.

How RLS works

PostgreSQL evaluates RLS policies before returning or modifying any row. The auth.uid() function returns the UUID from the current JWT. If no valid JWT is present, auth.uid() returns null, which matches no user_id.

RLS policies

dreams table

OperationPolicy conditionEffect
SELECTauth.uid() = user_idUsers see only their own dreams
INSERTauth.uid() = user_idUsers can only create rows for themselves
UPDATEauth.uid() = user_idUsers can only edit their own dreams
DELETEauth.uid() = user_idUsers can only delete their own dreams

profiles table

OperationPolicy conditionEffect
SELECTNo restriction (public)Any authenticated user can read profiles (needed for shared feed)
INSERTauth.uid() = idUsers can only create their own profile row
UPDATEauth.uid() = idUsers can only update their own profile

Shared feed — reading other users' dreams

DB.getAllDreams(limit) fetches dreams from all users, which requires the dreams SELECT policy to allow cross-user reads. In practice this means the policy must either be relaxed for SELECT or the query uses a server-side role. This is the one area where RLS policy design has a trade-off between isolation and social features.

Trade-off to note: Enabling public SELECT on the dreams table (to power the shared feed) means all dreams are visible to any authenticated user. If private dreams are needed in the future, a is_public flag and an updated RLS policy would gate visibility.

Storage security

The dream-catcher-db bucket is configured as public. Image URLs are shared openly in the feed without requiring auth tokens. Writes are blocked for the anon key — only the service role key (held only in Edge Functions) can upload.

Key access matrix

KeyWhere storedWhat it can doRLS enforced?
Anon Key config.js (browser) Query DB, auth flows Yes — full RLS applies
Service Role Key Supabase Edge Function secrets Upload to Storage, full DB access No — bypasses RLS
Claude API Key Supabase Edge Function secrets Call Anthropic API N/A
OpenAI API Key Supabase Edge Function secrets Call Whisper + gpt-image-1 N/A
Security guarantee: API keys (Claude, OpenAI) never leave Supabase's server environment. The browser receives only final outputs — transcribed text, extracted scenes, and public image URLs.

8. Secrets & Environment Variables

Browser-visible (config.js)

These are intentionally public. The anon key is a read-only credential with RLS enforced.

SUPABASE_URL      = "https://[project-id].supabase.co"
SUPABASE_ANON_KEY = "eyJhbGciOiJIUzI1NiIs..."   // public, safe to expose

Edge Function secrets (never browser-visible)

Set via supabase secrets set KEY=value and stored encrypted in Supabase's secret management. Injected as environment variables into the Deno runtime at invocation.

VariableUsed byPurpose
OPENAI_API_KEYtranscribe-audio, generate-imageAuthenticate to OpenAI (Whisper + gpt-image-1)
CLAUDE_API_KEYextract-scenesAuthenticate to Anthropic Claude
SUPABASE_SERVICE_ROLE_KEYgenerate-imageUpload images to Storage (bypasses RLS)
SUPABASE_URLAll three functionsConnect to Supabase for auth validation
SUPABASE_ANON_KEYAll three functionsCreate Supabase client for auth.getUser()

9. Integration Summary

Service Function Model / Endpoint Called from Auth
Supabase Auth Login / session Google OAuth 2.0 proxy auth.js (browser) Google account
Supabase Postgres Store dreams + profiles REST API (dreams, profiles) db.js (browser) Anon key + RLS
Supabase Storage Host generated images dream-catcher-db bucket generate-image (Edge Fn) Service Role Key
Anthropic Claude Scene extraction claude-opus-4-8 extract-scenes (Edge Fn) CLAUDE_API_KEY
OpenAI Whisper Audio transcription whisper-1 transcribe-audio (Edge Fn) OPENAI_API_KEY
OpenAI Images Image generation gpt-image-1 (generations + edits) generate-image (Edge Fn) OPENAI_API_KEY

Request path summary

TRANSCRIPTION Browser mic → Blob → transcribe-audio (Edge Fn) → Whisper → text SCENE EXTRACTION Transcript → extract-scenes (Edge Fn) → Claude → scenes + cleaned text IMAGE GENERATION (per scene) Scene + style → generate-image (Edge Fn) → gpt-image-1 → PNG → Storage → URL DATABASE READS Browser → Supabase REST API (anon key + RLS) → dreams / profiles DATABASE WRITES Browser → Supabase REST API (anon key + RLS) → dreams / profiles Storage writes → Service Role Key (Edge Fn only) AUTH Browser → Supabase Auth → Google OAuth → JWT → stored in localStorage

Figure 7 — All request paths at a glance

File-to-responsibility map

FileResponsibility
index.htmlApp shell — login screen + main layout
style.cssAll visual styling (dark cinematic theme)
config.jsSupabase URL and anon key (public)
auth.jsGoogle OAuth, session management, auth state changes
db.jsAll Supabase DB and Storage operations
api.jsEdge Function callers, style definitions, prompt builder
app.jsOrchestration, state machine, UI rendering
avatars.jsSVG avatar generation per user
logger.jsSession logging to localStorage for debug view
imagestore.jsIndexedDB wrapper (defined, not actively used)
netlify.tomlNetlify static hosting config + security headers
supabase/functions/transcribe-audio/Deno: Whisper audio transcription
supabase/functions/extract-scenes/Deno: Claude scene extraction
supabase/functions/generate-image/Deno: gpt-image-1 generation + storage upload

Part II

The Why Behind the How

A deep technical explainer — how every layer of this system actually works, from first principles, with every new term defined as it appears.

Chapter 1

The Foundation — How HTTPS Keeps Everything Private

Before we can understand how Google sign-in works, or how your JWT travels safely, or why your API keys can't be stolen in transit, we need to understand one thing that underlies all of it: HTTPS and TLS.

When you type a URL and hit enter, your request travels as electrical signals and radio waves through cables, routers, and wireless networks before it reaches any server. Those signals pass through infrastructure owned by your ISP (Internet Service Provider — the company providing your internet connection), data centers, and sometimes hardware owned by unknown parties. Without protection, anyone with access to any intermediate point could read your data in plain text, or modify it before it reaches you.

HTTPS stands for HyperText Transfer Protocol Secure. The "Secure" part means the connection is encrypted using a protocol called TLS — Transport Layer Security. TLS is a cryptographic handshake that happens before any real data is exchanged.

The TLS Handshake — what happens in the first 100 milliseconds

1 Your browser says hello. It sends a "ClientHello" message listing the encryption algorithms it supports and a random number it just generated.
2 The server responds. It sends back its chosen encryption algorithm, another random number, and most importantly — its SSL certificate.
3 Your browser checks the certificate. An SSL certificate is a digital document that says: "This server is really [project-id].supabase.co, and here is a cryptographic proof signed by a trusted authority." Your browser has a pre-installed list of trusted authorities — called Certificate Authorities (CAs) — like DigiCert, Let's Encrypt, and Comodo. If the certificate is signed by one of them, and the domain matches, the browser trusts it.
4 They agree on a shared secret. Using a technique called key exchange, the browser and server mathematically derive the same encryption key — without ever transmitting that key across the network. This sounds impossible but is the fundamental magic of modern cryptography. The algorithm most commonly used is called Diffie-Hellman key exchange.
5 All further communication is encrypted. Every byte from this point — the HTTP request, the response, the headers, everything — is scrambled using that shared key. Anyone intercepting the traffic sees only random-looking noise.
Think of it like this: you and a stranger need to agree on a secret password in a public place where anyone can overhear you. You each pick a secret number, do some math with a shared public number, exchange the result out loud, and each do some more private math with your secret number. You both arrive at the same final value — and no one listening can figure it out from what was said publicly. That's Diffie-Hellman in plain English.

This is the foundation that makes everything else safe. When the rest of this document says "the request travels over HTTPS," it means this entire mechanism is in place for every single request — auth codes, JWTs, database queries, API calls, image uploads. No one in the middle can read or modify any of it.

Chapter 2

Google Sign-In — What Actually Happens, Step by Step

The protocol behind "Sign in with Google" is called OAuth 2.0 — an open standard for letting one application (Dream Catcher) ask another application (Google) to confirm a user's identity, without Dream Catcher ever seeing the user's Google password. Supabase acts as the middleman, handling the OAuth dance so the browser JavaScript never has to manage raw tokens.

Step 1 — The button click triggers a redirect

When you click "Sign in with Google," auth.js calls supabase.auth.signInWithOAuth({ provider: 'google' }). The Supabase SDK constructs a URL and redirects your browser to it. That URL is at Google's servers and looks roughly like this:

https://accounts.google.com/o/oauth2/v2/auth
  ?client_id=123456789.apps.googleusercontent.com
  &redirect_uri=https://[project-id].supabase.co/auth/v1/callback
  &response_type=code
  &scope=email+profile
  &state=xK9zPm2qRs...

Let's break down every parameter:

Step 2 — What Google checks

Your browser is now fully on Google's servers. Google renders its consent screen. Meanwhile, Google is checking:

When you click "Allow," Google generates an authorization code — a random string, single-use, valid for about 60 seconds. Google redirects your browser to the redirect_uri:

https://[project-id].supabase.co/auth/v1/callback
  ?code=4/0AX4XfWh3k9z...
  &state=xK9zPm2qRs...
Why a code instead of the actual token? The redirect URL appears in your browser address bar, in server logs, in browser history, and potentially leaks through the HTTP Referer header. A full token in a URL is a security hole — it can be logged anywhere. An authorization code is useless on its own: it must be exchanged for a token by something that also knows the client secret, which the browser never has. The code is just a one-time claim ticket.

Step 3 — Supabase completes the token exchange (the browser watches but can't participate)

Supabase's auth servers receive that callback redirect. Now Supabase makes a server-to-server call directly to Google — no browser involved:

POST https://oauth2.googleapis.com/token
  code=4/0AX4XfWh3k9z...
  client_id=123456789.apps.googleusercontent.com
  client_secret=GOCSPX-...           ← secret, stored only in Supabase
  redirect_uri=https://...supabase.co/auth/v1/callback
  grant_type=authorization_code

The client_secret is what proves to Google that this request is genuinely from Dream Catcher's backend, not from an impostor who intercepted the code. This secret lives only in Supabase's server configuration — it was never in the browser, never in your HTML files, never in your JavaScript.

Google responds with three tokens:

Supabase uses the access token to call https://www.googleapis.com/oauth2/v3/userinfo and get your name, email, and profile photo. Then Supabase creates or updates an entry for you in its own auth.users table and issues its own JWT — the Supabase session token — signed with Supabase's own secret key.

Step 4 — The token returns to the browser, safely

Supabase now redirects the browser back to the app — specifically to the redirectTo URL originally passed (the Netlify site URL). The Supabase session token is attached as a URL fragment:

https://your-app.netlify.app/#access_token=eyJhbGciOiJIUzI1NiIs...&token_type=bearer&...

The # symbol marks a fragment. Fragments are fundamentally different from query parameters (which come after ?). When your browser makes an HTTP request, the server receives the path and query string but never the fragment — the browser strips it before sending. Netlify's servers never see the token. Only the JavaScript running in your browser tab can read it.

The Supabase JS SDK detects the fragment on page load, extracts the token, stores it in localStorage, and fires onAuthStateChange with a SIGNED_IN event. auth.js receives that event and shows the home screen.

Could anyone intercept any of this?

Browser Supabase Auth Google │ │ │ │ signInWithOAuth() │ │ │─────────────────────────►│ │ │ redirect to Google │ │ │◄─────────────────────────│ │ │ │ │──────────── browser navigates to Google ───────────►│ │ │ checks credentials │ │ shows consent screen │◄──────────────── redirect + auth code ──────────────│ │ │ │ │───── callback hits Supabase (not browser) ─────────►│ │ │ code + client_secret │ │ │─────────────────────────►│ │ │ access_token + JWT │ │ │◄─────────────────────────│ │ │ fetch user profile │ │ │─────────────────────────►│ │ │ { name, email, photo } │ │ │◄─────────────────────────│ │ │ issue Supabase JWT │ │◄── redirect back to app │ │ │ with JWT in #fragment │ │ │ │ │ │ SDK reads fragment, │ │ │ stores in localStorage │ │ │ onAuthStateChange fires │ │

Figure 8 — Google OAuth 2.0 full flow with Supabase as auth proxy

Chapter 3

What Is a JWT and How Does Verification Work?

Every time Dream Catcher calls a Supabase Edge Function, it includes a token in the request header. That token is a JWT — JSON Web Token, pronounced "jot." Understanding what a JWT is and how it's verified is key to understanding why Edge Functions trust it without calling Google or doing a database lookup.

Structure — three parts separated by dots

A JWT looks like this:

eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9
.eyJzdWIiOiJhMWIyYzNkNC0uLi4iLCJlbWFpbCI6ImhhcnByZWV0QC4uLiIsInJvbGUiOiJhdXRoZW50aWNhdGVkIiwiaWF0IjoxNzE4MDAwMDAwLCJleHAiOjE3MTgwMDM2MDB9
.SflKxwRJSMeKKF2QT4fwpMeJf36POk6yJV_adQssw5c

Three parts, each Base64-encoded (a way of converting binary data into printable ASCII characters so it can travel inside HTTP headers without corruption). Decode them and you get:

Part 1 — Header

{
  "alg": "HS256",    // the signing algorithm: HMAC-SHA256
  "typ": "JWT"       // this is a JWT
}

Part 2 — Payload (the claims)

{
  "sub":   "a1b2c3d4-e5f6-...",   // subject: the user's UUID
  "email": "you@example.com",
  "role":  "authenticated",
  "iat":   1718000000,             // issued at (Unix epoch: seconds since Jan 1 1970)
  "exp":   1718003600              // expires at (1 hour later)
}

Part 3 — Signature

The signature is computed by Supabase's servers at the moment the token is created:

HMAC_SHA256(
  key    = SUPABASE_JWT_SECRET,        // a secret only Supabase knows
  data   = base64(header) + "." + base64(payload)
)

HMAC-SHA256 is a cryptographic function. Given the same key and data, it always produces the same output — a fixed-length string of bytes. Change even one character in the data, and the output changes completely and unpredictably.

How an Edge Function verifies a JWT — no database lookup needed

When generate-image receives a request with a JWT, it does this:

1 Split the token on the two dots. Extract header, payload, and signature.
2 Run HMAC_SHA256(SUPABASE_JWT_SECRET, header + "." + payload). The Supabase JWT secret is injected into the Edge Function's environment — it's the same secret Supabase used when the token was created.
3 Compare the result to the signature in the token. If they match: the payload is authentic and untampered. If they don't match: the token was forged or modified. Reject with 401.
4 Check the exp claim. If the current time is past the expiry: reject with 401. The Supabase SDK in the browser silently refreshes tokens before they expire using a refresh token, so legitimate users never notice this.
5 Read the sub claim to get the user's UUID. This UUID is now trusted — it was baked into the token by Supabase and cannot be changed without invalidating the signature.
Why is this faster than a database lookup? Verifying a JWT is pure math — a local computation that takes microseconds. A database lookup means a network call, query execution, and waiting for a response. For an Edge Function that might run thousands of times per minute, the difference is significant. JWTs were designed precisely so authorization checks can be stateless and local.

What happens if someone tampers with the payload?

Suppose an attacker receives a legitimate JWT and tries to change the sub field to another user's UUID. They can Base64-decode the payload, edit the JSON, and Base64-encode it again. But when the Edge Function recomputes the signature using the modified payload, it gets a completely different value. It won't match the original signature. The token is rejected.

The attacker cannot forge a valid signature without knowing the SUPABASE_JWT_SECRET, which lives only in Supabase's server environment — never in the browser, never in your source code.

The token lifecycle

Sign in JWT issued │ │ ▼ ▼ ┌──────────────────────────────────────────────────────────┐ │ access_token (expires in 1 hour) │ │ refresh_token (expires in weeks/months) │ │ Both stored in localStorage by Supabase SDK │ └──────────────────────────────────────────────────────────┘ │ │ │ app uses access_token │ SDK detects │ for API calls │ token nearing expiry │ │ ▼ ▼ Each Edge Function POST /auth/v1/token validates it locally ?grant_type=refresh_token Supabase issues new access_token silently, user never sees this

Figure 9 — JWT lifecycle and silent refresh

Chapter 4

Deno, Edge Functions, and Why Your API Keys Can Never Reach the Browser

This chapter answers a fundamental question: if Dream Catcher has no backend server, how do secret API keys stay secret? The answer is Edge Functions — and to understand them, we need to first understand what the browser can and cannot do.

The browser's limitation with secrets

When you open a website, your browser downloads all the code needed to run it: HTML, CSS, JavaScript. That code executes on your machine, in your browser tab. This is why websites can respond instantly to your clicks without a round trip to a server — the logic is already on your device.

The consequence: anyone can read all the code your browser downloads. Open Chrome DevTools, go to the Sources tab, and you'll see every JavaScript file the site loaded. There is no way to hide code from someone who wants to read it.

This means a secret API key placed anywhere in your JavaScript is exposed. Not just "theoretically accessible" — literally readable by anyone who opens DevTools. If your OpenAI key is in your client-side JavaScript, anyone can open your site, copy the key, and use it to make API calls billed to your account.

What about obfuscating the key — splitting it, encoding it? Security researchers call this "security through obscurity," and it doesn't work. Any code that can be executed by a browser can be read, deobfuscated, and reverse-engineered. The key will eventually be found. The only real solution is to move the key off the browser entirely.

What is a server and why does it solve this?

A server is a computer running code that responds to requests from other computers. When you call an Edge Function from your browser, your request travels to a Supabase-managed computer somewhere in a data center. That computer runs your function code, which has access to environment variables — key-value pairs stored in the server's memory that are never transmitted to the browser.

The browser sends a request: "here is my JWT, here is my image prompt." The server runs the function, makes the OpenAI call internally using the API key (which only the server knows), and returns the result. The browser receives the result. At no point did the API key travel to the browser or appear in any response.

BROWSER (visible to user, DevTools, etc.) ┌──────────────────────────────────────────────┐ │ Your prompt │ │ Your JWT │ │ Nothing else │ └──────────────────────┬───────────────────────┘ │ HTTPS request ▼ EDGE FUNCTION (invisible to user) ┌──────────────────────────────────────────────┐ │ Receives: prompt + JWT │ │ │ │ Has access to environment: │ │ OPENAI_API_KEY = "sk-..." ← secret │ │ CLAUDE_API_KEY = "sk-ant-" ← secret │ │ SUPABASE_SERVICE_ROLE_KEY ← secret │ │ │ │ Makes internal HTTPS call to OpenAI │ │ Receives base64 image │ │ Uploads to storage │ └──────────────────────┬───────────────────────┘ │ Returns only: { url: "..." } ▼ BROWSER receives a public URL. No keys. No secrets.

Figure 10 — The API key never crosses the boundary into the browser

What is Deno?

Supabase Edge Functions are written in TypeScript and run on Deno — a JavaScript/TypeScript runtime created by Ryan Dahl, the same person who created Node.js. Node.js was built in 2009 and has some design decisions that, in hindsight, caused security and complexity problems. Deno was built in 2018 to fix them.

For Edge Functions, the most relevant differences are:

What does "Edge" mean geographically?

Edge compute refers to running code at servers physically close to users — at the "edge" of the network — rather than in one central data center. Supabase deploys Edge Function code to many regions worldwide. When you trigger generate-image from India, it executes on a server in Singapore or Mumbai, not in a US data center. This reduces the round-trip time — your request doesn't have to physically travel to the other side of the world.

How the proxy call to OpenAI actually works at the network level

When your browser calls /functions/v1/generate-image, here is the exact sequence of network calls:

1 Browser → Supabase. An HTTPS POST from your browser to [project-id].supabase.co, carrying your JWT and the image prompt. This request is encrypted by TLS. Supabase's servers see the decrypted contents — your prompt — but the API keys are nowhere in this request.
2 Deno function starts. The Supabase platform routes the request to a Deno process running your function code. The Deno environment has the secret environment variables pre-loaded. The function validates your JWT.
3 Supabase → OpenAI. From inside the Deno process, a new HTTPS POST is made to api.openai.com/v1/images/generations. This request includes the OPENAI_API_KEY in its Authorization header. This is a server-to-server call — your browser is not involved. The request originates from Supabase's servers, not from your IP address.
4 OpenAI → Supabase. OpenAI returns a JSON response containing the generated image as a base64 string (a text representation of the binary image file).
5 Supabase → Supabase Storage. The function decodes the base64 string back into bytes, then uploads those bytes to Supabase Storage using the Service Role Key. The image lands at a predictable path: dream-catcher-db/{user_id}/{entry_id}/scene-0.png.
6 Supabase → Browser. The function returns a small JSON response: { "url": "https://...supabase.co/storage/...scene-0.png" }. The browser receives only this URL — no API keys, no base64 blob, no internal details.

Environment variables — what they are and how they're set

An environment variable is a key-value pair injected into a process's memory at startup. Programs read them with Deno.env.get("KEY_NAME"). They are not part of the code — they're configuration. This separation means the same function code can run in development (with test API keys) and production (with real API keys) without any code changes.

In Supabase, secrets are set via the CLI: supabase secrets set OPENAI_API_KEY=sk-.... Supabase encrypts and stores them. When a function starts, Supabase decrypts them and injects them into the Deno process's environment. They're never stored in your code repository, never in your HTML, never transmitted to any browser.

Chapter 5

The Database Layer — How a Browser Query Travels to Postgres and Back

When db.js calls supabase.from('dreams').select('*'), what happens next is a chain of systems working together: the Supabase JS SDK, a REST API layer called PostgREST, PostgreSQL itself, and Row-Level Security policies. Each layer has a specific job. Let's trace the full path.

What is PostgreSQL?

PostgreSQL (often called Postgres) is a relational database — a system for storing data in structured tables with rows and columns, and querying it with SQL. It's been in development since 1986 and is considered one of the most reliable and feature-rich databases available. Supabase is essentially managed Postgres with a set of services built on top of it.

Dream Catcher's data lives in two tables: dreams and profiles. These tables live inside a Postgres instance managed by Supabase. You never interact with Postgres directly from the browser — there's a translation layer in between.

What is PostgREST?

Databases speak SQL. HTTP clients speak HTTP. PostgREST is an open-source server that sits in front of PostgreSQL and translates HTTP requests into SQL queries. It runs inside Supabase's infrastructure — you never deploy or configure it yourself.

When the Supabase JS SDK sends:

GET https://[project-id].supabase.co/rest/v1/dreams
Authorization: Bearer eyJhbGciOiJIUzI1NiIs...
apikey: eyJhbGciOiJIUzI1NiIs...

PostgREST translates this to a SQL query and runs it against Postgres:

SELECT * FROM dreams;

But before running that query, PostgREST does something critical: it reads the JWT from the Authorization header, verifies it, and configures the Postgres session so that auth.uid() will return the user's UUID for the duration of this query. This is the bridge between the HTTP world and the database world.

Row-Level Security (RLS) — the database's own access control

Row-Level Security is a feature built into PostgreSQL. It lets you attach security policies to tables that automatically filter which rows any given query can see or modify. It runs inside the database engine — it cannot be bypassed by clever client-side code.

Here is the RLS policy on the dreams table:

CREATE POLICY "users can read own dreams"
  ON dreams
  FOR SELECT
  USING (auth.uid() = user_id);

USING defines a filter that is applied to every row the query touches. auth.uid() is a Supabase function that reads the UUID from the JWT that PostgREST injected into the session. user_id is a column in the dreams table.

What this means in practice: when Postgres runs SELECT * FROM dreams, it automatically rewrites the query to filter by the logged-in user's UUID. The actual query that executes is:

SELECT * FROM dreams
WHERE user_id = 'a1b2c3d4-e5f6-7890-abcd-ef1234567890';
-- ↑ injected by RLS, not by your application code

Even if a client sent a request trying to query all rows — removing filters, changing parameters — RLS still applies. It's enforced by the database, not by application logic. There is no way to opt out of it from a client using the anon key.

Why the anon key being public is not a vulnerability

The anon key is the API key in config.js. It's visible to anyone who views your source code. This seems alarming — shouldn't API keys be secret?

The anon key has a specific, limited purpose: it tells PostgREST "this request is from an anonymous or authenticated browser client — apply RLS." It is not a master password. It has no ability to bypass RLS, access other users' data, or perform administrative operations. Its only power is to talk to PostgREST with the constraints RLS imposes.

Compare to the service role key:

KeyWho has itRLS enforced?Storage write?
Anon key Browser (public) Yes — always No
Service role key Edge Functions only No — bypasses RLS Yes

If someone steals the anon key, they can query the API — but they'll only see data their own account has access to, because RLS always filters by their JWT. Without a valid JWT tied to a real user account, they see nothing.

How an upsert travels through the system

When DB.saveDream(entry) is called, the SDK constructs:

POST https://[project-id].supabase.co/rest/v1/dreams
Authorization: Bearer {JWT}
apikey: {anon key}
Content-Type: application/json
Prefer: resolution=merge-duplicates    ← instructs PostgREST to upsert

{
  "id": "entry-12345",
  "user_id": "a1b2c3d4-...",
  "date": "2026-06-12",
  "transcript": "I was standing in a forest...",
  "scenes": ["A woman in a misty clearing...", "..."],
  "style_key": "cinematic-noir",
  "created_at": "2026-06-12T07:30:00Z"
}

PostgREST translates this to:

INSERT INTO dreams (id, user_id, date, transcript, scenes, style_key, created_at)
VALUES ('entry-12345', 'a1b2c3d4-...', '2026-06-12', ...)
ON CONFLICT (id) DO UPDATE SET ...;

Before the INSERT executes, the RLS INSERT policy checks: auth.uid() = user_id. Since the client is submitting its own UUID (from the JWT) as the user_id, this passes. If someone tried to submit a different user's UUID as user_id, the RLS check would fail and the INSERT would be rejected.

The shared feed — reading other users' dreams

The home screen shows dreams from all users grouped by person. This means the SELECT policy on dreams must allow reading rows where user_id is not the current user. This is the one deliberate relaxation of isolation in the system — made intentionally to enable the social feed feature. All dreams in the system are visible to any authenticated user. The profiles table is similarly public-readable because the feed needs to show display names.

If private dreams are ever needed, an is_public boolean column and an updated RLS policy (USING (is_public = true OR auth.uid() = user_id)) would be the right approach.

Chapter 6

Netlify and the CDN — How Your App Gets to a Browser Anywhere in the World

Dream Catcher has no backend server. Netlify serves the files. Understanding exactly what that means — what a CDN is, what "static" means at the infrastructure level, and how files travel from Netlify to a user's browser — explains why the app loads fast globally and how Netlify fits into the security model.

Static vs dynamic — the core distinction

A dynamic web application has a server that runs code for every request. When you load Facebook, a server queries a database for your specific timeline, runs business logic to rank posts, and generates HTML tailored to you. The HTML doesn't exist on disk — it's assembled fresh for each request.

A static web application is different. The files on the server are exactly the files the browser receives. When you request index.html, the server reads that file from disk and returns it. No code runs. No database is queried. Every user who requests index.html gets the exact same file.

Dream Catcher is static. The personalization — your dreams, your profile, your images — all happens in the browser after the files load, through direct calls to Supabase. Netlify's job is purely file delivery.

A dynamic server is a chef who cooks a custom meal for each customer. A static server is a vending machine — the contents are fixed, you just pick what you want.

What is a CDN?

CDN stands for Content Delivery Network. It's a geographically distributed network of servers — called edge nodes or Points of Presence (PoPs) — each holding a copy of your site's files.

Netlify operates CDN nodes in dozens of cities worldwide. When someone in Mumbai visits your site, their browser's DNS lookup returns the IP address of a Netlify node in or near Mumbai, not one in Virginia. The files travel a few hundred kilometers instead of halfway around the world. This is why the initial page load feels fast regardless of where the user is.

┌──────────────────┐ │ Netlify Origin │ │ (your files │ │ live here) │ └────────┬─────────┘ │ copies files to edge nodes ┌───────────────┼────────────────┐ ▼ ▼ ▼ ┌──────────────┐ ┌──────────────┐ ┌──────────────┐ │ Edge node │ │ Edge node │ │ Edge node │ │ Frankfurt │ │ Singapore │ │ São Paulo │ └──────┬───────┘ └──────┬───────┘ └──────┬───────┘ │ │ │ ▼ ▼ ▼ User in User in User in London India Brazil

Figure 11 — CDN distributes files to edge nodes near users

What happens from the moment you type the URL

1 DNS resolution. Your browser asks a DNS server: "What is the IP address for dream-catcher.netlify.app?" DNS (Domain Name System) is the internet's phone book — it maps domain names to IP addresses. Netlify's DNS returns the IP of the nearest CDN node to your location.
2 TCP connection + TLS handshake. Your browser opens a TCP connection to that IP address (TCP is the underlying transport protocol for reliable data delivery). Then the TLS handshake happens — as described in Chapter 1. An encrypted channel is established. All of this takes roughly 50–150 milliseconds depending on your distance from the CDN node.
3 HTTP request for index.html. Your browser sends: GET / HTTP/1.1 Host: dream-catcher.netlify.app. The CDN node checks if it has a cached copy of index.html. If yes, it returns it immediately from memory. If no (a "cache miss"), it fetches from the origin, caches it, and returns it.
4 HTML parsed, additional files requested. Your browser parses the HTML and finds references to other files: <link rel="stylesheet" href="style.css">, <script src="app.js">, etc. It requests each of these in parallel — separate HTTP requests, all over the same encrypted connection.
5 JavaScript begins executing. Once the scripts are downloaded, they execute in order. Supabase is initialized, the session is checked, and the app renders.

Netlify never sees your dreams data. Every request Netlify handles is for a file: index.html, app.js, style.css. The dream data, the images, the profile — all of that flows through Supabase, not Netlify.

Caching — why the second load is faster than the first

Caching means storing a copy of something so you can serve it faster next time. CDN caching happens at two levels:

What the security headers actually do

Netlify's netlify.toml attaches security headers to every response. These aren't just formalities — each one closes a specific attack vector:

X-Frame-Options: DENY prevents any website from embedding the Dream Catcher UI inside an iframe. An iframe is an HTML element that loads another webpage inside the current page. A clickjacking attack works by embedding the target site in a transparent iframe over a decoy page. The user thinks they're clicking something on the decoy page but are actually clicking buttons on the hidden target site — potentially logging out, deleting their account, or confirming an action. DENY tells browsers: never render this site inside a frame, regardless of who's asking.

X-Content-Type-Options: nosniff prevents a browser behavior called MIME sniffing. MIME types are labels that tell browsers what kind of file a response is: text/html, text/css, application/javascript. Without this header, some browsers will look at a file's contents and try to guess its type even if the server said otherwise. An attacker who could upload a file to your server might label it as an image but fill it with JavaScript — and a browser that sniffs the MIME type might execute it. nosniff says: trust the server's label, never guess.

Referrer-Policy: strict-origin-when-cross-origin controls what is sent in the Referer header (yes, spelled with one r — a historical typo that became a standard). When you click a link, your browser includes the page you came from in the Referer header of the request to the new page. This is useful for analytics but can leak information. If a URL fragment ever contained a token and the user clicked an external link, the full URL (including the fragment) could be sent to a third party. This policy restricts the Referer to just the origin (the domain) when navigating cross-origin, never the full path or fragment.

Chapter 7

How It All Fits Together — A Single Dream Session, Every System Explained

Let's put all six chapters together by walking through one morning — you open the app, record a dream, and a notebook page appears with cinematic scenes. At each moment, we'll name exactly which system is acting, what it's doing, and why it's safe.

7:00 AM — you open the app

Your browser resolves dream-catcher.netlify.app via DNS to the nearest Netlify CDN node. A TLS handshake establishes an encrypted connection. Netlify delivers index.html, style.css, and the six JavaScript files. These are static files — Netlify ran no code, queried no database. The files are the same for every user.

Your JavaScript initializes. auth.js creates a Supabase client using the public anon key and calls supabase.auth.getSession(). The Supabase SDK checks localStorage for a stored JWT. It finds one from your previous login — checks if it's expired — sees it's still valid (or silently refreshes it). The onAuthStateChange event fires with SIGNED_IN. The app shows your home screen.

db.js calls supabase.from('dreams').select('*'). The Supabase SDK sends an HTTPS GET to PostgREST with your JWT in the Authorization header. PostgREST validates the JWT, injects your UUID into the Postgres session, and runs a SELECT filtered by your user_id via RLS. Your dreams come back — only yours, because RLS filtered everything else. The home carousel renders.

7:05 AM — you tap Record

app.js calls the browser's navigator.mediaDevices.getUserMedia() API. The browser asks you for microphone permission. You grant it. A MediaRecorder object starts capturing audio as a stream of binary chunks, accumulating in memory.

7:08 AM — you tap Stop

The audio chunks are assembled into a Blob (a browser object representing raw binary data) in a format like webm or m4a depending on your browser. api.js wraps this in a FormData object and sends it to the transcribe-audio Edge Function over HTTPS. The request includes your JWT. The audio data and JWT travel encrypted — a man-in-the-middle sees nothing.

The Deno function receives the request, validates your JWT using the Supabase JWT secret, extracts the audio file, and forwards it to OpenAI Whisper — a server-to-server call using OPENAI_API_KEY from the environment. Whisper processes the audio and returns text. The function returns { "text": "..." } to your browser. Your API key was never in the browser. Your audio was encrypted in transit. The text appears in the bottom sheet.

7:09 AM — you tap Visualize

app.js calls extractScenes(transcript). An HTTPS POST goes to the extract-scenes Edge Function carrying the transcript and your JWT. The Deno function validates the JWT, constructs a Claude prompt, and calls the Anthropic API using CLAUDE_API_KEY. Claude processes the transcript, identifies three visually distinct scenes, and returns them as JSON. The function returns { scenes: [...], cleanedTranscript: "..." }.

Your browser has stored the preferredDreamStyle in localStorage from a previous session — "cinematic-noir." No style picker is shown.

7:09 AM — image generation begins

app.js calls generateSceneRange(). For each of the three scenes in sequence:

Scene 0. An HTTPS POST goes to generate-image with the scene description, style key, entry ID, your appearance prompt, and no reference image. The Deno function builds the full prompt — scene text + appearance description + the cinematic-noir style suffix. It calls api.openai.com/v1/images/generations using OPENAI_API_KEY. OpenAI's gpt-image-1 model generates a 1024×1024 PNG and returns it as base64. The function decodes it to bytes and calls Supabase Storage using the SUPABASE_SERVICE_ROLE_KEY — which bypasses RLS, allowing the function to write to the bucket. The image is saved at dream-catcher-db/{your_id}/{entry_id}/scene-0.png. A public URL is returned.

Scenes 1 and 2. Same process, but the referenceImageUrl is set to the scene-0 URL. The Edge Function calls api.openai.com/v1/images/edits instead — passing the first image as a visual reference with input_fidelity: high. This tells gpt-image-1 to maintain character consistency across scenes. Each image is uploaded and a URL returned.

Your browser received three public Storage URLs. No OpenAI API key ever touched the browser. The images load directly from Supabase Storage — the browser fetches them as plain public files, no auth required.

7:10 AM — the dream is saved

DB.saveDream(entry) sends an HTTPS POST to PostgREST with your JWT. PostgREST validates the JWT, checks the RLS INSERT policy (auth.uid() = user_id passes because you submitted your own UUID), and runs an UPSERT into the dreams table. The dream is now in the database. Your notebook page renders.


Every step in that session — file delivery, auth, transcription, scene extraction, image generation, storage, database write — used HTTPS. API keys never left the server environment. The database enforced access control without relying on your application code getting it right. Google's identity verification happened without Dream Catcher ever seeing your Google password. The CDN delivered your files from nearby, fast.

This is not magic — it's a set of well-understood protocols, each designed to solve one specific trust problem, layered together. Once you see how each layer works individually, the whole system becomes readable.

Appendix

Glossary — Every Term Defined

TermDefinition
Anon KeySupabase's public API key for browser clients. Safe to expose because RLS policies restrict what it can access.
Authorization CodeA short-lived single-use code Google issues during OAuth. Useless without the client secret to exchange it.
Base64A way of encoding binary data (like an image) as plain ASCII text so it can travel inside HTTP headers or JSON.
CA (Certificate Authority)A trusted organization that signs SSL certificates. Your browser has a built-in list of trusted CAs.
CDN (Content Delivery Network)A global network of servers that cache and deliver static files from locations near users.
Client SecretA secret credential that proves to Google that a token exchange request is genuinely from your app's backend.
ClickjackingAn attack where a site is embedded invisibly in an iframe and the user is tricked into clicking its buttons.
CORSCross-Origin Resource Sharing — a browser policy that controls which origins can call which APIs. Edge Functions set CORS headers to allow browser requests.
DenoA TypeScript/JavaScript runtime used by Supabase Edge Functions. Faster startup than Node.js, uses web standard APIs.
DNS (Domain Name System)The internet's phone book — translates domain names like supabase.co to IP addresses.
Edge ComputeRunning code at servers geographically close to users, reducing latency.
Environment VariableA key-value pair injected into a process's memory at startup. Used to store secrets outside of code.
Fragment (#)The part of a URL after #. Never sent to servers by the browser — only readable by client-side JavaScript.
HMAC-SHA256A cryptographic function that produces a unique fixed-length fingerprint given data and a secret key.
HTTPSHTTP over TLS — an encrypted connection between browser and server that prevents eavesdropping and tampering.
iframeAn HTML element that embeds another webpage inside the current page.
JWT (JSON Web Token)A signed token containing user identity claims. Verifiable without a database lookup using the issuer's secret key.
localStorageA browser storage mechanism for persisting data between sessions. Accessible only to scripts from the same origin.
MIME TypeA label describing what kind of data a file contains (e.g., text/html, image/png).
OAuth 2.0An authorization protocol that lets one app verify a user's identity via another service without seeing their password.
PostgRESTAn open-source server that translates HTTP requests into SQL queries, providing a REST API in front of PostgreSQL.
PostgreSQLA powerful open-source relational database. Supabase is essentially managed PostgreSQL with additional services.
RLS (Row-Level Security)A PostgreSQL feature that filters rows based on security policies evaluated inside the database engine.
Service Role KeyA Supabase key that bypasses RLS. Stored only in Edge Function environment — never in the browser.
SSL CertificateA digital document signed by a CA that proves a server is who it claims to be. Used during TLS handshake.
State ParameterA random string sent in an OAuth request and expected back in the callback. Prevents CSRF attacks.
Static SiteA website where the server delivers pre-existing files without running code or querying databases per request.
TCPTransmission Control Protocol — the underlying transport layer for reliable, ordered data delivery across the internet.
TLS (Transport Layer Security)The cryptographic protocol behind HTTPS. Establishes an encrypted channel between client and server.
Token ExchangeSending an authorization code + client secret to Google in exchange for real access tokens.
URL FragmentSee Fragment (#).