jevstrudel.git / worker / migrations / 0002_accounts.sql
1-- Accounts, signed in with passkeys (WebAuthn), and each account's radio
2-- history. Owners: src/accounts-store.ts reads and writes users,
3-- credentials, sessions and auth_challenges (for src/auth.ts);
4-- src/radio.ts reads and writes radio_plays.
5--
6-- What is identifying here, and why (migrations/CLAUDE.md): a user is a
7-- random id and the display name its owner typed, so the site can say who
8-- is signed in and charge that account's daily Jev budget (src/budget.ts).
9-- A credential is the passkey's public key, which only verifies signatures.
10-- A session is the SHA-256 of a random cookie token, never the token, so a
11-- read of this table cannot sign anyone in. No address, email, password or
12-- request is stored. Times are milliseconds since the epoch, UTC.
13
14CREATE TABLE users (
15  id TEXT PRIMARY KEY CHECK (length(id) = 22 AND id NOT GLOB '*[^A-Za-z0-9_-]*'),
16  display_name TEXT NOT NULL CHECK (
17    length(display_name) BETWEEN 1 AND 40 AND display_name = trim(display_name)
18  ),
19  created_at INTEGER NOT NULL CHECK (created_at > 0)
20) STRICT;
21
22-- A passkey: its credential id (base64url, as the browser reports it), the
23-- COSE public key, the authenticator's signature counter (0 for most
24-- synced passkeys, which do not count), and the transports the browser
25-- reported, as a JSON array of strings. A user has one or more.
26CREATE TABLE credentials (
27  id TEXT PRIMARY KEY CHECK (
28    length(id) BETWEEN 16 AND 1366 AND id NOT GLOB '*[^A-Za-z0-9_-]*'
29  ),
30  user_id TEXT NOT NULL REFERENCES users (id) ON DELETE CASCADE,
31  public_key BLOB NOT NULL CHECK (length(public_key) > 0),
32  sign_count INTEGER NOT NULL CHECK (sign_count >= 0),
33  transports TEXT NOT NULL CHECK (json_valid(transports) AND json_type(transports) = 'array'),
34  created_at INTEGER NOT NULL CHECK (created_at > 0)
35) STRICT;
36CREATE INDEX credentials_by_user ON credentials (user_id);
37
38-- A signed-in browser: the SHA-256 (hex) of its cookie's token.
39CREATE TABLE sessions (
40  id_hash TEXT PRIMARY KEY CHECK (length(id_hash) = 64 AND id_hash NOT GLOB '*[^0-9a-f]*'),
41  user_id TEXT NOT NULL REFERENCES users (id) ON DELETE CASCADE,
42  created_at INTEGER NOT NULL CHECK (created_at > 0),
43  expires_at INTEGER NOT NULL CHECK (expires_at > created_at)
44) STRICT;
45CREATE INDEX sessions_by_user ON sessions (user_id);
46CREATE INDEX sessions_by_expiry ON sessions (expires_at);
47
48-- A WebAuthn ceremony in flight: the challenge the Worker issued, what it
49-- is for, and until when. Consumed by the verify step with DELETE …
50-- RETURNING, so each is used at most once, and refused once expired.
51--   login     sign in with any passkey on this site; no user yet
52--   register  a new account: its id and display name, created on verify
53--   add       another passkey for the signed-in account `user_id`
54CREATE TABLE auth_challenges (
55  challenge TEXT PRIMARY KEY CHECK (
56    length(challenge) = 43 AND challenge NOT GLOB '*[^A-Za-z0-9_-]*'
57  ),
58  purpose TEXT NOT NULL CHECK (purpose IN ('login', 'register', 'add')),
59  user_id TEXT,
60  display_name TEXT,
61  created_at INTEGER NOT NULL CHECK (created_at > 0),
62  expires_at INTEGER NOT NULL CHECK (expires_at > created_at),
63  -- (a CHECK passes when it is NULL, so every test here is NOT NULL first)
64  CHECK (
65    (purpose = 'login' AND user_id IS NULL AND display_name IS NULL)
66    OR (
67      purpose = 'register' AND user_id IS NOT NULL AND length(user_id) = 22
68      AND display_name IS NOT NULL AND length(display_name) BETWEEN 1 AND 40
69    )
70    OR (purpose = 'add' AND user_id IS NOT NULL AND display_name IS NULL)
71  )
72) STRICT;
73CREATE INDEX auth_challenges_by_expiry ON auth_challenges (expires_at);
74
75-- What the radio played for a signed-in listener, newest last: the song
76-- (a "<theme>/<song>" id the deploy had) and when it started. Kept to the
77-- newest few hundred per user by src/radio.ts.
78CREATE TABLE radio_plays (
79  id INTEGER PRIMARY KEY,
80  user_id TEXT NOT NULL REFERENCES users (id) ON DELETE CASCADE,
81  song_id TEXT NOT NULL CHECK (
82    length(song_id) BETWEEN 3 AND 129 AND song_id GLOB '*/*' AND song_id NOT GLOB '*[^a-z0-9/-]*'
83  ),
84  played_at INTEGER NOT NULL CHECK (played_at > 0)
85) STRICT;
86CREATE INDEX radio_plays_by_user ON radio_plays (user_id, played_at);