System Architecture & Technical Reference
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.
Figure 1 — High-level system architecture
config.js) is safe to expose because PostgreSQL RLS policies ensure users can only touch their own rows.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.
[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"
| Capability | Detail |
|---|---|
| CDN distribution | Static assets served from edge nodes globally |
| HTTPS | Automatic TLS certificate via Let's Encrypt |
| Deploy previews | Every git push creates a preview URL |
| Security headers | Clickjack protection, MIME sniff prevention, referrer control |
| Redirects / rewrites | Not currently used; app is single-page |
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.
auth.js calls supabase.auth.signInWithOAuth({ provider: 'google' }).window.location.href (the app URL) with an authorization code in the URL fragment.localStorage automatically. The SDK refreshes it silently before it expires.onAuthStateChange fires a SIGNED_IN event. auth.js hides the login screen and shows the app.DB.upsertProfile(user) creates or updates the profiles row with display_name and avatar_url from Google's user metadata.Figure 2 — Google OAuth 2.0 via Supabase Auth proxy
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.
Auth.signOut() calls supabase.auth.signOut(), which clears the
stored JWT from localStorage. The onAuthStateChange SIGNED_OUT
event then shows the login screen.
Supabase provides four distinct services that Dream Catcher uses. Each is accessed differently and has its own security boundary.
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.
The database has two application tables:
public.dreams| Column | Type | Description |
|---|---|---|
| id | VARCHAR / UUID | Entry ID, generated client-side |
| user_id | UUID | FK to auth.users — enforces ownership |
| date | DATE | Dream date (YYYY-MM-DD) |
| transcript | TEXT | Full dream text, cleaned by Claude |
| scenes | JSONB | Array of scene descriptions |
| style_key | VARCHAR | Visual style used for generation |
| created_at | TIMESTAMPTZ | ISO 8601 timestamp |
public.profiles| Column | Type | Description |
|---|---|---|
| id | UUID | PK, FK to auth.users |
| display_name | VARCHAR | Full name from Google |
| avatar_url | VARCHAR | Profile photo URL from Google |
| appearance_prompt | TEXT | User'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).
All generated images are stored in the dream-catcher-db bucket.
| Operation | Who can do it | Auth method |
|---|---|---|
| Read (view images) | Anyone — bucket is public | No auth required |
| Write (upload images) | Edge Function only | Service Role Key (bypasses RLS) |
| Delete | Not currently exposed | — |
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:
transcribe-audio — audio-to-text via OpenAI Whisperextract-scenes — scene extraction via Anthropic Claudegenerate-image — image generation and storage upload via OpenAI
Each function is invoked via a POST request to
https://<project>.supabase.co/functions/v1/<name>.
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.
const { data: { user } } = await supabase.auth.getUser(token)
user is null, the function returns 401 and stops.
transcribe-audioPurpose: Converts a voice recording into text using OpenAI Whisper.
| Input | multipart/form-data with field audio (webm / m4a / ogg) |
|---|---|
| Output | { "text": "I was walking through a forest..." } |
| External API | OpenAI POST /v1/audio/transcriptions — model: whisper-1, language: en |
| Secret used | OPENAI_API_KEY |
| Error codes | 401 no auth, 400 no audio file, 500 API failure |
Figure 3 — transcribe-audio call flow
extract-scenesPurpose: 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 API | Anthropic Claude POST /v1/messages — model: claude-opus-4-8 |
| Secret used | CLAUDE_API_KEY |
| Max scenes | 6 (Claude decides based on dream complexity) |
| Error codes | 401 no auth, 400 no transcript, 500 API failure |
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}
Figure 4 — extract-scenes call flow
generate-imagePurpose: 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 used | OPENAI_API_KEY, SUPABASE_SERVICE_ROLE_KEY |
| Error codes | 401 no auth, 400 missing params, 500 API/upload failure |
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.
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.
Figure 5 — generate-image call flow (scene 0 vs scenes 1+)
This section traces a complete dream recording session from microphone tap to rendered notebook page.
Figure 6 — Complete API call journey for a dream session
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({ ... })
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.
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.
dreams table| Operation | Policy condition | Effect |
|---|---|---|
| SELECT | auth.uid() = user_id | Users see only their own dreams |
| INSERT | auth.uid() = user_id | Users can only create rows for themselves |
| UPDATE | auth.uid() = user_id | Users can only edit their own dreams |
| DELETE | auth.uid() = user_id | Users can only delete their own dreams |
profiles table| Operation | Policy condition | Effect |
|---|---|---|
| SELECT | No restriction (public) | Any authenticated user can read profiles (needed for shared feed) |
| INSERT | auth.uid() = id | Users can only create their own profile row |
| UPDATE | auth.uid() = id | Users can only update their own profile |
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.
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.
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 | Where stored | What it can do | RLS 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 |
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
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.
| Variable | Used by | Purpose |
|---|---|---|
OPENAI_API_KEY | transcribe-audio, generate-image | Authenticate to OpenAI (Whisper + gpt-image-1) |
CLAUDE_API_KEY | extract-scenes | Authenticate to Anthropic Claude |
SUPABASE_SERVICE_ROLE_KEY | generate-image | Upload images to Storage (bypasses RLS) |
SUPABASE_URL | All three functions | Connect to Supabase for auth validation |
SUPABASE_ANON_KEY | All three functions | Create Supabase client for auth.getUser() |
| 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 |
Figure 7 — All request paths at a glance
| File | Responsibility |
|---|---|
index.html | App shell — login screen + main layout |
style.css | All visual styling (dark cinematic theme) |
config.js | Supabase URL and anon key (public) |
auth.js | Google OAuth, session management, auth state changes |
db.js | All Supabase DB and Storage operations |
api.js | Edge Function callers, style definitions, prompt builder |
app.js | Orchestration, state machine, UI rendering |
avatars.js | SVG avatar generation per user |
logger.js | Session logging to localStorage for debug view |
imagestore.js | IndexedDB wrapper (defined, not actively used) |
netlify.toml | Netlify 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
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
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.
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
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.
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:
email profile means we want to see the user's name and email — nothing else.Your browser is now fully on Google's servers. Google renders its consent screen. Meanwhile, Google is checking:
client_id match a registered app?redirect_uri exactly match one of the pre-approved URIs for that app? If someone tries to swap in a malicious URL here, Google refuses. This is registered in Google Cloud Console when the app is first set up.
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...
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.
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.
Figure 8 — Google OAuth 2.0 full flow with Supabase as auth proxy
Chapter 3
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.
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:
{
"alg": "HS256", // the signing algorithm: HMAC-SHA256
"typ": "JWT" // this is a JWT
}
{
"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)
}
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.
When generate-image receives a request with a JWT, it does this:
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.
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.
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.
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.
Figure 9 — JWT lifecycle and silent refresh
Chapter 4
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.
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.
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.
Figure 10 — The API key never crosses the boundary into the browser
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:
fetch(), Request, Response APIs that browsers use, so Edge Function code feels familiar.
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.
When your browser calls /functions/v1/generate-image, here is the exact
sequence of network calls:
[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.
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.
dream-catcher-db/{user_id}/{entry_id}/scene-0.png.
{ "url": "https://...supabase.co/storage/...scene-0.png" }. The browser receives only this URL — no API keys, no base64 blob, no internal details.
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
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.
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.
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 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.
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:
| Key | Who has it | RLS 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.
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 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
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.
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.
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.
Figure 11 — CDN distributes files to edge nodes near users
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.
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.
<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.
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 means storing a copy of something so you can serve it faster next time. CDN caching happens at two levels:
Cache-Control headers to tell browsers how long they can cache each file.
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
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.
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.
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.
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.
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.
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.
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
| Term | Definition |
|---|---|
| Anon Key | Supabase's public API key for browser clients. Safe to expose because RLS policies restrict what it can access. |
| Authorization Code | A short-lived single-use code Google issues during OAuth. Useless without the client secret to exchange it. |
| Base64 | A 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 Secret | A secret credential that proves to Google that a token exchange request is genuinely from your app's backend. |
| Clickjacking | An attack where a site is embedded invisibly in an iframe and the user is tricked into clicking its buttons. |
| CORS | Cross-Origin Resource Sharing — a browser policy that controls which origins can call which APIs. Edge Functions set CORS headers to allow browser requests. |
| Deno | A 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 Compute | Running code at servers geographically close to users, reducing latency. |
| Environment Variable | A 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-SHA256 | A cryptographic function that produces a unique fixed-length fingerprint given data and a secret key. |
| HTTPS | HTTP over TLS — an encrypted connection between browser and server that prevents eavesdropping and tampering. |
| iframe | An 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. |
| localStorage | A browser storage mechanism for persisting data between sessions. Accessible only to scripts from the same origin. |
| MIME Type | A label describing what kind of data a file contains (e.g., text/html, image/png). |
| OAuth 2.0 | An authorization protocol that lets one app verify a user's identity via another service without seeing their password. |
| PostgREST | An open-source server that translates HTTP requests into SQL queries, providing a REST API in front of PostgreSQL. |
| PostgreSQL | A 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 Key | A Supabase key that bypasses RLS. Stored only in Edge Function environment — never in the browser. |
| SSL Certificate | A digital document signed by a CA that proves a server is who it claims to be. Used during TLS handshake. |
| State Parameter | A random string sent in an OAuth request and expected back in the callback. Prevents CSRF attacks. |
| Static Site | A website where the server delivers pre-existing files without running code or querying databases per request. |
| TCP | Transmission 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 Exchange | Sending an authorization code + client secret to Google in exchange for real access tokens. |
| URL Fragment | See Fragment (#). |