From 04c7af001e0d6518177b5512170027639643a8a7 Mon Sep 17 00:00:00 2001 From: David Condrey Date: Wed, 5 Aug 2026 03:00:35 -0700 Subject: [PATCH 1/3] security(server): make an issued session token withdrawable MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A session was an HMAC-signed bearer token with a 30-day expiry and no server-side state, so nothing could end one early. /logout deleted a cookie in the browser that asked and left a copied token valid for the rest of its life. The CRM audit log made that concrete: it can now show an admin exactly which operator session read the contact table, and there was no lever to stop it. users.session_epoch is that lever. Every token carries the epoch it was signed at, a session resolves only while the two still match, and incrementing the column ends every outstanding session for that person at once. A counter rather than a timestamp: two revocations in one millisecond are two revocations, and a clock that steps backwards must not resurrect a session. The increment is done in SQL rather than read-then-write, so two racing revocations both land instead of the second overwriting the first with the same value — which would report success while leaving the sessions it was called to kill alive. sessionUser() is now the only way to resolve a session. Both halves live in it because a signature check not followed by an epoch check silently restores the old unrevocable token, at whichever route forgot, which is the failure nobody notices. verifySession stays pure and exported for what it is: proof the token was issued here and has not expired, never proof the session is live — the same split consent.ts draws between verifyConsentRecord and findActiveConsent. It costs no round trips. sessionUser returns the row it read, so /api/auth/me spends the one query it always did, and siteRole is now a single joined statement instead of two sequential reads, which pays for the epoch read on every RBAC path. The join also expresses "an unowned site grants nobody anything" directly: a NULL team_id matches no membership row, so that case no longer needs its own branch. A token with no epoch claim is rejected rather than read as epoch 0. It cannot be compared against a revocation, and an unverifiable revocation state has to mean revoked; the price is one forced sign-in at deploy. Deleting a user now also ends their sessions, since an account that does not exist holds none — a test asserting the opposite was rewritten, because it pinned the behaviour this replaces. POST /api/auth/logout-everywhere is the self-service control. It is deliberately all-or-nothing: with no session table there is no device list to revoke from, and the honest control is the one that ends everything, which is what someone reaching for it wants anyway. Also, autonomously: dropped two imports in routes/auth.ts left unused by the /me rewrite. --- apps/server/migrations/0014_bored_crystal.sql | 1 + .../server/migrations/meta/0014_snapshot.json | 1711 +++++++++++++++++ apps/server/migrations/meta/_journal.json | 7 + apps/server/src/db/schema.ts | 15 +- apps/server/src/lib/accounts.ts | 134 +- apps/server/src/lib/auth.ts | 20 +- apps/server/src/routes/auth.ts | 55 +- apps/server/test/accounts.test.ts | 125 +- apps/server/test/crm-audit.test.ts | 33 +- apps/server/test/crm.test.ts | 4 +- 10 files changed, 2037 insertions(+), 68 deletions(-) create mode 100644 apps/server/migrations/0014_bored_crystal.sql create mode 100644 apps/server/migrations/meta/0014_snapshot.json diff --git a/apps/server/migrations/0014_bored_crystal.sql b/apps/server/migrations/0014_bored_crystal.sql new file mode 100644 index 0000000..3040cec --- /dev/null +++ b/apps/server/migrations/0014_bored_crystal.sql @@ -0,0 +1 @@ +ALTER TABLE `users` ADD `session_epoch` integer DEFAULT 0 NOT NULL; \ No newline at end of file diff --git a/apps/server/migrations/meta/0014_snapshot.json b/apps/server/migrations/meta/0014_snapshot.json new file mode 100644 index 0000000..bfadf9c --- /dev/null +++ b/apps/server/migrations/meta/0014_snapshot.json @@ -0,0 +1,1711 @@ +{ + "version": "6", + "dialect": "sqlite", + "id": "eb8399ec-2216-476e-91aa-117cddec994a", + "prevId": "2ee41da5-11b1-4831-890e-3e11f73fb1c8", + "tables": { + "alert_deliveries": { + "name": "alert_deliveries", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "destination_id": { + "name": "destination_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "site_id": { + "name": "site_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "dedupe_key": { + "name": "dedupe_key", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "severity": { + "name": "severity", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "attempts": { + "name": "attempts", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "last_error": { + "name": "last_error", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "idx_alert_deliveries_dedupe": { + "name": "idx_alert_deliveries_dedupe", + "columns": [ + "destination_id", + "dedupe_key" + ], + "isUnique": true + }, + "idx_alert_deliveries_site": { + "name": "idx_alert_deliveries_site", + "columns": [ + "site_id", + "created_at" + ], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "alert_destinations": { + "name": "alert_destinations", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "site_id": { + "name": "site_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "target": { + "name": "target", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "min_severity": { + "name": "min_severity", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'warning'" + }, + "secret": { + "name": "secret", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "enabled": { + "name": "enabled", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 1 + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "idx_alert_destinations_site": { + "name": "idx_alert_destinations_site", + "columns": [ + "site_id" + ], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "api_keys": { + "name": "api_keys", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "site_id": { + "name": "site_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "key_hash": { + "name": "key_hash", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "label": { + "name": "label", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "last_used": { + "name": "last_used", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": { + "api_keys_key_hash_unique": { + "name": "api_keys_key_hash_unique", + "columns": [ + "key_hash" + ], + "isUnique": true + }, + "idx_apikeys_site": { + "name": "idx_apikeys_site", + "columns": [ + "site_id" + ], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "auth_tokens": { + "name": "auth_tokens", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "token_hash": { + "name": "token_hash", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "expires_at": { + "name": "expires_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "used_at": { + "name": "used_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "idx_auth_tokens_email": { + "name": "idx_auth_tokens_email", + "columns": [ + "email" + ], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "consent_records": { + "name": "consent_records", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "site_id": { + "name": "site_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "visitor_hash": { + "name": "visitor_hash", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "tier": { + "name": "tier", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "external_user_id": { + "name": "external_user_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "salt_window": { + "name": "salt_window", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "window_key": { + "name": "window_key", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "gpc_at_grant": { + "name": "gpc_at_grant", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "granted_at": { + "name": "granted_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "expires_at": { + "name": "expires_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "revoked_at": { + "name": "revoked_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "statement": { + "name": "statement", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "idx_consent_site_visitor": { + "name": "idx_consent_site_visitor", + "columns": [ + "site_id", + "visitor_hash", + "tier" + ], + "isUnique": false + }, + "idx_consent_site_extuser": { + "name": "idx_consent_site_extuser", + "columns": [ + "site_id", + "external_user_id" + ], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "event_rollups": { + "name": "event_rollups", + "columns": { + "site_id": { + "name": "site_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "hostname": { + "name": "hostname", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "bucket_start": { + "name": "bucket_start", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "interval": { + "name": "interval", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "pageviews": { + "name": "pageviews", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "events": { + "name": "events", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "visitors": { + "name": "visitors", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": { + "event_rollups_site_id_hostname_bucket_start_interval_pk": { + "columns": [ + "site_id", + "hostname", + "bucket_start", + "interval" + ], + "name": "event_rollups_site_id_hostname_bucket_start_interval_pk" + } + }, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "event_sessions": { + "name": "event_sessions", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "site_id": { + "name": "site_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "visitor_hash": { + "name": "visitor_hash", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "day_key": { + "name": "day_key", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "started_at": { + "name": "started_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "ended_at": { + "name": "ended_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "entry_path": { + "name": "entry_path", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "exit_path": { + "name": "exit_path", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "channel": { + "name": "channel", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "pageviews": { + "name": "pageviews", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "events": { + "name": "events", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "duration_ms": { + "name": "duration_ms", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "is_bounce": { + "name": "is_bounce", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + } + }, + "indexes": { + "idx_sessions_site_started": { + "name": "idx_sessions_site_started", + "columns": [ + "site_id", + "started_at" + ], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "events": { + "name": "events", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "site_id": { + "name": "site_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "hostname": { + "name": "hostname", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "path": { + "name": "path", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "referrer": { + "name": "referrer", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "''" + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "props": { + "name": "props", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "visitor_hash": { + "name": "visitor_hash", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "country": { + "name": "country", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "device": { + "name": "device", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "utm_source": { + "name": "utm_source", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "utm_medium": { + "name": "utm_medium", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "utm_campaign": { + "name": "utm_campaign", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "channel": { + "name": "channel", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "browser": { + "name": "browser", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "os": { + "name": "os", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "form_factor": { + "name": "form_factor", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "region": { + "name": "region", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "city": { + "name": "city", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "timezone": { + "name": "timezone", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "network": { + "name": "network", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "connection": { + "name": "connection", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "language": { + "name": "language", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "screen_tier": { + "name": "screen_tier", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "orientation": { + "name": "orientation", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "dpr_class": { + "name": "dpr_class", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "value": { + "name": "value", + "type": "real", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "currency": { + "name": "currency", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": { + "idx_events_site_created_name": { + "name": "idx_events_site_created_name", + "columns": [ + "site_id", + "created_at", + "name" + ], + "isUnique": false + }, + "idx_events_site_host_created": { + "name": "idx_events_site_host_created", + "columns": [ + "site_id", + "hostname", + "created_at" + ], + "isUnique": false + }, + "idx_events_site_created_browser": { + "name": "idx_events_site_created_browser", + "columns": [ + "site_id", + "created_at", + "browser" + ], + "isUnique": false + }, + "idx_events_site_created_os": { + "name": "idx_events_site_created_os", + "columns": [ + "site_id", + "created_at", + "os" + ], + "isUnique": false + }, + "idx_events_site_created_region": { + "name": "idx_events_site_created_region", + "columns": [ + "site_id", + "created_at", + "region" + ], + "isUnique": false + }, + "idx_events_site_created_network": { + "name": "idx_events_site_created_network", + "columns": [ + "site_id", + "created_at", + "network" + ], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "experiments": { + "name": "experiments", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "site_id": { + "name": "site_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "flag_key": { + "name": "flag_key", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "variants": { + "name": "variants", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "active": { + "name": "active", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 1 + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "idx_experiments_site": { + "name": "idx_experiments_site", + "columns": [ + "site_id" + ], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "flags": { + "name": "flags", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "site_id": { + "name": "site_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "flag_key": { + "name": "flag_key", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "enabled": { + "name": "enabled", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 1 + }, + "default_variant": { + "name": "default_variant", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "variants": { + "name": "variants", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "rules": { + "name": "rules", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'[]'" + }, + "salt": { + "name": "salt", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "rollout_seed": { + "name": "rollout_seed", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "version": { + "name": "version", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 1 + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "idx_flags_site": { + "name": "idx_flags_site", + "columns": [ + "site_id" + ], + "isUnique": false + }, + "idx_flags_site_key": { + "name": "idx_flags_site_key", + "columns": [ + "site_id", + "flag_key" + ], + "isUnique": true + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "funnels": { + "name": "funnels", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "site_id": { + "name": "site_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "steps": { + "name": "steps", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "idx_funnels_site": { + "name": "idx_funnels_site", + "columns": [ + "site_id" + ], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "goals": { + "name": "goals", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "site_id": { + "name": "site_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "match_value": { + "name": "match_value", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "idx_goals_site": { + "name": "idx_goals_site", + "columns": [ + "site_id" + ], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "identity_salts": { + "name": "identity_salts", + "columns": { + "scope": { + "name": "scope", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "salt": { + "name": "salt", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "window": { + "name": "window", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "window_end": { + "name": "window_end", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "idx_identity_salts_window_end": { + "name": "idx_identity_salts_window_end", + "columns": [ + "window_end" + ], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "memberships": { + "name": "memberships", + "columns": { + "team_id": { + "name": "team_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "role": { + "name": "role", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "idx_memberships_user": { + "name": "idx_memberships_user", + "columns": [ + "user_id" + ], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": { + "memberships_team_id_user_id_pk": { + "columns": [ + "team_id", + "user_id" + ], + "name": "memberships_team_id_user_id_pk" + } + }, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "mmr_checkpoints": { + "name": "mmr_checkpoints", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": true + }, + "tree_size": { + "name": "tree_size", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "root": { + "name": "root", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "signed": { + "name": "signed", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "mmr_leaves": { + "name": "mmr_leaves", + "columns": { + "leaf_no": { + "name": "leaf_no", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "node_index": { + "name": "node_index", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "rollup_key": { + "name": "rollup_key", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "leaf_hash": { + "name": "leaf_hash", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "mmr_leaves_rollup_key_unique": { + "name": "mmr_leaves_rollup_key_unique", + "columns": [ + "rollup_key" + ], + "isUnique": true + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "mmr_nodes": { + "name": "mmr_nodes", + "columns": { + "node_index": { + "name": "node_index", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "hash": { + "name": "hash", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "salts": { + "name": "salts", + "columns": { + "day_key": { + "name": "day_key", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "salt": { + "name": "salt", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "scitt_log": { + "name": "scitt_log", + "columns": { + "entry_id": { + "name": "entry_id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": true + }, + "statement_hash": { + "name": "statement_hash", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "registered_at": { + "name": "registered_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "sessions": { + "name": "sessions", + "columns": { + "site_id": { + "name": "site_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "visitor_hash": { + "name": "visitor_hash", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "day_key": { + "name": "day_key", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "first_seen": { + "name": "first_seen", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": { + "sessions_site_id_visitor_hash_day_key_pk": { + "columns": [ + "site_id", + "visitor_hash", + "day_key" + ], + "name": "sessions_site_id_visitor_hash_day_key_pk" + } + }, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "site_config": { + "name": "site_config", + "columns": { + "site_id": { + "name": "site_id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "tier": { + "name": "tier", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'anonymous'" + }, + "salt_window": { + "name": "salt_window", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'day'" + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "sites": { + "name": "sites", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "domain": { + "name": "domain", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "team_id": { + "name": "team_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "teams": { + "name": "teams", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "users": { + "name": "users", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "last_login": { + "name": "last_login", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "session_epoch": { + "name": "session_epoch", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + } + }, + "indexes": { + "users_email_unique": { + "name": "users_email_unique", + "columns": [ + "email" + ], + "isUnique": true + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + } + }, + "views": {}, + "enums": {}, + "_meta": { + "schemas": {}, + "tables": {}, + "columns": {} + }, + "internal": { + "indexes": {} + } +} \ No newline at end of file diff --git a/apps/server/migrations/meta/_journal.json b/apps/server/migrations/meta/_journal.json index bcc297f..5208933 100644 --- a/apps/server/migrations/meta/_journal.json +++ b/apps/server/migrations/meta/_journal.json @@ -92,6 +92,13 @@ "when": 1785567975307, "tag": "0013_mysterious_alice", "breakpoints": true + }, + { + "idx": 14, + "version": "6", + "when": 1785923479389, + "tag": "0014_bored_crystal", + "breakpoints": true } ] } \ No newline at end of file diff --git a/apps/server/src/db/schema.ts b/apps/server/src/db/schema.ts index 06e5183..d76a9ff 100644 --- a/apps/server/src/db/schema.ts +++ b/apps/server/src/db/schema.ts @@ -23,13 +23,26 @@ export const sites = sqliteTable('sites', { // ── Accounts & RBAC (Phase 4). Dashboard operators — NOT tracked visitors — so this is orthogonal to // the cookieless/no-cross-session-identity visitor model. Passwordless: no password column ever. ── -/** A dashboard user, identified by email. No password is stored — auth is magic-link + passkeys. */ +/** + * A dashboard user, identified by email. No password is stored — auth is magic-link + passkeys. + * + * `session_epoch` is what makes a session revocable. Session tokens are HMAC-signed and carry their + * own expiry, so nothing about the token itself can be withdrawn once it is out: logging out clears a + * cookie in one browser and leaves a copied token valid for the rest of its 30 days. Every token + * carries the epoch it was signed at, and a session resolves only while that still equals this + * column — so incrementing it ends every outstanding session for this person at once, without a + * session table and without a lookup per token beyond the one the RBAC path already performs. + * + * A counter rather than a timestamp: two revocations in the same millisecond are two revocations, and + * a clock that moves backwards must not resurrect a session. + */ export const users = sqliteTable('users', { id: text('id').primaryKey(), email: text('email').notNull().unique(), name: text('name'), createdAt: integer('created_at').notNull(), lastLogin: integer('last_login'), + sessionEpoch: integer('session_epoch').notNull().default(0), }); /** A team that owns sites; members access them through a role. */ diff --git a/apps/server/src/lib/accounts.ts b/apps/server/src/lib/accounts.ts index 196c98e..97284b7 100644 --- a/apps/server/src/lib/accounts.ts +++ b/apps/server/src/lib/accounts.ts @@ -2,7 +2,7 @@ // tokens, and team roles. This is entirely separate from the cookieless VISITOR model — these are the // humans who log in to view analytics. No password is ever stored; only a SHA-256 of a one-time token. -import { and, eq, inArray } from 'drizzle-orm'; +import { and, eq, inArray, sql } from 'drizzle-orm'; import { db } from '../db/queries.js'; import * as schema from '../db/schema.js'; import type { Env } from '../env.js'; @@ -34,6 +34,15 @@ export interface SessionPayload { sub: string; /** Expiry, unix ms. */ exp: number; + /** + * The user's `session_epoch` at the moment this token was signed. A session is live only while it + * still matches the column, which is what makes an issued token withdrawable at all. + * + * REQUIRED, and a token without it is rejected rather than read as epoch 0. A token minted before + * this existed cannot be checked against a revocation, and "we cannot tell whether this was + * revoked" has to mean revoked. The price is that every operator signs in once after the deploy. + */ + epoch: number; } const TOKEN_TTL_MS = 15 * 60 * 1000; // magic-link validity @@ -61,9 +70,15 @@ function hmacKey(secret: string): Promise { ); } -/** Sign a session token (`.`, both base64url). Pure — secret is passed in. */ -export async function signSession(sub: string, secret: string, now: number): Promise { - const payload: SessionPayload = { sub, exp: now + SESSION_TTL_MS }; +/** Sign a session token (`.`, both base64url). Pure — secret and epoch are passed in, + * so the one caller that mints a session is the one that has just read the user. */ +export async function signSession( + sub: string, + secret: string, + now: number, + epoch: number, +): Promise { + const payload: SessionPayload = { sub, exp: now + SESSION_TTL_MS, epoch }; const body = b64url(new TextEncoder().encode(JSON.stringify(payload))); const sig = new Uint8Array( await crypto.subtle.sign('HMAC', await hmacKey(secret), new TextEncoder().encode(body)), @@ -71,7 +86,15 @@ export async function signSession(sub: string, secret: string, now: number): Pro return `${body}.${b64url(sig)}`; } -/** Verify a session token's signature + expiry, returning its claims or null. */ +/** + * Verify a session token's signature + expiry, returning its claims or null. + * + * Pure, and NEVER sufficient on its own: it proves the token was issued by this deployment and has + * not expired, not that the session is still live. Only `sessionUser` can answer that, because only a + * read of `session_epoch` can. Split for the same reason `verifyConsentRecord` is split from + * `findActiveConsent` — the cryptographic check is testable in isolation, and keeping it separate + * makes it obvious that something else has to follow it. + */ export async function verifySession( token: string, secret: string, @@ -98,6 +121,9 @@ export async function verifySession( if ( typeof payload.sub !== 'string' || typeof payload.exp !== 'number' || + // A token carrying no epoch cannot be compared against a revocation, and an unverifiable + // revocation state must read as revoked. This is what signs everyone out once on deploy. + typeof payload.epoch !== 'number' || payload.exp < now ) { return null; @@ -149,15 +175,83 @@ export async function consumeMagicToken( return row.email; } +/** An authenticated operator, as every session-resolving path receives them. Carries the columns + * `/api/auth/me` needs, so resolving a session and describing the user are one read rather than two. */ +export interface SessionUser { + id: string; + email: string; + name: string | null; +} + +/** + * THE ONLY WAY TO RESOLVE A SESSION. Verifies the token cryptographically, then confirms the session + * has not been revoked by comparing its epoch against the user's current one. + * + * Both halves live here rather than at the call sites because a signature check that is not followed + * by the epoch check silently restores the old behaviour — an unrevocable 30-day token — and does so + * at whichever route forgot, which is exactly the failure nobody notices. `verifySession` alone + * cannot authorize anything; this returns the user or null, and there is no third option. + * + * A missing user row is null too: an account that no longer exists has no live sessions, whatever its + * tokens still say. + * + * COST. One read, and it returns the row rather than just the id, so `/api/auth/me` spends no more + * queries than before. On the RBAC path `siteRole` is now a single joined statement instead of two, + * so the epoch check is paid for out of a round trip that was already there. + */ +export async function sessionUser( + env: Env, + token: string | undefined, + secret: string, + now: number, +): Promise { + if (!token) return null; + const payload = await verifySession(token, secret, now); + if (!payload) return null; + const row = await db(env) + .select({ + id: schema.users.id, + email: schema.users.email, + name: schema.users.name, + sessionEpoch: schema.users.sessionEpoch, + }) + .from(schema.users) + .where(eq(schema.users.id, payload.sub)) + .get(); + if (!row || row.sessionEpoch !== payload.epoch) return null; + return { id: row.id, email: row.email, name: row.name }; +} + +/** + * End every session this user currently holds, by moving the epoch past the one their tokens carry. + * Returns false when there is no such user. + * + * The increment is done in SQL rather than read-then-write: two revocations racing must both land, + * and a read-modify-write would let the second overwrite the first with the same value — which reads + * as success while leaving the sessions the second one was called to kill still alive. + */ +export async function revokeSessions(env: Env, userId: string): Promise { + const rows = await db(env) + .update(schema.users) + .set({ sessionEpoch: sql`${schema.users.sessionEpoch} + 1` }) + .where(eq(schema.users.id, userId)) + .returning({ id: schema.users.id }); + return rows.length > 0; +} + /** Find or create a user by email. A brand-new user gets a personal team + an `owner` membership. */ export async function upsertUserByEmail( env: Env, email: string, now: number, -): Promise<{ id: string; email: string }> { +): Promise<{ id: string; email: string; sessionEpoch: number }> { const e = email.toLowerCase(); const existing = await db(env) - .select({ id: schema.users.id, email: schema.users.email }) + .select({ + id: schema.users.id, + email: schema.users.email, + sessionEpoch: schema.users.sessionEpoch, + }) .from(schema.users) .where(eq(schema.users.email, e)) .get(); @@ -179,7 +273,8 @@ export async function upsertUserByEmail( await db(env) .insert(schema.memberships) .values({ teamId, userId, role: 'owner', createdAt: now }); - return { id: userId, email: e }; + // A brand-new row starts at the column default, and the token minted from this must agree. + return { id: userId, email: e, sessionEpoch: 0 }; } /** @@ -228,21 +323,22 @@ export async function userMemberships( .map((r) => ({ teamId: r.teamId, role: r.role as Role })); } -/** The role a user holds on the team that owns `siteId`, or null if the site is unowned or the user is - * not a member. Used to gate dashboard (session) access to a site's analytics. */ +/** + * The role a user holds on the team that owns `siteId`, or null if the site is unowned or the user is + * not a member. Used to gate dashboard (session) access to a site's analytics. + * + * ONE statement, joining the membership to the site that points at its team. It was two sequential + * reads — the site, then the membership — which is two round trips to answer a question SQLite + * answers in one, on the path every session-authenticated request takes. The inner join is also what + * expresses "unowned site grants nobody anything": a NULL `team_id` matches no membership row, so + * that case needs no separate branch to get right. + */ export async function siteRole(env: Env, userId: string, siteId: string): Promise { - const site = await db(env) - .select({ teamId: schema.sites.teamId }) - .from(schema.sites) - .where(eq(schema.sites.id, siteId)) - .get(); - if (!site?.teamId) return null; const m = await db(env) .select({ role: schema.memberships.role }) .from(schema.memberships) - .where( - and(eq(schema.memberships.teamId, site.teamId), eq(schema.memberships.userId, userId)), - ) + .innerJoin(schema.sites, eq(schema.sites.teamId, schema.memberships.teamId)) + .where(and(eq(schema.sites.id, siteId), eq(schema.memberships.userId, userId))) .get(); return m && isRole(m.role) ? m.role : null; } diff --git a/apps/server/src/lib/auth.ts b/apps/server/src/lib/auth.ts index af06992..a56dcfa 100644 --- a/apps/server/src/lib/auth.ts +++ b/apps/server/src/lib/auth.ts @@ -8,7 +8,7 @@ import { getCookie } from 'hono/cookie'; import { db } from '../db/queries.js'; import * as schema from '../db/schema.js'; import type { AppEnv, Env } from '../env.js'; -import { type Role, SESSION_COOKIE, roleAtLeast, siteRole, verifySession } from './accounts.js'; +import { type Role, SESSION_COOKIE, roleAtLeast, sessionUser, siteRole } from './accounts.js'; import { hashKey } from './apikeys.js'; import { constantTimeEqualHex, sha256Hex } from './crypto.js'; import { ApiError } from './http.js'; @@ -74,11 +74,10 @@ export const requireSiteAccess: MiddlewareHandler = async (c, next) => { return next(); } const secret = c.env.SESSION_SECRET; - const token = getCookie(c, SESSION_COOKIE); const siteId = c.req.query('site_id'); - if (secret && token && siteId) { - const payload = await verifySession(token, secret, Date.now()); - if (payload && (await siteRole(c.env, payload.sub, siteId))) { + if (secret && siteId) { + const user = await sessionUser(c.env, getCookie(c, SESSION_COOKIE), secret, Date.now()); + if (user && (await siteRole(c.env, user.id, siteId))) { c.set('siteId', siteId); return next(); } @@ -108,21 +107,20 @@ export function requireTeamRole(need: Role): MiddlewareHandler { if (!secret) { throw new ApiError('auth_unavailable', 503, 'account auth is not configured'); } - const token = getCookie(c, SESSION_COOKIE); const siteId = c.req.query('site_id'); - if (!token || !siteId) { + if (!siteId) { throw new ApiError('unauthorized', 401); } - const payload = await verifySession(token, secret, Date.now()); - if (!payload) { + const user = await sessionUser(c.env, getCookie(c, SESSION_COOKIE), secret, Date.now()); + if (!user) { throw new ApiError('unauthorized', 401); } - const role = await siteRole(c.env, payload.sub, siteId); + const role = await siteRole(c.env, user.id, siteId); if (!role || !roleAtLeast(role, need)) { throw new ApiError('forbidden', 403); } c.set('siteId', siteId); - c.set('userId', payload.sub); + c.set('userId', user.id); c.set('role', role); return next(); }; diff --git a/apps/server/src/routes/auth.ts b/apps/server/src/routes/auth.ts index 922a0cc..d665840 100644 --- a/apps/server/src/routes/auth.ts +++ b/apps/server/src/routes/auth.ts @@ -2,23 +2,26 @@ // exchanges it for an HMAC-signed session cookie; GET /me returns the signed-in user + team roles; POST // /logout clears the cookie. All account auth is gated on SESSION_SECRET — absent, these return 503 and // the per-site API-key path (beacon, programmatic stats) is entirely unaffected. +// +// /logout and /logout-everywhere are not two spellings of one thing. A session token is HMAC-signed and +// self-contained, so deleting the cookie ends the session in THAT browser and does nothing to a token +// already copied out of it — which stays valid for the rest of its thirty days. Only the second route +// withdraws a token that is already out, by moving the user past the epoch every one of them carries. import { vValidator } from '@hono/valibot-validator'; -import { eq } from 'drizzle-orm'; import { Hono } from 'hono'; import { deleteCookie, getCookie, setCookie } from 'hono/cookie'; import * as v from 'valibot'; -import { db } from '../db/queries.js'; -import * as schema from '../db/schema.js'; import type { AppEnv } from '../env.js'; import { SESSION_COOKIE, consumeMagicToken, createMagicToken, + revokeSessions, + sessionUser, signSession, upsertUserByEmail, userMemberships, - verifySession, } from '../lib/accounts.js'; import { requireAdmin } from '../lib/auth.js'; import { ApiError, validationErrorHook } from '../lib/http.js'; @@ -74,7 +77,7 @@ authRoutes.post('/verify', vValidator('json', VerifySchema, validationErrorHook) throw new ApiError('invalid_token', 401, 'the link is invalid, used, or expired'); } const user = await upsertUserByEmail(c.env, email, now); - const session = await signSession(user.id, secret, now); + const session = await signSession(user.id, secret, now, user.sessionEpoch); setCookie(c, SESSION_COOKIE, session, { httpOnly: true, secure: true, @@ -88,20 +91,9 @@ authRoutes.post('/verify', vValidator('json', VerifySchema, validationErrorHook) // The signed-in user and their team roles, or 401. authRoutes.get('/me', async (c) => { const secret = requireSecret(c.env); - const token = getCookie(c, SESSION_COOKIE); - const payload = token ? await verifySession(token, secret, Date.now()) : null; - if (!payload) { - throw new ApiError('unauthenticated', 401); - } - const user = await db(c.env) - .select({ - id: schema.users.id, - email: schema.users.email, - name: schema.users.name, - }) - .from(schema.users) - .where(eq(schema.users.id, payload.sub)) - .get(); + // `sessionUser` reads the row it verifies against, so describing the operator costs nothing beyond + // the check itself — this route spends the same one query it did before the epoch existed. + const user = await sessionUser(c.env, getCookie(c, SESSION_COOKIE), secret, Date.now()); if (!user) { throw new ApiError('unauthenticated', 401); } @@ -112,3 +104,28 @@ authRoutes.post('/logout', (c) => { deleteCookie(c, SESSION_COOKIE, { path: '/' }); return c.body(null, 204); }); + +/** + * End every session this operator holds, anywhere — the one thing `/logout` cannot do. + * + * `/logout` deletes a cookie in the browser that asked. It does nothing to a token already copied out + * of that browser, which stays valid for the rest of its thirty days; for a session someone suspects + * has been stolen, clearing the cookie is not a remedy at all. This moves the user's epoch past the + * one every outstanding token carries, so all of them stop resolving at once — including the one + * making this request, whose cookie is cleared too so the browser is not left holding a dead session + * it believes in. + * + * Deliberately all-or-nothing. Facet keeps no session table, so there is no list of devices to revoke + * from and no per-device granularity to offer; the honest control is the one that ends everything, + * which is also the one someone reaching for it actually wants. + */ +authRoutes.post('/logout-everywhere', async (c) => { + const secret = requireSecret(c.env); + const user = await sessionUser(c.env, getCookie(c, SESSION_COOKIE), secret, Date.now()); + if (!user) { + throw new ApiError('unauthenticated', 401); + } + await revokeSessions(c.env, user.id); + deleteCookie(c, SESSION_COOKIE, { path: '/' }); + return c.body(null, 204); +}); diff --git a/apps/server/test/accounts.test.ts b/apps/server/test/accounts.test.ts index 4878aba..4cfdd55 100644 --- a/apps/server/test/accounts.test.ts +++ b/apps/server/test/accounts.test.ts @@ -9,6 +9,7 @@ import { consumeMagicToken, createMagicToken, isRole, + revokeSessions, roleAtLeast, signSession, upsertUserByEmail, @@ -20,11 +21,11 @@ const SECRET = 'unit-secret'; describe('session tokens', () => { it('round-trips a valid session', async () => { - const t = await signSession('user-1', SECRET, 1_000_000); + const t = await signSession('user-1', SECRET, 1_000_000, 0); expect((await verifySession(t, SECRET, 1_001_000))?.sub).toBe('user-1'); }); it('rejects tampering, a wrong secret, and expiry', async () => { - const t = await signSession('user-1', SECRET, 0); + const t = await signSession('user-1', SECRET, 0, 0); expect(await verifySession(`${t}x`, SECRET, 1)).toBeNull(); expect(await verifySession(t, 'wrong', 1)).toBeNull(); expect(await verifySession(t, SECRET, 40 * 24 * 60 * 60 * 1000)).toBeNull(); @@ -167,13 +168,13 @@ describe('requireSiteAccess (session RBAC on /api/stats)', () => { ) .bind(siteId, 'S', 's.test', now, teamId) .run(); - const memberCookie = `${SESSION_COOKIE}=${await signSession(member.id, secret, now)}`; + const memberCookie = `${SESSION_COOKIE}=${await signSession(member.id, secret, now, member.sessionEpoch)}`; const ok = await app.request(`/api/stats${q}`, { headers: { cookie: memberCookie } }, env); expect(ok.status).toBe(200); // A user with no membership on this site's team is blocked. const outsider = await upsertUserByEmail(env, 'outsider@example.com', now); - const outsiderCookie = `${SESSION_COOKIE}=${await signSession(outsider.id, secret, now)}`; + const outsiderCookie = `${SESSION_COOKIE}=${await signSession(outsider.id, secret, now, outsider.sessionEpoch)}`; const blocked = await app.request( `/api/stats${q}`, { headers: { cookie: outsiderCookie } }, @@ -182,3 +183,119 @@ describe('requireSiteAccess (session RBAC on /api/stats)', () => { expect(blocked.status).toBe(401); }); }); + +// Session revocation. A session token is HMAC-signed and self-contained, so nothing about the token +// itself can be withdrawn once it is out — /logout clears a cookie in one browser and leaves a copied +// token valid for the rest of its thirty days. The epoch is what makes it withdrawable, and these are +// the four properties that have to hold for it to mean anything. +describe('revoking sessions', () => { + const app = createApp(); + const secret = env.SESSION_SECRET as string; + + /** A live session cookie for a fresh operator. */ + async function signedIn(email: string): Promise<{ id: string; cookie: string }> { + const now = Date.now(); + const user = await upsertUserByEmail(env, email, now); + return { + id: user.id, + cookie: `${SESSION_COOKIE}=${await signSession(user.id, secret, now, user.sessionEpoch)}`, + }; + } + + const me = (cookie: string) => app.request('/api/auth/me', { headers: { cookie } }, env); + + it('ends every outstanding session at once, not just the one that asked', async () => { + const user = await signedIn('two-devices@example.com'); + // A second token for the same person: the other browser, or the copy someone took. + const other = `${SESSION_COOKIE}=${await signSession(user.id, secret, Date.now(), 0)}`; + expect((await me(user.cookie)).status).toBe(200); + expect((await me(other)).status).toBe(200); + + const res = await app.request( + '/api/auth/logout-everywhere', + { method: 'POST', headers: { cookie: user.cookie } }, + env, + ); + expect(res.status).toBe(204); + // The point of the whole design: the token this request never saw is dead too. + expect((await me(other)).status).toBe(401); + expect((await me(user.cookie)).status).toBe(401); + // And the browser that asked is not left holding a session it believes in. + expect(res.headers.get('set-cookie')).toContain(`${SESSION_COOKIE}=;`); + }); + + it('leaves everyone else signed in', async () => { + // Revocation is per user. A shared epoch would make one person's stolen laptop everybody's + // forced sign-in. + const alice = await signedIn('alice@example.com'); + const bob = await signedIn('bob@example.com'); + await app.request( + '/api/auth/logout-everywhere', + { method: 'POST', headers: { cookie: alice.cookie } }, + env, + ); + expect((await me(alice.cookie)).status).toBe(401); + expect((await me(bob.cookie)).status).toBe(200); + }); + + it('signs the next session in at the new epoch, so revocation is not permanent', async () => { + const user = await signedIn('again@example.com'); + await revokeSessions(env, user.id); + expect((await me(user.cookie)).status).toBe(401); + + // Signing back in must work. An epoch read at mint time that went stale would lock the account + // out of itself — revocation has to end the old sessions, not the ability to have one. + const token = await createMagicToken(env, 'again@example.com', Date.now()); + const verified = await app.request( + '/api/auth/verify', + { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ token }), + }, + env, + ); + expect(verified.status).toBe(200); + const fresh = (verified.headers.get('set-cookie') ?? '').split(';')[0] ?? ''; + expect((await me(fresh)).status).toBe(200); + // The old one stays dead: signing in again does not resurrect what was revoked. + expect((await me(user.cookie)).status).toBe(401); + }); + + it('refuses a token that carries no epoch at all', async () => { + // What a session minted before revocation existed looks like. It cannot be compared against a + // revocation, and an unverifiable revocation state has to read as revoked. + const body = btoa(JSON.stringify({ sub: 'anyone', exp: Date.now() + 60_000 })) + .replace(/\+/g, '-') + .replace(/\//g, '_') + .replace(/=+$/, ''); + const key = await crypto.subtle.importKey( + 'raw', + new TextEncoder().encode(secret), + { name: 'HMAC', hash: 'SHA-256' }, + false, + ['sign'], + ); + const sig = new Uint8Array( + await crypto.subtle.sign('HMAC', key, new TextEncoder().encode(body)), + ); + const legacy = `${body}.${btoa(String.fromCharCode(...sig)) + .replace(/\+/g, '-') + .replace(/\//g, '_') + .replace(/=+$/, '')}`; + // Genuinely signed by this deployment — it fails on the missing claim, not on the signature. + expect(await verifySession(legacy, secret, Date.now())).toBeNull(); + }); + + it('ends the sessions of a user whose account is deleted', async () => { + const user = await signedIn('deleted@example.com'); + expect((await me(user.cookie)).status).toBe(200); + await env.DB.prepare('DELETE FROM users WHERE id = ?').bind(user.id).run(); + // There is no epoch to compare against, and an account that does not exist holds no sessions. + expect((await me(user.cookie)).status).toBe(401); + }); + + it('reports nothing to revoke for an unknown user', async () => { + expect(await revokeSessions(env, 'no-such-user')).toBe(false); + }); +}); diff --git a/apps/server/test/crm-audit.test.ts b/apps/server/test/crm-audit.test.ts index f8371d6..3143f86 100644 --- a/apps/server/test/crm-audit.test.ts +++ b/apps/server/test/crm-audit.test.ts @@ -65,7 +65,10 @@ async function operator( .run(); await e.DB.prepare('UPDATE sites SET team_id = ? WHERE id = ?').bind(teamId, SITE).run(); const secret = e.SESSION_SECRET as string; - return { id: user.id, cookie: `${SESSION_COOKIE}=${await signSession(user.id, secret, now)}` }; + return { + id: user.id, + cookie: `${SESSION_COOKIE}=${await signSession(user.id, secret, now, user.sessionEpoch)}`, + }; } function crm(e: TestEnv, path: string, init: RequestInit = {}, cookie?: string) { @@ -510,20 +513,26 @@ describe('GET /api/crm/audit', () => { }); it('keeps the entry when the account behind it is gone, with no email to give it', async () => { - const { id: userId, cookie } = await operator(env, 'admin@example.com', 'admin'); - await crm(env, '/contacts', {}, cookie); - // A closed account cannot be given a name back, and inventing one would be worse than the - // blank. The id stays either way, so the entry still says *someone specific* did this. - await env.DB.prepare('DELETE FROM users WHERE id = ?').bind(userId).run(); - // The session outlives the row, which is what makes this reachable at all: sessions are - // stateless and verify against the secret, not against a user lookup. - const res = await crm(env, '/audit', {}, cookie); + // A colleague who has since left. Their entry stays — the log records what operators did, and + // a closed account cannot be given a name back. Inventing one would be worse than the blank, + // and the id stays either way, so the entry still says *someone specific* did this. + const departed = await upsertUserByEmail(env, 'departed@example.com', Date.now()); + const { cookie } = await operator(env, 'admin@example.com', 'admin'); + await (env.CRM_DB as D1Database) + .prepare( + 'INSERT INTO crm_audit_log (id, site_id, actor_user_id, actor_role, action, target_id, occurred_at) VALUES (?, ?, ?, ?, ?, ?, ?)', + ) + .bind('gone', SITE, departed.id, 'analyst', 'contact.export', 'c1', Date.now()) + .run(); + await env.DB.prepare('DELETE FROM users WHERE id = ?').bind(departed.id).run(); + + const res = await crm(env, '/audit?action=contact.export', {}, cookie); const body = (await res.json()) as { entries: (AuditRow & { actor_email: string | null })[]; }; - expect(body.entries.length).toBeGreaterThan(0); - expect(body.entries.every((e) => e.actor_user_id === userId)).toBe(true); - expect(body.entries.every((e) => e.actor_email === null)).toBe(true); + expect(body.entries).toHaveLength(1); + expect(body.entries[0]?.actor_user_id).toBe(departed.id); + expect(body.entries[0]?.actor_email).toBeNull(); }); it('resolves a full page of distinct actors', async () => { diff --git a/apps/server/test/crm.test.ts b/apps/server/test/crm.test.ts index e2ebfbb..96b4b72 100644 --- a/apps/server/test/crm.test.ts +++ b/apps/server/test/crm.test.ts @@ -63,7 +63,7 @@ async function operator(e: TestEnv, email: string, role: string): Promise { await operator(env, 'owner@example.com', 'owner'); const now = Date.now(); const outsider = await upsertUserByEmail(env, 'outsider@example.com', now); - const cookie = `${SESSION_COOKIE}=${await signSession(outsider.id, env.SESSION_SECRET as string, now)}`; + const cookie = `${SESSION_COOKIE}=${await signSession(outsider.id, env.SESSION_SECRET as string, now, outsider.sessionEpoch)}`; expect((await crm(env, '/contacts', {}, cookie)).status).toBe(403); }); }); From 51fc69b0e66759b6d39af36d122510bf963d4257 Mon Sep 17 00:00:00 2001 From: David Condrey Date: Wed, 5 Aug 2026 03:07:07 -0700 Subject: [PATCH 2/3] fix(server): create a user and their owning membership in one transaction MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit /audit-file on lib/accounts.ts. The three inserts that bootstrap an account ran as separate statements, so a failure between them left a user row with no team — and permanently, because every later login takes the `existing` branch and returns without ever looking for a membership. That operator sees no teams from /api/auth/me and siteRole answers null for every site, while the users table looks perfectly healthy. One batch, which for D1 is one transaction. Same reasoning and same remedy as deleteCompany, which already batches for exactly this reason. The covering test pins the invariant rather than the statement count: a refactor that drops the membership insert fails it. Mutation-checked by removing that insert, which fails it and three existing tests. --- apps/server/src/lib/accounts.ts | 23 ++++++++++++++--------- apps/server/test/accounts.test.ts | 19 +++++++++++++++++++ 2 files changed, 33 insertions(+), 9 deletions(-) diff --git a/apps/server/src/lib/accounts.ts b/apps/server/src/lib/accounts.ts index 97284b7..ae63d8d 100644 --- a/apps/server/src/lib/accounts.ts +++ b/apps/server/src/lib/accounts.ts @@ -264,15 +264,20 @@ export async function upsertUserByEmail( } const userId = randomHex(12); const teamId = randomHex(12); - await db(env) - .insert(schema.users) - .values({ id: userId, email: e, createdAt: now, lastLogin: now }); - await db(env) - .insert(schema.teams) - .values({ id: teamId, name: `${e}'s team`, createdAt: now }); - await db(env) - .insert(schema.memberships) - .values({ teamId, userId, role: 'owner', createdAt: now }); + const client = db(env); + // ONE batch, which for D1 is one transaction. Run as three separate statements, a failure between + // them leaves a user row with no team — and that state is permanent rather than merely wrong, + // because every later login takes the `existing` branch above and returns without ever looking for + // a membership. The operator then sees no teams from /api/auth/me and `siteRole` answers null for + // every site, while the users table looks perfectly healthy. Same reasoning, same remedy as + // `deleteCompany`: two writes that must both land are one statement or they are a latent bug. + await client.batch([ + client + .insert(schema.users) + .values({ id: userId, email: e, createdAt: now, lastLogin: now }), + client.insert(schema.teams).values({ id: teamId, name: `${e}'s team`, createdAt: now }), + client.insert(schema.memberships).values({ teamId, userId, role: 'owner', createdAt: now }), + ]); // A brand-new row starts at the column default, and the token minted from this must agree. return { id: userId, email: e, sessionEpoch: 0 }; } diff --git a/apps/server/test/accounts.test.ts b/apps/server/test/accounts.test.ts index 4cfdd55..c491c45 100644 --- a/apps/server/test/accounts.test.ts +++ b/apps/server/test/accounts.test.ts @@ -299,3 +299,22 @@ describe('revoking sessions', () => { expect(await revokeSessions(env, 'no-such-user')).toBe(false); }); }); + +describe('account bootstrap', () => { + it('never leaves a user without the team that makes them reachable', async () => { + // The three inserts are one batch, which for D1 is one transaction. Split apart, a failure + // between them strands a user who can never gain a team — every later login takes the + // `existing` branch and returns early, so nothing repairs it and the users table still looks + // healthy. This pins the invariant rather than the statement count: a refactor that drops the + // membership insert fails here. + const user = await upsertUserByEmail(env, 'bootstrap@example.com', Date.now()); + const memberships = await userMemberships(env, user.id); + expect(memberships).toHaveLength(1); + expect(memberships[0]?.role).toBe('owner'); + // And the team the membership names actually exists, rather than pointing at nothing. + const team = await env.DB.prepare('SELECT id FROM teams WHERE id = ?') + .bind(memberships[0]?.teamId) + .first<{ id: string }>(); + expect(team?.id).toBe(memberships[0]?.teamId); + }); +}); From 2fd7e89459fdd4d75208990d4a3c35f78c9c2d98 Mon Sep 17 00:00:00 2001 From: David Condrey Date: Wed, 5 Aug 2026 03:12:29 -0700 Subject: [PATCH 3/3] feat(server): let a deployment admin end another operator's sessions MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit POST /api/users/:id/revoke-sessions, behind ADMIN_TOKEN. Deferred by the accounts.ts audit and worth closing now: revocation existed only as /api/auth/logout-everywhere, so the only person who could end a session was the operator holding it — precisely the wrong person when the question is whether that session was stolen. The CRM audit log names the operator; this is the lever it points at. Behind the admin token rather than a team role deliberately. Team admins have no user-management surface at all today — they cannot list their members, rename them or remove them — so a route reaching across to another person's sessions would be the first of its kind, arriving without any of the structure that should come with it. 404 rather than a cheerful 200 for an unknown id, so a typo is never reported as a revocation that did not happen. Idempotent: revoking twice is two epochs and the same outcome. Documented both revocation routes in docs/api.md, including the two consequences an operator will actually meet: pre-upgrade tokens carry no epoch and are rejected, so everyone signs in once; and deleting a user ends their sessions, since an account that does not exist holds none. --- apps/server/src/routes/admin.ts | 26 ++++++++++++++++ apps/server/test/accounts.test.ts | 49 +++++++++++++++++++++++++++++++ docs/api.md | 39 ++++++++++++++++++++++-- 3 files changed, 112 insertions(+), 2 deletions(-) diff --git a/apps/server/src/routes/admin.ts b/apps/server/src/routes/admin.ts index 6a88043..ba67bfe 100644 --- a/apps/server/src/routes/admin.ts +++ b/apps/server/src/routes/admin.ts @@ -16,6 +16,7 @@ import { siteExists } from '../db/catalog.js'; import { db } from '../db/queries.js'; import * as schema from '../db/schema.js'; import type { AppEnv } from '../env.js'; +import { revokeSessions } from '../lib/accounts.js'; import { issueKey, listKeys, revokeKey } from '../lib/apikeys.js'; import { requireAdmin } from '../lib/auth.js'; import { ApiError, validationErrorHook } from '../lib/http.js'; @@ -143,6 +144,31 @@ adminRoutes.patch( }, ); +/** + * End every session an operator holds. The lever the CRM audit log points at. + * + * The log names the operator whose session read the contact table; without this, the only person who + * could act on that was the operator themselves, which is precisely the wrong person when the + * question is whether their session was stolen. `/api/auth/logout-everywhere` is the self-service + * form of the same call. + * + * Behind `ADMIN_TOKEN` rather than a team role, and that is a deliberate limit rather than an + * oversight. Team admins have no user-management surface at all today — they cannot list their + * members, rename them, or remove them — and a route that reaches across to another person's + * sessions would be the first thing of its kind, arriving without any of the structure that should + * come with it. Ending someone's sessions is a deployment-operator action until that exists. + * + * Idempotent: revoking twice is two epochs and the same outcome. `404` distinguishes "no such user" + * from "done", so a typo'd id is not silently reported as a revocation that never happened. + */ +adminRoutes.post('/users/:id/revoke-sessions', requireAdmin, async (c) => { + const userId = c.req.param('id') ?? ''; + if (!(await revokeSessions(c.env, userId))) { + return c.json({ error: 'not_found' }, 404); + } + return c.json({ user_id: userId, sessions_revoked: true }); +}); + adminRoutes.post( '/keys', requireAdmin, diff --git a/apps/server/test/accounts.test.ts b/apps/server/test/accounts.test.ts index c491c45..372f19c 100644 --- a/apps/server/test/accounts.test.ts +++ b/apps/server/test/accounts.test.ts @@ -318,3 +318,52 @@ describe('account bootstrap', () => { expect(team?.id).toBe(memberships[0]?.teamId); }); }); + +describe('admin session revocation', () => { + const app = createApp(); + + it('ends a named operator’s sessions, which is the lever the audit log points at', async () => { + // The log names the operator whose session read the contact table. Before this route the only + // person who could act on that was the operator themselves — the wrong person entirely when + // the question is whether their session was stolen. + const now = Date.now(); + const user = await upsertUserByEmail(env, 'suspect@example.com', now); + const secret = env.SESSION_SECRET as string; + const cookie = `${SESSION_COOKIE}=${await signSession(user.id, secret, now, user.sessionEpoch)}`; + expect((await app.request('/api/auth/me', { headers: { cookie } }, env)).status).toBe(200); + + const res = await app.request( + `/api/users/${user.id}/revoke-sessions`, + { method: 'POST', headers: { Authorization: 'Bearer test-admin-token' } }, + env, + ); + expect(res.status).toBe(200); + expect(await res.json()).toEqual({ user_id: user.id, sessions_revoked: true }); + expect((await app.request('/api/auth/me', { headers: { cookie } }, env)).status).toBe(401); + }); + + it('404s an unknown user rather than reporting a revocation that never happened', async () => { + const res = await app.request( + '/api/users/no-such-user/revoke-sessions', + { method: 'POST', headers: { Authorization: 'Bearer test-admin-token' } }, + env, + ); + expect(res.status).toBe(404); + }); + + it('refuses without the admin token, so it is not a session-authenticated route', async () => { + const now = Date.now(); + const user = await upsertUserByEmail(env, 'notadmin@example.com', now); + const secret = env.SESSION_SECRET as string; + const cookie = `${SESSION_COOKIE}=${await signSession(user.id, secret, now, user.sessionEpoch)}`; + // A team role is not enough and a session cookie is not a credential here. + const res = await app.request( + `/api/users/${user.id}/revoke-sessions`, + { method: 'POST', headers: { cookie } }, + env, + ); + expect(res.status).toBe(401); + // And the session it tried to revoke is untouched. + expect((await app.request('/api/auth/me', { headers: { cookie } }, env)).status).toBe(200); + }); +}); diff --git a/docs/api.md b/docs/api.md index 7ffc380..cd16b71 100644 --- a/docs/api.md +++ b/docs/api.md @@ -27,7 +27,8 @@ All endpoints live under `/api` on your deployment. Times are unix epoch **milli (`POST`/`GET /api/alerts`, `DELETE /api/alerts/:id`), and `POST /api/auth/admin-link` — **admin token**: `Authorization: Bearer `. - `POST /api/auth/request`, `POST /api/auth/verify` — **public** (dashboard sign-in); - `GET /api/auth/me`, `POST /api/auth/logout` — **session cookie**. All `503 auth_unavailable` + `GET /api/auth/me`, `POST /api/auth/logout`, `POST /api/auth/logout-everywhere` — **session + cookie**. All `503 auth_unavailable` unless `SESSION_SECRET` is bound. - `/api/crm/*` — **session cookie + team role**, and deliberately *not* an API key. This is the only authenticated surface that refuses `clk_` keys: they authorize aggregate analytics and are meant to @@ -1231,6 +1232,22 @@ curl -X DELETE "https://your-deployment.example.com/api/keys/22222222-2222-4222- { "deleted": true } ``` +### `POST /api/users/:id/revoke-sessions` (admin token) + +Ends every session the named operator holds — the same mechanism as `/api/auth/logout-everywhere`, +applied to someone else. Returns `{ "user_id": "...", "sessions_revoked": true }`, or `404` when +there is no such user, so a mistyped id is never reported as a revocation that did not happen. +Idempotent: revoking twice is two epochs and the same outcome. + +This is **the lever the CRM audit log points at.** The log names the operator whose session read the +contact table; without this route the only person who could act on that was that operator, which is +precisely the wrong person when the question is whether their session was stolen. + +It sits behind `ADMIN_TOKEN` rather than a team role, and that is a deliberate limit. Team admins +have no user-management surface at all today — they cannot list their members, rename them, or remove +them — so a route reaching across to another person's sessions would be the first of its kind, +arriving without any of the structure that should come with it. + --- ## Admin: alert destinations @@ -1309,7 +1326,25 @@ Returns `{ "user": …, "memberships": [...] }` for the signed-in user, or `401 ### `POST /api/auth/logout` (session cookie) -Clears the session cookie. Returns `204`. +Clears the session cookie. Returns `204`. **This ends the session in that browser only.** A session +token is HMAC-signed and self-contained, so a token already copied out of the browser keeps working +until it expires — deleting the cookie does nothing to it. + +### `POST /api/auth/logout-everywhere` (session cookie) + +Ends **every** session this operator holds, anywhere, and clears the cookie of the browser that +asked. Returns `204`, or `401 unauthenticated`. This is the remedy for a session you believe was +stolen; `/logout` is not. + +It works by moving the user's `session_epoch` past the one every outstanding token carries. Each +token records the epoch it was signed at and resolves only while the two still match, so one +increment ends all of them at once. Deliberately all-or-nothing: Facet keeps no session table, so +there is no device list to revoke from, and the honest control is the one that ends everything. + +Signing back in afterwards works normally — revocation ends the sessions, not the ability to have +one. Two consequences worth knowing: a token minted before this existed carries no epoch and is +rejected rather than assumed valid, so every operator signs in once after upgrading; and deleting a +user row also ends their sessions, since an account that does not exist holds none. ---