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..ae63d8d 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(); @@ -170,16 +264,22 @@ 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 }); - return { id: userId, email: e }; + 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 }; } /** @@ -228,21 +328,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/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/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..372f19c 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,187 @@ 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); + }); +}); + +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); + }); +}); + +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/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); }); }); 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. ---