From 96f0991605945972c8f7e8d11dcd3ec1668c8099 Mon Sep 17 00:00:00 2001 From: Lucas Berger Date: Wed, 17 Jun 2026 16:17:04 -0400 Subject: [PATCH] feat(19-01): add local_credentials schema, 0003 migration, generate-secrets LOCAL_SESSION_SECRET, .dockerignore D-15 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - schema.ts: export localCredentials = mysqlTable('local_credentials', {...}) - user_id FK->users(cascade), username, password_hash, createdAt, updatedAt - UNIQUE(user_id), UNIQUE(username), INDEX(user_id) - 0003_warm_deathstrike.sql: purely additive CREATE TABLE (no ALTER/DROP/TRUNCATE on existing tables) - Applied to dev DB: pnpm --filter @familysync/api db:migrate exits 0 - test/setup.ts: add localCredentials to afterEach delete cleanup (FK-safe ordering) - generate-secrets.mjs: emit LOCAL_SESSION_SECRET (base64 32-byte, >=32 chars, D-05) - .dockerignore: add apps/api/scripts/ exclusion (D-15/IMG-02) — entire break-glass dir excluded --- .dockerignore | 4 +- .../db/migrations/0003_warm_deathstrike.sql | 14 + .../src/db/migrations/meta/0003_snapshot.json | 1144 +++++++++++++++++ apps/api/src/db/migrations/meta/_journal.json | 7 + apps/api/src/db/schema.ts | 41 + apps/api/test/setup.ts | 5 +- scripts/generate-secrets.mjs | 5 + 7 files changed, 1218 insertions(+), 2 deletions(-) create mode 100644 apps/api/src/db/migrations/0003_warm_deathstrike.sql create mode 100644 apps/api/src/db/migrations/meta/0003_snapshot.json diff --git a/.dockerignore b/.dockerignore index abbfc41..09a9488 100644 --- a/.dockerignore +++ b/.dockerignore @@ -2,7 +2,9 @@ .env .env.* !.env.example -apps/api/scripts/seed-credential.mjs +# Phase 19 (D-15 / IMG-02): exclude the entire break-glass scripts directory so +# reset-admin.ts and any future dev-only scripts never ship in the production image. +apps/api/scripts/ # === VCS (large and unnecessary) === .git diff --git a/apps/api/src/db/migrations/0003_warm_deathstrike.sql b/apps/api/src/db/migrations/0003_warm_deathstrike.sql new file mode 100644 index 0000000..96711bf --- /dev/null +++ b/apps/api/src/db/migrations/0003_warm_deathstrike.sql @@ -0,0 +1,14 @@ +CREATE TABLE `local_credentials` ( + `id` int AUTO_INCREMENT NOT NULL, + `user_id` int NOT NULL, + `username` varchar(128) NOT NULL, + `password_hash` varchar(256) NOT NULL, + `created_at` timestamp NOT NULL DEFAULT (now()), + `updated_at` timestamp DEFAULT (now()) ON UPDATE CURRENT_TIMESTAMP, + CONSTRAINT `local_credentials_id` PRIMARY KEY(`id`), + CONSTRAINT `uniq_local_cred_user` UNIQUE(`user_id`), + CONSTRAINT `uniq_local_cred_username` UNIQUE(`username`) +); +--> statement-breakpoint +ALTER TABLE `local_credentials` ADD CONSTRAINT `local_credentials_user_id_users_id_fk` FOREIGN KEY (`user_id`) REFERENCES `users`(`id`) ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +CREATE INDEX `idx_local_credentials_user_id` ON `local_credentials` (`user_id`); \ No newline at end of file diff --git a/apps/api/src/db/migrations/meta/0003_snapshot.json b/apps/api/src/db/migrations/meta/0003_snapshot.json new file mode 100644 index 0000000..443a33b --- /dev/null +++ b/apps/api/src/db/migrations/meta/0003_snapshot.json @@ -0,0 +1,1144 @@ +{ + "version": "5", + "dialect": "mysql", + "id": "81927b64-67fa-4476-989d-fc9c55f418ab", + "prevId": "10d0f5e8-4445-429e-8077-49075e88db4b", + "tables": { + "app_config": { + "name": "app_config", + "columns": { + "key": { + "name": "key", + "type": "varchar(128)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "value": { + "name": "value", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "onUpdate": true, + "default": "(now())" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": { + "app_config_key": { + "name": "app_config_key", + "columns": [ + "key" + ] + } + }, + "uniqueConstraints": {}, + "checkConstraint": {} + }, + "calendar_events": { + "name": "calendar_events", + "columns": { + "id": { + "name": "id", + "type": "int", + "primaryKey": false, + "notNull": true, + "autoincrement": true + }, + "calendar_id": { + "name": "calendar_id", + "type": "int", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "uid": { + "name": "uid", + "type": "varchar(512)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "etag": { + "name": "etag", + "type": "varchar(256)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "object_url": { + "name": "object_url", + "type": "varchar(1024)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "raw_vevent": { + "name": "raw_vevent", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "title": { + "name": "title", + "type": "varchar(500)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "dtstart_utc": { + "name": "dtstart_utc", + "type": "timestamp", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "dtstart_date": { + "name": "dtstart_date", + "type": "date", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "all_day": { + "name": "all_day", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "has_rrule": { + "name": "has_rrule", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "reminder_lead_minutes": { + "name": "reminder_lead_minutes", + "type": "int", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "onUpdate": true, + "default": "(now())" + } + }, + "indexes": { + "idx_calendar_events_dtstart_utc": { + "name": "idx_calendar_events_dtstart_utc", + "columns": [ + "dtstart_utc" + ], + "isUnique": false + }, + "idx_calendar_events_dtstart_date": { + "name": "idx_calendar_events_dtstart_date", + "columns": [ + "dtstart_date" + ], + "isUnique": false + }, + "idx_calendar_events_has_rrule": { + "name": "idx_calendar_events_has_rrule", + "columns": [ + "has_rrule" + ], + "isUnique": false + } + }, + "foreignKeys": { + "calendar_events_calendar_id_calendars_id_fk": { + "name": "calendar_events_calendar_id_calendars_id_fk", + "tableFrom": "calendar_events", + "tableTo": "calendars", + "columnsFrom": [ + "calendar_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "calendar_events_id": { + "name": "calendar_events_id", + "columns": [ + "id" + ] + } + }, + "uniqueConstraints": { + "uniq_calendar_uid": { + "name": "uniq_calendar_uid", + "columns": [ + "calendar_id", + "uid" + ] + } + }, + "checkConstraint": {} + }, + "calendar_outbox": { + "name": "calendar_outbox", + "columns": { + "id": { + "name": "id", + "type": "int", + "primaryKey": false, + "notNull": true, + "autoincrement": true + }, + "user_id": { + "name": "user_id", + "type": "int", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "operation": { + "name": "operation", + "type": "enum('create','update','delete')", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "status": { + "name": "status", + "type": "enum('pending','done','failed','dead')", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'pending'" + }, + "uid": { + "name": "uid", + "type": "varchar(512)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "calendar_url": { + "name": "calendar_url", + "type": "varchar(1024)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "calendar_object_url": { + "name": "calendar_object_url", + "type": "varchar(1024)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "etag": { + "name": "etag", + "type": "varchar(256)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "payload": { + "name": "payload", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "attempt_count": { + "name": "attempt_count", + "type": "int", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "next_attempt_at": { + "name": "next_attempt_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(now())" + }, + "last_error": { + "name": "last_error", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "group_id": { + "name": "group_id", + "type": "varchar(64)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(now())" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "onUpdate": true, + "default": "(now())" + } + }, + "indexes": { + "idx_outbox_user_status": { + "name": "idx_outbox_user_status", + "columns": [ + "user_id", + "status" + ], + "isUnique": false + }, + "idx_outbox_next_attempt": { + "name": "idx_outbox_next_attempt", + "columns": [ + "next_attempt_at", + "status" + ], + "isUnique": false + }, + "idx_outbox_uid": { + "name": "idx_outbox_uid", + "columns": [ + "uid" + ], + "isUnique": false + } + }, + "foreignKeys": { + "calendar_outbox_user_id_users_id_fk": { + "name": "calendar_outbox_user_id_users_id_fk", + "tableFrom": "calendar_outbox", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "calendar_outbox_id": { + "name": "calendar_outbox_id", + "columns": [ + "id" + ] + } + }, + "uniqueConstraints": {}, + "checkConstraint": {} + }, + "calendars": { + "name": "calendars", + "columns": { + "id": { + "name": "id", + "type": "int", + "primaryKey": false, + "notNull": true, + "autoincrement": true + }, + "user_id": { + "name": "user_id", + "type": "int", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "url": { + "name": "url", + "type": "varchar(1024)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "display_name": { + "name": "display_name", + "type": "varchar(256)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "color": { + "name": "color", + "type": "varchar(7)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "ctag": { + "name": "ctag", + "type": "varchar(512)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "sync_token": { + "name": "sync_token", + "type": "varchar(1024)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "last_synced_at": { + "name": "last_synced_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "is_shared": { + "name": "is_shared", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + } + }, + "indexes": { + "idx_calendars_user_id": { + "name": "idx_calendars_user_id", + "columns": [ + "user_id" + ], + "isUnique": false + } + }, + "foreignKeys": { + "calendars_user_id_users_id_fk": { + "name": "calendars_user_id_users_id_fk", + "tableFrom": "calendars", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "calendars_id": { + "name": "calendars_id", + "columns": [ + "id" + ] + } + }, + "uniqueConstraints": { + "uniq_calendar_user_url": { + "name": "uniq_calendar_user_url", + "columns": [ + "user_id", + "url" + ] + } + }, + "checkConstraint": {} + }, + "list_items": { + "name": "list_items", + "columns": { + "id": { + "name": "id", + "type": "int", + "primaryKey": false, + "notNull": true, + "autoincrement": true + }, + "list_id": { + "name": "list_id", + "type": "int", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "text": { + "name": "text", + "type": "varchar(500)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "checked": { + "name": "checked", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "rank": { + "name": "rank", + "type": "varchar(255) COLLATE utf8mb4_bin", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(now())" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "onUpdate": true, + "default": "(now())" + } + }, + "indexes": { + "idx_list_items_list_id_rank": { + "name": "idx_list_items_list_id_rank", + "columns": [ + "list_id", + "rank" + ], + "isUnique": false + }, + "idx_list_items_list_id_checked": { + "name": "idx_list_items_list_id_checked", + "columns": [ + "list_id", + "checked" + ], + "isUnique": false + } + }, + "foreignKeys": { + "list_items_list_id_lists_id_fk": { + "name": "list_items_list_id_lists_id_fk", + "tableFrom": "list_items", + "tableTo": "lists", + "columnsFrom": [ + "list_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "list_items_id": { + "name": "list_items_id", + "columns": [ + "id" + ] + } + }, + "uniqueConstraints": {}, + "checkConstraint": {} + }, + "list_shares": { + "name": "list_shares", + "columns": { + "id": { + "name": "id", + "type": "int", + "primaryKey": false, + "notNull": true, + "autoincrement": true + }, + "list_id": { + "name": "list_id", + "type": "int", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "user_id": { + "name": "user_id", + "type": "int", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(now())" + } + }, + "indexes": { + "idx_list_shares_user_id": { + "name": "idx_list_shares_user_id", + "columns": [ + "user_id" + ], + "isUnique": false + } + }, + "foreignKeys": { + "list_shares_list_id_lists_id_fk": { + "name": "list_shares_list_id_lists_id_fk", + "tableFrom": "list_shares", + "tableTo": "lists", + "columnsFrom": [ + "list_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "list_shares_user_id_users_id_fk": { + "name": "list_shares_user_id_users_id_fk", + "tableFrom": "list_shares", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "list_shares_id": { + "name": "list_shares_id", + "columns": [ + "id" + ] + } + }, + "uniqueConstraints": { + "uniq_list_share": { + "name": "uniq_list_share", + "columns": [ + "list_id", + "user_id" + ] + } + }, + "checkConstraint": {} + }, + "lists": { + "name": "lists", + "columns": { + "id": { + "name": "id", + "type": "int", + "primaryKey": false, + "notNull": true, + "autoincrement": true + }, + "owner_id": { + "name": "owner_id", + "type": "int", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "is_shared": { + "name": "is_shared", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(now())" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "onUpdate": true, + "default": "(now())" + } + }, + "indexes": { + "idx_lists_owner_id": { + "name": "idx_lists_owner_id", + "columns": [ + "owner_id" + ], + "isUnique": false + } + }, + "foreignKeys": { + "lists_owner_id_users_id_fk": { + "name": "lists_owner_id_users_id_fk", + "tableFrom": "lists", + "tableTo": "users", + "columnsFrom": [ + "owner_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "lists_id": { + "name": "lists_id", + "columns": [ + "id" + ] + } + }, + "uniqueConstraints": {}, + "checkConstraint": {} + }, + "local_credentials": { + "name": "local_credentials", + "columns": { + "id": { + "name": "id", + "type": "int", + "primaryKey": false, + "notNull": true, + "autoincrement": true + }, + "user_id": { + "name": "user_id", + "type": "int", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "username": { + "name": "username", + "type": "varchar(128)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "password_hash": { + "name": "password_hash", + "type": "varchar(256)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(now())" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "onUpdate": true, + "default": "(now())" + } + }, + "indexes": { + "idx_local_credentials_user_id": { + "name": "idx_local_credentials_user_id", + "columns": [ + "user_id" + ], + "isUnique": false + } + }, + "foreignKeys": { + "local_credentials_user_id_users_id_fk": { + "name": "local_credentials_user_id_users_id_fk", + "tableFrom": "local_credentials", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "local_credentials_id": { + "name": "local_credentials_id", + "columns": [ + "id" + ] + } + }, + "uniqueConstraints": { + "uniq_local_cred_user": { + "name": "uniq_local_cred_user", + "columns": [ + "user_id" + ] + }, + "uniq_local_cred_username": { + "name": "uniq_local_cred_username", + "columns": [ + "username" + ] + } + }, + "checkConstraint": {} + }, + "member_credentials": { + "name": "member_credentials", + "columns": { + "id": { + "name": "id", + "type": "int", + "primaryKey": false, + "notNull": true, + "autoincrement": true + }, + "user_id": { + "name": "user_id", + "type": "int", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "encrypted_password": { + "name": "encrypted_password", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "fastmail_email": { + "name": "fastmail_email", + "type": "varchar(256)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(now())" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "onUpdate": true, + "default": "(now())" + }, + "provider_type": { + "name": "provider_type", + "type": "varchar(64)", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'caldav'" + } + }, + "indexes": { + "idx_member_credentials_user_id": { + "name": "idx_member_credentials_user_id", + "columns": [ + "user_id" + ], + "isUnique": false + } + }, + "foreignKeys": { + "member_credentials_user_id_users_id_fk": { + "name": "member_credentials_user_id_users_id_fk", + "tableFrom": "member_credentials", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "member_credentials_id": { + "name": "member_credentials_id", + "columns": [ + "id" + ] + } + }, + "uniqueConstraints": { + "uniq_member_credential_user": { + "name": "uniq_member_credential_user", + "columns": [ + "user_id" + ] + } + }, + "checkConstraint": {} + }, + "push_subscriptions": { + "name": "push_subscriptions", + "columns": { + "id": { + "name": "id", + "type": "int", + "primaryKey": false, + "notNull": true, + "autoincrement": true + }, + "user_id": { + "name": "user_id", + "type": "int", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "endpoint": { + "name": "endpoint", + "type": "varchar(2048)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "p256dh": { + "name": "p256dh", + "type": "varchar(512)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "auth": { + "name": "auth", + "type": "varchar(256)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(now())" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "onUpdate": true, + "default": "(now())" + } + }, + "indexes": { + "idx_push_subscriptions_user_id": { + "name": "idx_push_subscriptions_user_id", + "columns": [ + "user_id" + ], + "isUnique": false + } + }, + "foreignKeys": { + "push_subscriptions_user_id_users_id_fk": { + "name": "push_subscriptions_user_id_users_id_fk", + "tableFrom": "push_subscriptions", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "push_subscriptions_id": { + "name": "push_subscriptions_id", + "columns": [ + "id" + ] + } + }, + "uniqueConstraints": { + "uniq_push_endpoint": { + "name": "uniq_push_endpoint", + "columns": [ + "endpoint" + ] + } + }, + "checkConstraint": {} + }, + "users": { + "name": "users", + "columns": { + "id": { + "name": "id", + "type": "int", + "primaryKey": false, + "notNull": true, + "autoincrement": true + }, + "oidc_iss": { + "name": "oidc_iss", + "type": "varchar(512)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "oidc_sub": { + "name": "oidc_sub", + "type": "varchar(256)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "display_name": { + "name": "display_name", + "type": "varchar(256)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "color": { + "name": "color", + "type": "varchar(7)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(now())" + }, + "is_admin": { + "name": "is_admin", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "claimed": { + "name": "claimed", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": { + "users_id": { + "name": "users_id", + "columns": [ + "id" + ] + } + }, + "uniqueConstraints": { + "uniq_oidc_identity": { + "name": "uniq_oidc_identity", + "columns": [ + "oidc_iss", + "oidc_sub" + ] + } + }, + "checkConstraint": {} + } + }, + "views": {}, + "_meta": { + "schemas": {}, + "tables": {}, + "columns": {} + }, + "internal": { + "tables": {}, + "indexes": {} + } +} \ No newline at end of file diff --git a/apps/api/src/db/migrations/meta/_journal.json b/apps/api/src/db/migrations/meta/_journal.json index cab20d4..a2e39b6 100644 --- a/apps/api/src/db/migrations/meta/_journal.json +++ b/apps/api/src/db/migrations/meta/_journal.json @@ -22,6 +22,13 @@ "when": 1781545048917, "tag": "0002_lethal_millenium_guard", "breakpoints": true + }, + { + "idx": 3, + "version": "5", + "when": 1781727317172, + "tag": "0003_warm_deathstrike", + "breakpoints": true } ] } \ No newline at end of file diff --git a/apps/api/src/db/schema.ts b/apps/api/src/db/schema.ts index 220abc3..507bd3f 100644 --- a/apps/api/src/db/schema.ts +++ b/apps/api/src/db/schema.ts @@ -309,6 +309,47 @@ export const appConfig = mysqlTable('app_config', { updatedAt: timestamp('updated_at').defaultNow().onUpdateNow(), }); +/** + * Local authentication credentials per member (Phase 19 — D-09). + * + * Stores username + PHC-encoded scrypt password hash for members who authenticate + * via local username/password rather than (or before) OIDC. + * + * Design decisions: + * - Separate table from `users` to keep the users row identity-method-agnostic (D-09). + * - A user has a local login iff a `local_credentials` row exists (UNIQUE on user_id). + * - OIDC-link flow (D-12): when a local user links OIDC, their `local_credentials` + * row is deleted — they become OIDC-only. + * - CASCADE DELETE on users.id keeps credentials clean when a member is removed. + * - username is globally unique (login identifier, separate from displayName). + * - password_hash is PHC-encoded: scrypt$N$r$p$$ (varchar 256). + * + * PROHIBITION: LOCAL_SESSION_SECRET (the signing key for this table's sessions) is an + * env-only secret and must NEVER be stored in this table or app_config (SC-3). + */ +export const localCredentials = mysqlTable( + 'local_credentials', + { + id: int().primaryKey().autoincrement(), + userId: int('user_id') + .notNull() + .references(() => users.id, { onDelete: 'cascade' }), + username: varchar('username', { length: 128 }).notNull(), + // PHC-encoded: scrypt$N$r$p$$ — max ~83 chars + passwordHash: varchar('password_hash', { length: 256 }).notNull(), + createdAt: timestamp('created_at').defaultNow().notNull(), + updatedAt: timestamp('updated_at').defaultNow().onUpdateNow(), + }, + (t) => [ + // One local credential per user — user_id is unique (D-09: auth method is per-user property) + unique('uniq_local_cred_user').on(t.userId), + // Username is globally unique (login identifier; case-sensitive per MariaDB default) + unique('uniq_local_cred_username').on(t.username), + // Index for fast lookup by user_id (e.g., on middleware / self-change-password) + index('idx_local_credentials_user_id').on(t.userId), + ], +); + /** * Items within a list. * diff --git a/apps/api/test/setup.ts b/apps/api/test/setup.ts index f6af415..82ead50 100644 --- a/apps/api/test/setup.ts +++ b/apps/api/test/setup.ts @@ -24,7 +24,7 @@ import { afterEach } from 'vitest'; import { db } from '../src/db/client.js'; -import { lists, listItems, listShares, pushSubscriptions } from '../src/db/schema.js'; +import { lists, listItems, listShares, pushSubscriptions, localCredentials } from '../src/db/schema.js'; /** * Truncate list and push tables in FK-safe order after each test. @@ -39,6 +39,9 @@ afterEach(async () => { await db.delete(listShares); await db.delete(pushSubscriptions); await db.delete(lists); + // Phase 19: local_credentials has FK to users (cascade delete via users); truncate here + // so each test starts with a clean credential slate. users intentionally left intact. + await db.delete(localCredentials); } catch { // DB may not be available in pure-unit test runs (no DB_HOST configured). // Swallow the error — pure-logic tests do not need cleanup. diff --git a/scripts/generate-secrets.mjs b/scripts/generate-secrets.mjs index 181bb9e..f3d577e 100644 --- a/scripts/generate-secrets.mjs +++ b/scripts/generate-secrets.mjs @@ -27,6 +27,9 @@ import { randomBytes, createECDH } from 'node:crypto'; const sessionSecret = randomBytes(32).toString('hex'); const encKey = randomBytes(32).toString('hex'); +// Phase 19 (D-05): LOCAL_SESSION_SECRET signs the local-auth JWT session cookie. +// Must be >= 32 chars. 32 random bytes encoded as base64 = 44 chars (safe, distinct from hex keys). +const localSessionSecret = randomBytes(32).toString('base64'); // VAPID key generation (P-256 / prime256v1 — same curve as web-push) const ecdhCurve = createECDH('prime256v1'); @@ -55,4 +58,6 @@ SESSION_SECRET=${sessionSecret} APP_PASSWORD_ENCRYPTION_KEY=${encKey} VAPID_PUBLIC_KEY=${vapid.publicKey} VAPID_PRIVATE_KEY=${vapid.privateKey} +# Phase 19 (D-05): Signs local-auth JWT session cookies. Required when not using DEV_AUTH_BYPASS. +LOCAL_SESSION_SECRET=${localSessionSecret} `);