exports.up = (pgm) => { pgm.sql(` -- A registered passkey (#37). Groundwork only: nothing reads these yet. -- -- Bound to the customer and deleted with them. Account deletion already -- removes the personal data this sits beside, and a credential that -- outlived its owner could authenticate as a customer who no longer exists. -- Disabling an account (#33) is a different question and is deliberately not -- a schema concern: a disabled customer keeps their credentials and is -- refused at the authentication ceremony instead, so re-enabling them does -- not mean re-registering every device. CREATE TABLE IF NOT EXISTS customer_credentials ( id SERIAL PRIMARY KEY, customer_id INTEGER NOT NULL REFERENCES customers(id) ON DELETE CASCADE, -- The credential ID as base64url text rather than bytea. It arrives from -- the browser in that form, is compared as an opaque string, and is never -- interpreted here — storing bytes would mean encoding on write and -- decoding on every read for no gain. -- -- Unique across the table, not merely per customer: a credential ID -- identifies an authenticator, and the same one appearing under two -- accounts means something has gone wrong rather than that two people -- share a key. credential_id TEXT NOT NULL UNIQUE, -- The COSE public key, base64url. Verified against, never parsed here. public_key TEXT NOT NULL, -- BIGINT because the spec allows a 32-bit unsigned value, which overflows -- a signed INTEGER at half its range. -- -- What to do when this fails to increase is NOT decided here. Many synced -- passkeys report 0 forever, so "a regression means cloning" is wrong for -- them and right for hardware keys. That policy belongs with the -- authentication ceremony that enforces it (#39); this column only has to -- be able to hold the value. signature_counter BIGINT NOT NULL DEFAULT 0, -- How the authenticator can be reached: usb, nfc, ble, internal, hybrid. -- A JSON array as text, because it is passed back to the browser verbatim -- and never queried on. transports TEXT, created_at TIMESTAMPTZ NOT NULL DEFAULT now(), -- Null until first used. Shown on the account page (#40) so a customer can -- recognise which device a credential belongs to, which is the only way -- they can tell two entries apart. last_used_at TIMESTAMPTZ ); -- Listing a customer's credentials is the common read, and revocation (#40) -- has to scope by owner. CREATE INDEX IF NOT EXISTS customer_credentials_customer_id_idx ON customer_credentials (customer_id); -- An in-flight WebAuthn challenge (#37). -- -- A separate table rather than customer_tokens with a new kind, and the -- reason is structural rather than tidiness: customer_tokens.customer_id is -- NOT NULL, and an *authentication* challenge is issued before anyone is -- identified. A discoverable-credential sign-in has no customer to attach -- to at the moment the challenge is created, so it could not be stored -- there without making that column nullable for every other kind of token. CREATE TABLE IF NOT EXISTS webauthn_challenges ( -- The challenge itself, base64url, as issued. Primary key because it is -- the thing looked up, and unique by construction. challenge TEXT PRIMARY KEY, -- Null for authentication, set for registration. Registration requires an -- authenticated session — it is not a sign-up path — so that half always -- knows whose it is. customer_id INTEGER REFERENCES customers(id) ON DELETE CASCADE, -- 'registration' or 'authentication'. Not a CHECK constraint: the values -- come from this codebase rather than from a request, and the project has -- no enum types elsewhere. kind TEXT NOT NULL, expires_at TIMESTAMPTZ NOT NULL ); -- Expiry is swept by time, so the sweep reads this rather than the whole -- table. Single use is enforced by deleting the row on consumption, which -- needs no index beyond the primary key. CREATE INDEX IF NOT EXISTS webauthn_challenges_expires_at_idx ON webauthn_challenges (expires_at); `); }; exports.down = (pgm) => { pgm.sql(` DROP TABLE IF EXISTS webauthn_challenges; DROP TABLE IF EXISTS customer_credentials; `); };