This document describes how authentication works for the WatchTower dashboard in the Prototype 3 flow. We use Clerk for all real authentication. WatchTower keeps its own UI shell (landing, login, signup) and delegates identity, sessions, sign-in, sign-up, password reset, and sign-out to Clerk.
Decision record: see
docs/adr/ADR-0006.md.
Landing Page (public)
src/frontend/landing/index.html
│ "Get Started" / Login
▼
Login / Signup (our shell + Clerk component)
src/frontend/auth/login.html → #clerk-sign-in
src/frontend/auth/signup.html → #clerk-sign-up
│ Clerk authenticates and starts a session
▼
Protected WatchTower UI
src/frontend/dashboard/index.html (guarded by auth-guard.js)
│ Logout
▼
Back to Landing Page (public)
src/frontend/landing/index.html/login when served
by the Node server, or ../auth/login.html in the static/file flow).src/frontend/auth/login.html contains <div id="clerk-sign-in"></div>; signup.html
contains <div id="clerk-sign-up"></div>../clerk-config.js — exposes window.CLERK_PUBLISHABLE_KEY../auth.js — loads Clerk, mounts the component, handles redirects./dashboard when served by the Node server).auth.js redirects them
straight to the dashboard.forgot-password.html no longer pretends to send reset emails; it points
users to the “Forgot password?” link inside Clerk’s sign-in box.src/frontend/dashboard/index.html/login/clerk-config.js — publishable key (generated into src/frontend/auth)./dashboard/auth-guard.js — client-side route guard (in <head>)./dashboard/app.js — the existing dashboard logic (unchanged).auth-guard.js hides the dashboard shell until Clerk confirms a session.
#auth-user-label) is populated, logout controls are wired, and the guard
upserts the user into Supabase via POST /api/users/sync (see below)./login/.app.js (/api/events, /api/stats,
/api/developer/stream, /api/developer/insights, /api/developer/query)
send an X-Clerk-User-Id header so the backend returns only that user’s data.WatchTower keeps Clerk as the only authentication provider and uses Supabase purely as the application database — there is no Supabase Auth and no password is ever stored.
| Concern | Where it lives |
|---|---|
| Identity / sessions / sign-in | Clerk |
| Application users | Supabase public.app_users (keyed by clerk_user_id) |
| Telemetry events | Supabase public.prototype3_events, scoped by user_id |
auth-guard.js calls POST /api/users/sync
with { clerkUserId, email, displayName } and an X-Clerk-User-Id header.eventStore.syncUser(...), which upserts a row into
app_users (clerk_user_id, email, display_name, last_seen_at). No
password or credential is stored — Clerk owns those.GET /api/events, GET /api/stats,
GET /api/developer/stream, GET /api/developer/insights,
POST /api/developer/query) call requireCurrentUser(...). With no
X-Clerk-User-Id header they return 401.eventStore.listEvents(limit, { userId }) /
eventStore.allEvents(limit, { userId }), which filter
prototype3_events.user_id = <Clerk user id>.POST /api/events (and POST /api/beacon) resolve an ingest owner with
getIngestOwnerUserId(...) and, when present, stamp each incoming event with
user_id = <owner> before insert. The owner is the authenticated dashboard
user when present, otherwise the temporary DEFAULT_INGEST_OWNER_USER_ID
fallback (see “External GitHub Pages test app ownership” below), otherwise
none (the event stays anonymous with user_id = null)./demo/)POST /api/events without the dashboard’s Clerk header.auth-guard.js stores the
signed-in Clerk id in localStorage (watchtower_clerk_user_id), and
demo/app.js initializes the SDK with userId = <that id>. The id then rides
in the event payload and is persisted as prototype3_events.user_id./demo/ after signing in to the dashboard (or refresh it) so the id
is present. A truly external app on another origin has no such id and ingests
as anonymous (user_id = null) — which is the intended future “needs a
project/app key” path.https://cse110-sp26-group09.github.io/Watchtower-test-app/) is a real
external monitored app. It loads its own copy of the WatchTower SDK, points at
the absolute Render endpoint
(https://watchtower-course-project-g8dv.onrender.com/api/events), and sends
the same { events: [...] } batches as the local demo.Authorization: Bearer token or an X-Clerk-User-Id header, and its SDK does
not set a userId on events. The backend therefore accepts the events
(200 OK) but stores them with user_id = NULL — so, now that the
dashboard is scoped per Clerk user, those events do not appear for any
logged-in user.DEFAULT_INGEST_OWNER_USER_ID (Render env, or local .env) to the demo
owner’s Clerk user id. On ingest, when there is no authenticated user, the
backend stamps anonymous events with this id so they land on that owner’s
scoped dashboard.user_id for events that
arrive without one. Authenticated dashboard ingests still win and override
it, and the same-origin demo’s own per-user tagging is preserved.clerk_user_id filters back the rows persisted
during earlier sessions, so they see their saved data after logging back in.clerk_user_id never matches the first
user’s user_id, so users cannot see each other’s events.The backend resolves the current user id with this preference order
(resolveCurrentUserId in server.js):
Authorization: Bearer <Clerk.session.getToken()>. The server verifies the
signature against Clerk’s public JWKS (<issuer>/.well-known/jwks.json)
and the iss claim, then takes the user id from the signed sub claim.
This is cryptographically authoritative and cannot be spoofed.X-Clerk-User-Id header fallback — used only when token verification
is not configured (no real Clerk key, e.g. CI / local memory-store runs) or
when WATCHTOWER_TRUST_USER_HEADER=true is explicitly set.The Clerk issuer (Frontend API origin, e.g.
https://your-app.clerk.accounts.dev) is derived from CLERK_PUBLISHABLE_KEY
(base64-encoded inside the key) or set explicitly via CLERK_JWT_ISSUER.
No Clerk secret key is needed — JWKS verification uses only public keys.
When a real Clerk instance is configured, the dashboard routes require a
valid token: a request with only a (forged) X-Clerk-User-Id header and no
valid token is rejected with 401.
#logout-button lives in the dashboard topbar; the existing Settings →
“Sign out” button (#sign-out-button) is also wired.Clerk.signOut() and then redirect to the login page (/login/).| Concern | Owner |
|---|---|
| Sign-in / sign-up UI components | Clerk (mounted into our shell) |
| Password handling & storage | Clerk only — WatchTower stores nothing |
| Sessions & tokens | Clerk |
| Password reset / email verification | Clerk |
| Page branding & layout shell | WatchTower |
| Routing between landing/login/dashboard | WatchTower |
| Event ingestion + storage | WatchTower (unrelated to user auth) |
Storing passwords means owning hashing, salting, breach response, reset flows, and compliance. Clerk is purpose-built for this. By delegating, WatchTower never receives a password, so there is nothing sensitive to leak from our database or backend. The dashboard stores telemetry events only — no credentials.
GET /api/events, GET /api/stats,
GET /api/developer/stream, GET /api/developer/insights,
POST /api/developer/query) require an authenticated user (401 otherwise) and
scope all data to that user.X-Clerk-User-Id header is only a fallback for
unconfigured/test environments.POST /api/events, POST /api/beacon, and /api/events/stream remain open so
external/SDK ingestion keeps working without a dashboard login.auth.jwt() ->> 'sub'), so even an API bug
cannot leak cross-user rows. This requires the DB client to carry the Clerk
token (the server currently uses the service-role key, which bypasses RLS).These are two different authentication problems and must not be conflated:
| Dashboard user auth | SDK event ingestion auth | |
|---|---|---|
| Who authenticates | A human operator viewing dashboards | A monitored application sending events |
| Mechanism | Clerk login/signup/session | A future per-app/project key or token |
| Requires a user login? | Yes | No — must work headless/server-side |
| Implemented today | Clerk (this work) | Not yet — ingestion is currently open |
The browser SDK (src/sdk/watchtower.js) and the ingestion endpoint
should never require a normal Clerk user login. A separate app/project key
or signed token is the correct future mechanism so that monitored apps can send
telemetry without a human signing in.
src/frontend/auth/clerk-config.js by
npm run config:clerk from the CLERK_PUBLISHABLE_KEY environment variable
(see .env.example).clerk-config.js is gitignored — set the key in a local .env file or in
Render Environment settings, not in committed source.npm start # runs config:clerk, then boots src/backend/server.js
The backend serves the dashboard at /dashboard, the login shell at /login/,
and the landing page at /landing/. The landing, auth, and dashboard pages live
under src/frontend/ and the server resolves all three from there, so the full
Clerk flow works end to end through the Node server (not just the static file
flow).
CLERK_PUBLISHABLE_KEY in .env, then run npm start.http://localhost:3000/landing/./login./dashboard).app_users
(clerk_user_id = User A’s Clerk id, with last_seen_at set).X-Clerk-User-Id, so new
prototype3_events rows get user_id = User A’s Clerk id).DEFAULT_INGEST_OWNER_USER_ID)DEFAULT_INGEST_OWNER_USER_ID to the demo owner’s Clerk user id in the
local .env (or in Render → Environment for the hosted backend).https://cse110-sp26-group09.github.io/Watchtower-test-app/).POST /api/events → 200 OK.user_id populated with DEFAULT_INGEST_OWNER_USER_ID instead of
NULL.select
id,
user_id,
type,
event_name,
route,
app_name,
received_at
from public.prototype3_events
order by received_at desc
limit 20;
npm run test:unit # event-store + shared utils
npm run test:e2e # Playwright
npm run docs:js # JSDoc generation
The Playwright API specs send an X-Clerk-User-Id header so they post and read
back events as a single synthetic user, validating the per-user scoping without
weakening external SDK ingestion (which stays open).