diff --git a/apps/api/.env.example b/apps/api/.env.example index df075524d..5ccaed513 100644 --- a/apps/api/.env.example +++ b/apps/api/.env.example @@ -9,6 +9,14 @@ VITNODE_WEB_URL=http://localhost:3000 # storage adapter, so it must match where the server is reachable. VITNODE_API_URL=http://localhost:8000 +# === Passkeys (WebAuthn) === +# Off unless `authorization.passkeys` is set in `vitnode.api.config.ts`. +# `passkeys: true` binds passkeys to the hostname of `VITNODE_WEB_URL` and only +# accepts that origin. Browsers allow them on HTTPS or on localhost. To share +# passkeys between `example.com` and `forum.example.com`, pass +# `{ rpId: "example.com", origins: [...] }` instead. Changing the RP ID later +# makes every existing passkey unusable. + # === CRON Secret for Internal API Calls === CRON_SECRET=your-secure-cron-secret-key diff --git a/apps/api/migrations/20260928114540_add_core_users_passkeys/migration.sql b/apps/api/migrations/20260928114540_add_core_users_passkeys/migration.sql new file mode 100644 index 000000000..06049098e --- /dev/null +++ b/apps/api/migrations/20260928114540_add_core_users_passkeys/migration.sql @@ -0,0 +1,36 @@ +CREATE TABLE "core_users_passkey_challenges" ( + "id" serial PRIMARY KEY, + "tokenHash" varchar(64) NOT NULL UNIQUE, + "ceremony" varchar(16) NOT NULL, + "challenge" varchar(128) NOT NULL, + "userId" integer, + "webauthnUserId" varchar(128), + "createdAt" timestamp DEFAULT now() NOT NULL, + "expiresAt" timestamp NOT NULL +); +--> statement-breakpoint +ALTER TABLE "core_users_passkey_challenges" ENABLE ROW LEVEL SECURITY;--> statement-breakpoint +CREATE TABLE "core_users_passkeys" ( + "id" serial PRIMARY KEY, + "userId" integer NOT NULL, + "credentialId" varchar(1024) NOT NULL UNIQUE, + "publicKey" text NOT NULL, + "counter" bigint DEFAULT 0 NOT NULL, + "webauthnUserId" varchar(128) NOT NULL, + "transports" varchar(32)[] DEFAULT '{}'::varchar(32)[] NOT NULL, + "deviceType" varchar(32) NOT NULL, + "backedUp" boolean DEFAULT false NOT NULL, + "aaguid" varchar(36), + "name" varchar(64) NOT NULL, + "createdAt" timestamp DEFAULT now() NOT NULL, + "updatedAt" timestamp DEFAULT now() NOT NULL, + "lastUsedAt" timestamp +); +--> statement-breakpoint +ALTER TABLE "core_users_passkeys" ENABLE ROW LEVEL SECURITY;--> statement-breakpoint +CREATE INDEX "core_users_passkey_challenges_expires_at_idx" ON "core_users_passkey_challenges" ("expiresAt");--> statement-breakpoint +CREATE INDEX "core_users_passkey_challenges_user_id_idx" ON "core_users_passkey_challenges" ("userId");--> statement-breakpoint +CREATE INDEX "core_users_passkeys_user_id_idx" ON "core_users_passkeys" ("userId");--> statement-breakpoint +CREATE INDEX "core_users_passkeys_webauthn_user_id_idx" ON "core_users_passkeys" ("webauthnUserId");--> statement-breakpoint +ALTER TABLE "core_users_passkey_challenges" ADD CONSTRAINT "core_users_passkey_challenges_userId_core_users_id_fkey" FOREIGN KEY ("userId") REFERENCES "core_users"("id") ON DELETE CASCADE;--> statement-breakpoint +ALTER TABLE "core_users_passkeys" ADD CONSTRAINT "core_users_passkeys_userId_core_users_id_fkey" FOREIGN KEY ("userId") REFERENCES "core_users"("id") ON DELETE CASCADE; \ No newline at end of file diff --git a/apps/api/migrations/20260928114540_add_core_users_passkeys/snapshot.json b/apps/api/migrations/20260928114540_add_core_users_passkeys/snapshot.json new file mode 100644 index 000000000..f960358e2 --- /dev/null +++ b/apps/api/migrations/20260928114540_add_core_users_passkeys/snapshot.json @@ -0,0 +1,9204 @@ +{ + "version": "8", + "dialect": "postgres", + "id": "c1eb3581-341a-484b-92c7-eedd030ab3c9", + "prevIds": [ + "56fff4d4-495f-4005-a13f-07fcc543ac42" + ], + "ddl": [ + { + "isRlsEnabled": true, + "name": "core_admin_permissions", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": true, + "name": "core_admin_sessions", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": true, + "name": "core_content_file_refs", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": true, + "name": "core_content_revisions", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": true, + "name": "core_content_schedules", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": true, + "name": "core_content_slug_history", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": true, + "name": "core_cron", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": true, + "name": "core_admin_dashboard", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": true, + "name": "core_files", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": true, + "name": "core_languages", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": true, + "name": "core_languages_words", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": true, + "name": "core_logs", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": true, + "name": "core_moderators_permissions", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": true, + "name": "core_navigation", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": true, + "name": "core_page_layouts", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": true, + "name": "core_users_passkey_challenges", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": true, + "name": "core_users_passkeys", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": true, + "name": "core_queue", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": true, + "name": "core_roles", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": true, + "name": "core_search_index", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": true, + "name": "core_secrets", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": true, + "name": "core_sessions", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": true, + "name": "core_sessions_known_devices", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": true, + "name": "core_users", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": true, + "name": "core_users_confirm_emails", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": true, + "name": "core_users_forgot_password", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": true, + "name": "core_users_secondary_roles", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": true, + "name": "core_users_sso", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": true, + "name": "blog_categories", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": true, + "name": "blog_categories_translations", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": true, + "name": "blog_posts", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": true, + "name": "blog_posts_author_id", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": true, + "name": "blog_posts_category_id", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": true, + "name": "blog_posts_translations", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": true, + "name": "example_advanced_articles", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": true, + "name": "example_advanced_articles_categories", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": true, + "name": "example_advanced_articles_faq", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": true, + "name": "example_advanced_articles_related_articles", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": true, + "name": "example_advanced_articles_translations", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": true, + "name": "example_articles", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": true, + "name": "example_articles_gallery", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": true, + "name": "example_categories", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": true, + "name": "example_localized_articles", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": true, + "name": "example_localized_articles_translations", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": true, + "name": "example_pages", + "entityType": "tables", + "schema": "public" + }, + { + "type": "serial", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "id", + "entityType": "columns", + "schema": "public", + "table": "core_admin_permissions" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "roleId", + "entityType": "columns", + "schema": "public", + "table": "core_admin_permissions" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "userId", + "entityType": "columns", + "schema": "public", + "table": "core_admin_permissions" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "createdAt", + "entityType": "columns", + "schema": "public", + "table": "core_admin_permissions" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "updatedAt", + "entityType": "columns", + "schema": "public", + "table": "core_admin_permissions" + }, + { + "type": "boolean", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "false", + "generated": null, + "identity": null, + "name": "protected", + "entityType": "columns", + "schema": "public", + "table": "core_admin_permissions" + }, + { + "type": "boolean", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "false", + "generated": null, + "identity": null, + "name": "unrestricted", + "entityType": "columns", + "schema": "public", + "table": "core_admin_permissions" + }, + { + "type": "jsonb", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "'[]'", + "generated": null, + "identity": null, + "name": "permissions", + "entityType": "columns", + "schema": "public", + "table": "core_admin_permissions" + }, + { + "type": "serial", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "id", + "entityType": "columns", + "schema": "public", + "table": "core_admin_sessions" + }, + { + "type": "varchar(255)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "token", + "entityType": "columns", + "schema": "public", + "table": "core_admin_sessions" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "userId", + "entityType": "columns", + "schema": "public", + "table": "core_admin_sessions" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "createdAt", + "entityType": "columns", + "schema": "public", + "table": "core_admin_sessions" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "lastSeen", + "entityType": "columns", + "schema": "public", + "table": "core_admin_sessions" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "expiresAt", + "entityType": "columns", + "schema": "public", + "table": "core_admin_sessions" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "deviceId", + "entityType": "columns", + "schema": "public", + "table": "core_admin_sessions" + }, + { + "type": "serial", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "id", + "entityType": "columns", + "schema": "public", + "table": "core_content_file_refs" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "revisionId", + "entityType": "columns", + "schema": "public", + "table": "core_content_file_refs" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "fileId", + "entityType": "columns", + "schema": "public", + "table": "core_content_file_refs" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "createdAt", + "entityType": "columns", + "schema": "public", + "table": "core_content_file_refs" + }, + { + "type": "serial", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "id", + "entityType": "columns", + "schema": "public", + "table": "core_content_revisions" + }, + { + "type": "varchar(255)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "pluginId", + "entityType": "columns", + "schema": "public", + "table": "core_content_revisions" + }, + { + "type": "varchar(100)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "contentTypeId", + "entityType": "columns", + "schema": "public", + "table": "core_content_revisions" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "itemId", + "entityType": "columns", + "schema": "public", + "table": "core_content_revisions" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "languageId", + "entityType": "columns", + "schema": "public", + "table": "core_content_revisions" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "version", + "entityType": "columns", + "schema": "public", + "table": "core_content_revisions" + }, + { + "type": "varchar(20)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "operation", + "entityType": "columns", + "schema": "public", + "table": "core_content_revisions" + }, + { + "type": "jsonb", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "'{}'", + "generated": null, + "identity": null, + "name": "snapshot", + "entityType": "columns", + "schema": "public", + "table": "core_content_revisions" + }, + { + "type": "jsonb", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "'[]'", + "generated": null, + "identity": null, + "name": "changedFields", + "entityType": "columns", + "schema": "public", + "table": "core_content_revisions" + }, + { + "type": "varchar(16)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "'system'", + "generated": null, + "identity": null, + "name": "actorType", + "entityType": "columns", + "schema": "public", + "table": "core_content_revisions" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "actorUserId", + "entityType": "columns", + "schema": "public", + "table": "core_content_revisions" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "restoredFromRevisionId", + "entityType": "columns", + "schema": "public", + "table": "core_content_revisions" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "createdAt", + "entityType": "columns", + "schema": "public", + "table": "core_content_revisions" + }, + { + "type": "serial", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "id", + "entityType": "columns", + "schema": "public", + "table": "core_content_schedules" + }, + { + "type": "varchar(255)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "pluginId", + "entityType": "columns", + "schema": "public", + "table": "core_content_schedules" + }, + { + "type": "varchar(100)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "contentTypeId", + "entityType": "columns", + "schema": "public", + "table": "core_content_schedules" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "itemId", + "entityType": "columns", + "schema": "public", + "table": "core_content_schedules" + }, + { + "type": "varchar(16)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "action", + "entityType": "columns", + "schema": "public", + "table": "core_content_schedules" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "scheduledFor", + "entityType": "columns", + "schema": "public", + "table": "core_content_schedules" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "1", + "generated": null, + "identity": null, + "name": "generation", + "entityType": "columns", + "schema": "public", + "table": "core_content_schedules" + }, + { + "type": "varchar(16)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "'pending'", + "generated": null, + "identity": null, + "name": "status", + "entityType": "columns", + "schema": "public", + "table": "core_content_schedules" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "createdBy", + "entityType": "columns", + "schema": "public", + "table": "core_content_schedules" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "createdAt", + "entityType": "columns", + "schema": "public", + "table": "core_content_schedules" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "updatedAt", + "entityType": "columns", + "schema": "public", + "table": "core_content_schedules" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "completedAt", + "entityType": "columns", + "schema": "public", + "table": "core_content_schedules" + }, + { + "type": "text", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "lastError", + "entityType": "columns", + "schema": "public", + "table": "core_content_schedules" + }, + { + "type": "text", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "effectsError", + "entityType": "columns", + "schema": "public", + "table": "core_content_schedules" + }, + { + "type": "serial", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "id", + "entityType": "columns", + "schema": "public", + "table": "core_content_slug_history" + }, + { + "type": "varchar(255)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "pluginId", + "entityType": "columns", + "schema": "public", + "table": "core_content_slug_history" + }, + { + "type": "varchar(100)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "contentTypeId", + "entityType": "columns", + "schema": "public", + "table": "core_content_slug_history" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "itemId", + "entityType": "columns", + "schema": "public", + "table": "core_content_slug_history" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "languageId", + "entityType": "columns", + "schema": "public", + "table": "core_content_slug_history" + }, + { + "type": "varchar(160)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "slug", + "entityType": "columns", + "schema": "public", + "table": "core_content_slug_history" + }, + { + "type": "varchar(512)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "path", + "entityType": "columns", + "schema": "public", + "table": "core_content_slug_history" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "createdAt", + "entityType": "columns", + "schema": "public", + "table": "core_content_slug_history" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "retiredAt", + "entityType": "columns", + "schema": "public", + "table": "core_content_slug_history" + }, + { + "type": "serial", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "id", + "entityType": "columns", + "schema": "public", + "table": "core_cron" + }, + { + "type": "varchar(255)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "name", + "entityType": "columns", + "schema": "public", + "table": "core_cron" + }, + { + "type": "varchar(255)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "description", + "entityType": "columns", + "schema": "public", + "table": "core_cron" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "lastRun", + "entityType": "columns", + "schema": "public", + "table": "core_cron" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "createdAt", + "entityType": "columns", + "schema": "public", + "table": "core_cron" + }, + { + "type": "varchar(100)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "pluginId", + "entityType": "columns", + "schema": "public", + "table": "core_cron" + }, + { + "type": "varchar(100)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "module", + "entityType": "columns", + "schema": "public", + "table": "core_cron" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "nextRun", + "entityType": "columns", + "schema": "public", + "table": "core_cron" + }, + { + "type": "varchar(100)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "schedule", + "entityType": "columns", + "schema": "public", + "table": "core_cron" + }, + { + "type": "serial", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "id", + "entityType": "columns", + "schema": "public", + "table": "core_admin_dashboard" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "userId", + "entityType": "columns", + "schema": "public", + "table": "core_admin_dashboard" + }, + { + "type": "jsonb", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "'[]'", + "generated": null, + "identity": null, + "name": "widgets", + "entityType": "columns", + "schema": "public", + "table": "core_admin_dashboard" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "createdAt", + "entityType": "columns", + "schema": "public", + "table": "core_admin_dashboard" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "updatedAt", + "entityType": "columns", + "schema": "public", + "table": "core_admin_dashboard" + }, + { + "type": "serial", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "id", + "entityType": "columns", + "schema": "public", + "table": "core_files" + }, + { + "type": "varchar(255)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "name", + "entityType": "columns", + "schema": "public", + "table": "core_files" + }, + { + "type": "varchar(512)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "key", + "entityType": "columns", + "schema": "public", + "table": "core_files" + }, + { + "type": "varchar(255)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "folder", + "entityType": "columns", + "schema": "public", + "table": "core_files" + }, + { + "type": "varchar(255)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "mimeType", + "entityType": "columns", + "schema": "public", + "table": "core_files" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "0", + "generated": null, + "identity": null, + "name": "size", + "entityType": "columns", + "schema": "public", + "table": "core_files" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "userId", + "entityType": "columns", + "schema": "public", + "table": "core_files" + }, + { + "type": "varchar(100)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "pluginId", + "entityType": "columns", + "schema": "public", + "table": "core_files" + }, + { + "type": "jsonb", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "'{}'", + "generated": null, + "identity": null, + "name": "metadata", + "entityType": "columns", + "schema": "public", + "table": "core_files" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "createdAt", + "entityType": "columns", + "schema": "public", + "table": "core_files" + }, + { + "type": "serial", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "id", + "entityType": "columns", + "schema": "public", + "table": "core_languages" + }, + { + "type": "varchar(32)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "code", + "entityType": "columns", + "schema": "public", + "table": "core_languages" + }, + { + "type": "varchar(255)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "name", + "entityType": "columns", + "schema": "public", + "table": "core_languages" + }, + { + "type": "varchar(255)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "'UTC'", + "generated": null, + "identity": null, + "name": "timezone", + "entityType": "columns", + "schema": "public", + "table": "core_languages" + }, + { + "type": "boolean", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "false", + "generated": null, + "identity": null, + "name": "protected", + "entityType": "columns", + "schema": "public", + "table": "core_languages" + }, + { + "type": "boolean", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "false", + "generated": null, + "identity": null, + "name": "default", + "entityType": "columns", + "schema": "public", + "table": "core_languages" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "createdAt", + "entityType": "columns", + "schema": "public", + "table": "core_languages" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "updatedAt", + "entityType": "columns", + "schema": "public", + "table": "core_languages" + }, + { + "type": "boolean", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "false", + "generated": null, + "identity": null, + "name": "time24", + "entityType": "columns", + "schema": "public", + "table": "core_languages" + }, + { + "type": "serial", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "id", + "entityType": "columns", + "schema": "public", + "table": "core_languages_words" + }, + { + "type": "varchar", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "languageCode", + "entityType": "columns", + "schema": "public", + "table": "core_languages_words" + }, + { + "type": "varchar(50)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "pluginCode", + "entityType": "columns", + "schema": "public", + "table": "core_languages_words" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "itemId", + "entityType": "columns", + "schema": "public", + "table": "core_languages_words" + }, + { + "type": "text", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "value", + "entityType": "columns", + "schema": "public", + "table": "core_languages_words" + }, + { + "type": "varchar(255)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "tableName", + "entityType": "columns", + "schema": "public", + "table": "core_languages_words" + }, + { + "type": "varchar(255)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "variable", + "entityType": "columns", + "schema": "public", + "table": "core_languages_words" + }, + { + "type": "serial", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "id", + "entityType": "columns", + "schema": "public", + "table": "core_logs" + }, + { + "type": "varchar(255)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "pluginId", + "entityType": "columns", + "schema": "public", + "table": "core_logs" + }, + { + "type": "varchar(10)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "type", + "entityType": "columns", + "schema": "public", + "table": "core_logs" + }, + { + "type": "text", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "content", + "entityType": "columns", + "schema": "public", + "table": "core_logs" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "createdAt", + "entityType": "columns", + "schema": "public", + "table": "core_logs" + }, + { + "type": "varchar(45)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "ipAddress", + "entityType": "columns", + "schema": "public", + "table": "core_logs" + }, + { + "type": "varchar(10)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "'GET'", + "generated": null, + "identity": null, + "name": "method", + "entityType": "columns", + "schema": "public", + "table": "core_logs" + }, + { + "type": "text", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "'localhost'", + "generated": null, + "identity": null, + "name": "path", + "entityType": "columns", + "schema": "public", + "table": "core_logs" + }, + { + "type": "text", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "userAgent", + "entityType": "columns", + "schema": "public", + "table": "core_logs" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "500", + "generated": null, + "identity": null, + "name": "statusCode", + "entityType": "columns", + "schema": "public", + "table": "core_logs" + }, + { + "type": "bigint", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "userId", + "entityType": "columns", + "schema": "public", + "table": "core_logs" + }, + { + "type": "boolean", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "false", + "generated": null, + "identity": null, + "name": "test123", + "entityType": "columns", + "schema": "public", + "table": "core_logs" + }, + { + "type": "serial", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "id", + "entityType": "columns", + "schema": "public", + "table": "core_moderators_permissions" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "roleId", + "entityType": "columns", + "schema": "public", + "table": "core_moderators_permissions" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "userId", + "entityType": "columns", + "schema": "public", + "table": "core_moderators_permissions" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "createdAt", + "entityType": "columns", + "schema": "public", + "table": "core_moderators_permissions" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "updatedAt", + "entityType": "columns", + "schema": "public", + "table": "core_moderators_permissions" + }, + { + "type": "boolean", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "false", + "generated": null, + "identity": null, + "name": "protected", + "entityType": "columns", + "schema": "public", + "table": "core_moderators_permissions" + }, + { + "type": "boolean", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "false", + "generated": null, + "identity": null, + "name": "unrestricted", + "entityType": "columns", + "schema": "public", + "table": "core_moderators_permissions" + }, + { + "type": "jsonb", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "'[]'", + "generated": null, + "identity": null, + "name": "permissions", + "entityType": "columns", + "schema": "public", + "table": "core_moderators_permissions" + }, + { + "type": "serial", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "id", + "entityType": "columns", + "schema": "public", + "table": "core_navigation" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "parentId", + "entityType": "columns", + "schema": "public", + "table": "core_navigation" + }, + { + "type": "varchar(16)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "'header'", + "generated": null, + "identity": null, + "name": "location", + "entityType": "columns", + "schema": "public", + "table": "core_navigation" + }, + { + "type": "varchar(16)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "kind", + "entityType": "columns", + "schema": "public", + "table": "core_navigation" + }, + { + "type": "varchar(50)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "pluginId", + "entityType": "columns", + "schema": "public", + "table": "core_navigation" + }, + { + "type": "varchar(120)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "presetId", + "entityType": "columns", + "schema": "public", + "table": "core_navigation" + }, + { + "type": "text", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "href", + "entityType": "columns", + "schema": "public", + "table": "core_navigation" + }, + { + "type": "varchar(64)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "icon", + "entityType": "columns", + "schema": "public", + "table": "core_navigation" + }, + { + "type": "boolean", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "false", + "generated": null, + "identity": null, + "name": "isOpenInNewTab", + "entityType": "columns", + "schema": "public", + "table": "core_navigation" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "0", + "generated": null, + "identity": null, + "name": "position", + "entityType": "columns", + "schema": "public", + "table": "core_navigation" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "createdAt", + "entityType": "columns", + "schema": "public", + "table": "core_navigation" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "updatedAt", + "entityType": "columns", + "schema": "public", + "table": "core_navigation" + }, + { + "type": "varchar(120)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "pageId", + "entityType": "columns", + "schema": "public", + "table": "core_page_layouts" + }, + { + "type": "jsonb", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "'{}'", + "generated": null, + "identity": null, + "name": "zones", + "entityType": "columns", + "schema": "public", + "table": "core_page_layouts" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "createdAt", + "entityType": "columns", + "schema": "public", + "table": "core_page_layouts" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "updatedAt", + "entityType": "columns", + "schema": "public", + "table": "core_page_layouts" + }, + { + "type": "serial", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "id", + "entityType": "columns", + "schema": "public", + "table": "core_users_passkey_challenges" + }, + { + "type": "varchar(64)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "tokenHash", + "entityType": "columns", + "schema": "public", + "table": "core_users_passkey_challenges" + }, + { + "type": "varchar(16)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "ceremony", + "entityType": "columns", + "schema": "public", + "table": "core_users_passkey_challenges" + }, + { + "type": "varchar(128)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "challenge", + "entityType": "columns", + "schema": "public", + "table": "core_users_passkey_challenges" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "userId", + "entityType": "columns", + "schema": "public", + "table": "core_users_passkey_challenges" + }, + { + "type": "varchar(128)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "webauthnUserId", + "entityType": "columns", + "schema": "public", + "table": "core_users_passkey_challenges" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "createdAt", + "entityType": "columns", + "schema": "public", + "table": "core_users_passkey_challenges" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "expiresAt", + "entityType": "columns", + "schema": "public", + "table": "core_users_passkey_challenges" + }, + { + "type": "serial", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "id", + "entityType": "columns", + "schema": "public", + "table": "core_users_passkeys" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "userId", + "entityType": "columns", + "schema": "public", + "table": "core_users_passkeys" + }, + { + "type": "varchar(1024)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "credentialId", + "entityType": "columns", + "schema": "public", + "table": "core_users_passkeys" + }, + { + "type": "text", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "publicKey", + "entityType": "columns", + "schema": "public", + "table": "core_users_passkeys" + }, + { + "type": "bigint", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "0", + "generated": null, + "identity": null, + "name": "counter", + "entityType": "columns", + "schema": "public", + "table": "core_users_passkeys" + }, + { + "type": "varchar(128)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "webauthnUserId", + "entityType": "columns", + "schema": "public", + "table": "core_users_passkeys" + }, + { + "type": "varchar(32)", + "typeSchema": null, + "notNull": true, + "dimensions": 1, + "default": "'{}'", + "generated": null, + "identity": null, + "name": "transports", + "entityType": "columns", + "schema": "public", + "table": "core_users_passkeys" + }, + { + "type": "varchar(32)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "deviceType", + "entityType": "columns", + "schema": "public", + "table": "core_users_passkeys" + }, + { + "type": "boolean", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "false", + "generated": null, + "identity": null, + "name": "backedUp", + "entityType": "columns", + "schema": "public", + "table": "core_users_passkeys" + }, + { + "type": "varchar(36)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "aaguid", + "entityType": "columns", + "schema": "public", + "table": "core_users_passkeys" + }, + { + "type": "varchar(64)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "name", + "entityType": "columns", + "schema": "public", + "table": "core_users_passkeys" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "createdAt", + "entityType": "columns", + "schema": "public", + "table": "core_users_passkeys" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "updatedAt", + "entityType": "columns", + "schema": "public", + "table": "core_users_passkeys" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "lastUsedAt", + "entityType": "columns", + "schema": "public", + "table": "core_users_passkeys" + }, + { + "type": "serial", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "id", + "entityType": "columns", + "schema": "public", + "table": "core_queue" + }, + { + "type": "varchar(100)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "pluginId", + "entityType": "columns", + "schema": "public", + "table": "core_queue" + }, + { + "type": "varchar(100)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "name", + "entityType": "columns", + "schema": "public", + "table": "core_queue" + }, + { + "type": "varchar(100)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "'default'", + "generated": null, + "identity": null, + "name": "queue", + "entityType": "columns", + "schema": "public", + "table": "core_queue" + }, + { + "type": "varchar(20)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "'pending'", + "generated": null, + "identity": null, + "name": "status", + "entityType": "columns", + "schema": "public", + "table": "core_queue" + }, + { + "type": "jsonb", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "'{}'", + "generated": null, + "identity": null, + "name": "payload", + "entityType": "columns", + "schema": "public", + "table": "core_queue" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "0", + "generated": null, + "identity": null, + "name": "priority", + "entityType": "columns", + "schema": "public", + "table": "core_queue" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "0", + "generated": null, + "identity": null, + "name": "attempts", + "entityType": "columns", + "schema": "public", + "table": "core_queue" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "3", + "generated": null, + "identity": null, + "name": "maxAttempts", + "entityType": "columns", + "schema": "public", + "table": "core_queue" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "availableAt", + "entityType": "columns", + "schema": "public", + "table": "core_queue" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "reservedAt", + "entityType": "columns", + "schema": "public", + "table": "core_queue" + }, + { + "type": "text", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "lastError", + "entityType": "columns", + "schema": "public", + "table": "core_queue" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "createdAt", + "entityType": "columns", + "schema": "public", + "table": "core_queue" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "updatedAt", + "entityType": "columns", + "schema": "public", + "table": "core_queue" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "completedAt", + "entityType": "columns", + "schema": "public", + "table": "core_queue" + }, + { + "type": "serial", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "id", + "entityType": "columns", + "schema": "public", + "table": "core_roles" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "createdAt", + "entityType": "columns", + "schema": "public", + "table": "core_roles" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "updatedAt", + "entityType": "columns", + "schema": "public", + "table": "core_roles" + }, + { + "type": "boolean", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "false", + "generated": null, + "identity": null, + "name": "protected", + "entityType": "columns", + "schema": "public", + "table": "core_roles" + }, + { + "type": "boolean", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "false", + "generated": null, + "identity": null, + "name": "default", + "entityType": "columns", + "schema": "public", + "table": "core_roles" + }, + { + "type": "boolean", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "false", + "generated": null, + "identity": null, + "name": "root", + "entityType": "columns", + "schema": "public", + "table": "core_roles" + }, + { + "type": "boolean", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "false", + "generated": null, + "identity": null, + "name": "guest", + "entityType": "columns", + "schema": "public", + "table": "core_roles" + }, + { + "type": "varchar(50)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "color", + "entityType": "columns", + "schema": "public", + "table": "core_roles" + }, + { + "type": "varchar(64)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "prefix", + "entityType": "columns", + "schema": "public", + "table": "core_roles" + }, + { + "type": "boolean", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "false", + "generated": null, + "identity": null, + "name": "allowUploadFiles", + "entityType": "columns", + "schema": "public", + "table": "core_roles" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "totalMaxStorage", + "entityType": "columns", + "schema": "public", + "table": "core_roles" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "maxStorageForSubmit", + "entityType": "columns", + "schema": "public", + "table": "core_roles" + }, + { + "type": "boolean", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "true", + "generated": null, + "identity": null, + "name": "allowUploadAvatar", + "entityType": "columns", + "schema": "public", + "table": "core_roles" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "2048", + "generated": null, + "identity": null, + "name": "maxAvatarSize", + "entityType": "columns", + "schema": "public", + "table": "core_roles" + }, + { + "type": "boolean", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "true", + "generated": null, + "identity": null, + "name": "allowUploadCover", + "entityType": "columns", + "schema": "public", + "table": "core_roles" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "5120", + "generated": null, + "identity": null, + "name": "maxCoverSize", + "entityType": "columns", + "schema": "public", + "table": "core_roles" + }, + { + "type": "boolean", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "true", + "generated": null, + "identity": null, + "name": "allowEditPersonalInfo", + "entityType": "columns", + "schema": "public", + "table": "core_roles" + }, + { + "type": "serial", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "id", + "entityType": "columns", + "schema": "public", + "table": "core_search_index" + }, + { + "type": "varchar(255)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "pluginId", + "entityType": "columns", + "schema": "public", + "table": "core_search_index" + }, + { + "type": "varchar(100)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "itemType", + "entityType": "columns", + "schema": "public", + "table": "core_search_index" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "itemId", + "entityType": "columns", + "schema": "public", + "table": "core_search_index" + }, + { + "type": "varchar(32)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "''", + "generated": null, + "identity": null, + "name": "languageCode", + "entityType": "columns", + "schema": "public", + "table": "core_search_index" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 1, + "default": "'{}'", + "generated": null, + "identity": null, + "name": "authorIds", + "entityType": "columns", + "schema": "public", + "table": "core_search_index" + }, + { + "type": "text", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "''", + "generated": null, + "identity": null, + "name": "title", + "entityType": "columns", + "schema": "public", + "table": "core_search_index" + }, + { + "type": "text", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "''", + "generated": null, + "identity": null, + "name": "content", + "entityType": "columns", + "schema": "public", + "table": "core_search_index" + }, + { + "type": "tsvector", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": { + "as": "setweight(to_tsvector(CASE lower(split_part(\"core_search_index\".\"languageCode\", '-', 1)) WHEN 'da' THEN 'danish'::regconfig WHEN 'de' THEN 'german'::regconfig WHEN 'en' THEN 'english'::regconfig WHEN 'es' THEN 'spanish'::regconfig WHEN 'fi' THEN 'finnish'::regconfig WHEN 'fr' THEN 'french'::regconfig WHEN 'hu' THEN 'hungarian'::regconfig WHEN 'it' THEN 'italian'::regconfig WHEN 'nl' THEN 'dutch'::regconfig WHEN 'no' THEN 'norwegian'::regconfig WHEN 'pl' THEN 'polish'::regconfig WHEN 'pt' THEN 'portuguese'::regconfig WHEN 'ro' THEN 'romanian'::regconfig WHEN 'ru' THEN 'russian'::regconfig WHEN 'sv' THEN 'swedish'::regconfig WHEN 'tr' THEN 'turkish'::regconfig ELSE 'simple'::regconfig END, coalesce(\"core_search_index\".\"title\", '')), 'A') || setweight(to_tsvector(CASE lower(split_part(\"core_search_index\".\"languageCode\", '-', 1)) WHEN 'da' THEN 'danish'::regconfig WHEN 'de' THEN 'german'::regconfig WHEN 'en' THEN 'english'::regconfig WHEN 'es' THEN 'spanish'::regconfig WHEN 'fi' THEN 'finnish'::regconfig WHEN 'fr' THEN 'french'::regconfig WHEN 'hu' THEN 'hungarian'::regconfig WHEN 'it' THEN 'italian'::regconfig WHEN 'nl' THEN 'dutch'::regconfig WHEN 'no' THEN 'norwegian'::regconfig WHEN 'pl' THEN 'polish'::regconfig WHEN 'pt' THEN 'portuguese'::regconfig WHEN 'ro' THEN 'romanian'::regconfig WHEN 'ru' THEN 'russian'::regconfig WHEN 'sv' THEN 'swedish'::regconfig WHEN 'tr' THEN 'turkish'::regconfig ELSE 'simple'::regconfig END, coalesce(\"core_search_index\".\"content\", '')), 'B')", + "type": "stored" + }, + "identity": null, + "name": "search_vector", + "entityType": "columns", + "schema": "public", + "table": "core_search_index" + }, + { + "type": "varchar(100)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "containerType", + "entityType": "columns", + "schema": "public", + "table": "core_search_index" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "containerId", + "entityType": "columns", + "schema": "public", + "table": "core_search_index" + }, + { + "type": "text", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "url", + "entityType": "columns", + "schema": "public", + "table": "core_search_index" + }, + { + "type": "boolean", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "true", + "generated": null, + "identity": null, + "name": "isPublic", + "entityType": "columns", + "schema": "public", + "table": "core_search_index" + }, + { + "type": "jsonb", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "'{}'", + "generated": null, + "identity": null, + "name": "metadata", + "entityType": "columns", + "schema": "public", + "table": "core_search_index" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "createdAt", + "entityType": "columns", + "schema": "public", + "table": "core_search_index" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "updatedAt", + "entityType": "columns", + "schema": "public", + "table": "core_search_index" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "indexedAt", + "entityType": "columns", + "schema": "public", + "table": "core_search_index" + }, + { + "type": "varchar(100)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "name", + "entityType": "columns", + "schema": "public", + "table": "core_secrets" + }, + { + "type": "text", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "value", + "entityType": "columns", + "schema": "public", + "table": "core_secrets" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "createdAt", + "entityType": "columns", + "schema": "public", + "table": "core_secrets" + }, + { + "type": "serial", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "id", + "entityType": "columns", + "schema": "public", + "table": "core_sessions" + }, + { + "type": "varchar(255)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "token", + "entityType": "columns", + "schema": "public", + "table": "core_sessions" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "userId", + "entityType": "columns", + "schema": "public", + "table": "core_sessions" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "createdAt", + "entityType": "columns", + "schema": "public", + "table": "core_sessions" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "expiresAt", + "entityType": "columns", + "schema": "public", + "table": "core_sessions" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "deviceId", + "entityType": "columns", + "schema": "public", + "table": "core_sessions" + }, + { + "type": "serial", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "id", + "entityType": "columns", + "schema": "public", + "table": "core_sessions_known_devices" + }, + { + "type": "varchar(32)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "publicId", + "entityType": "columns", + "schema": "public", + "table": "core_sessions_known_devices" + }, + { + "type": "varchar(40)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "ipAddress", + "entityType": "columns", + "schema": "public", + "table": "core_sessions_known_devices" + }, + { + "type": "text", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "userAgent", + "entityType": "columns", + "schema": "public", + "table": "core_sessions_known_devices" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "lastSeen", + "entityType": "columns", + "schema": "public", + "table": "core_sessions_known_devices" + }, + { + "type": "serial", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "id", + "entityType": "columns", + "schema": "public", + "table": "core_users" + }, + { + "type": "varchar(255)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "nameCode", + "entityType": "columns", + "schema": "public", + "table": "core_users" + }, + { + "type": "varchar(255)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "name", + "entityType": "columns", + "schema": "public", + "table": "core_users" + }, + { + "type": "varchar(255)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "email", + "entityType": "columns", + "schema": "public", + "table": "core_users" + }, + { + "type": "varchar(128)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "firstName", + "entityType": "columns", + "schema": "public", + "table": "core_users" + }, + { + "type": "varchar(128)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "lastName", + "entityType": "columns", + "schema": "public", + "table": "core_users" + }, + { + "type": "varchar(32)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "phone", + "entityType": "columns", + "schema": "public", + "table": "core_users" + }, + { + "type": "varchar(100)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "headline", + "entityType": "columns", + "schema": "public", + "table": "core_users" + }, + { + "type": "boolean", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "false", + "generated": null, + "identity": null, + "name": "showRealName", + "entityType": "columns", + "schema": "public", + "table": "core_users" + }, + { + "type": "varchar", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "password", + "entityType": "columns", + "schema": "public", + "table": "core_users" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "createdAt", + "entityType": "columns", + "schema": "public", + "table": "core_users" + }, + { + "type": "boolean", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "false", + "generated": null, + "identity": null, + "name": "newsletter", + "entityType": "columns", + "schema": "public", + "table": "core_users" + }, + { + "type": "varchar(6)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "avatarColor", + "entityType": "columns", + "schema": "public", + "table": "core_users" + }, + { + "type": "boolean", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "false", + "generated": null, + "identity": null, + "name": "emailVerified", + "entityType": "columns", + "schema": "public", + "table": "core_users" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "roleId", + "entityType": "columns", + "schema": "public", + "table": "core_users" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "birthday", + "entityType": "columns", + "schema": "public", + "table": "core_users" + }, + { + "type": "varchar(40)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "ipAddress", + "entityType": "columns", + "schema": "public", + "table": "core_users" + }, + { + "type": "varchar(32)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "'en'", + "generated": null, + "identity": null, + "name": "language", + "entityType": "columns", + "schema": "public", + "table": "core_users" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "avatarId", + "entityType": "columns", + "schema": "public", + "table": "core_users" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "coverId", + "entityType": "columns", + "schema": "public", + "table": "core_users" + }, + { + "type": "serial", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "id", + "entityType": "columns", + "schema": "public", + "table": "core_users_confirm_emails" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "userId", + "entityType": "columns", + "schema": "public", + "table": "core_users_confirm_emails" + }, + { + "type": "varchar(100)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "token", + "entityType": "columns", + "schema": "public", + "table": "core_users_confirm_emails" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "createdAt", + "entityType": "columns", + "schema": "public", + "table": "core_users_confirm_emails" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "expiresAt", + "entityType": "columns", + "schema": "public", + "table": "core_users_confirm_emails" + }, + { + "type": "varchar(40)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "ipAddress", + "entityType": "columns", + "schema": "public", + "table": "core_users_confirm_emails" + }, + { + "type": "serial", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "id", + "entityType": "columns", + "schema": "public", + "table": "core_users_forgot_password" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "userId", + "entityType": "columns", + "schema": "public", + "table": "core_users_forgot_password" + }, + { + "type": "varchar(100)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "token", + "entityType": "columns", + "schema": "public", + "table": "core_users_forgot_password" + }, + { + "type": "varchar(40)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "ipAddress", + "entityType": "columns", + "schema": "public", + "table": "core_users_forgot_password" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "createdAt", + "entityType": "columns", + "schema": "public", + "table": "core_users_forgot_password" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "expiresAt", + "entityType": "columns", + "schema": "public", + "table": "core_users_forgot_password" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "userId", + "entityType": "columns", + "schema": "public", + "table": "core_users_secondary_roles" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "roleId", + "entityType": "columns", + "schema": "public", + "table": "core_users_secondary_roles" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "createdAt", + "entityType": "columns", + "schema": "public", + "table": "core_users_secondary_roles" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "userId", + "entityType": "columns", + "schema": "public", + "table": "core_users_sso" + }, + { + "type": "varchar(255)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "providerId", + "entityType": "columns", + "schema": "public", + "table": "core_users_sso" + }, + { + "type": "varchar(255)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "providerAccountId", + "entityType": "columns", + "schema": "public", + "table": "core_users_sso" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "createdAt", + "entityType": "columns", + "schema": "public", + "table": "core_users_sso" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "updatedAt", + "entityType": "columns", + "schema": "public", + "table": "core_users_sso" + }, + { + "type": "serial", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "id", + "entityType": "columns", + "schema": "public", + "table": "blog_categories" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "createdAt", + "entityType": "columns", + "schema": "public", + "table": "blog_categories" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "updatedAt", + "entityType": "columns", + "schema": "public", + "table": "blog_categories" + }, + { + "type": "varchar(50)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "color", + "entityType": "columns", + "schema": "public", + "table": "blog_categories" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "itemId", + "entityType": "columns", + "schema": "public", + "table": "blog_categories_translations" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "languageId", + "entityType": "columns", + "schema": "public", + "table": "blog_categories_translations" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "1", + "generated": null, + "identity": null, + "name": "version", + "entityType": "columns", + "schema": "public", + "table": "blog_categories_translations" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "createdAt", + "entityType": "columns", + "schema": "public", + "table": "blog_categories_translations" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "updatedAt", + "entityType": "columns", + "schema": "public", + "table": "blog_categories_translations" + }, + { + "type": "varchar(100)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "name", + "entityType": "columns", + "schema": "public", + "table": "blog_categories_translations" + }, + { + "type": "serial", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "id", + "entityType": "columns", + "schema": "public", + "table": "blog_posts" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "createdAt", + "entityType": "columns", + "schema": "public", + "table": "blog_posts" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "updatedAt", + "entityType": "columns", + "schema": "public", + "table": "blog_posts" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "publishedAt", + "entityType": "columns", + "schema": "public", + "table": "blog_posts" + }, + { + "type": "varchar(32)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "'draft'", + "generated": null, + "identity": null, + "name": "status", + "entityType": "columns", + "schema": "public", + "table": "blog_posts" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "1", + "generated": null, + "identity": null, + "name": "version", + "entityType": "columns", + "schema": "public", + "table": "blog_posts" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "coverImage", + "entityType": "columns", + "schema": "public", + "table": "blog_posts" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "itemId", + "entityType": "columns", + "schema": "public", + "table": "blog_posts_author_id" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "relatedItemId", + "entityType": "columns", + "schema": "public", + "table": "blog_posts_author_id" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "position", + "entityType": "columns", + "schema": "public", + "table": "blog_posts_author_id" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "createdAt", + "entityType": "columns", + "schema": "public", + "table": "blog_posts_author_id" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "itemId", + "entityType": "columns", + "schema": "public", + "table": "blog_posts_category_id" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "relatedItemId", + "entityType": "columns", + "schema": "public", + "table": "blog_posts_category_id" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "position", + "entityType": "columns", + "schema": "public", + "table": "blog_posts_category_id" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "createdAt", + "entityType": "columns", + "schema": "public", + "table": "blog_posts_category_id" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "itemId", + "entityType": "columns", + "schema": "public", + "table": "blog_posts_translations" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "languageId", + "entityType": "columns", + "schema": "public", + "table": "blog_posts_translations" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "1", + "generated": null, + "identity": null, + "name": "version", + "entityType": "columns", + "schema": "public", + "table": "blog_posts_translations" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "createdAt", + "entityType": "columns", + "schema": "public", + "table": "blog_posts_translations" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "updatedAt", + "entityType": "columns", + "schema": "public", + "table": "blog_posts_translations" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "publishedAt", + "entityType": "columns", + "schema": "public", + "table": "blog_posts_translations" + }, + { + "type": "varchar(32)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "'draft'", + "generated": null, + "identity": null, + "name": "status", + "entityType": "columns", + "schema": "public", + "table": "blog_posts_translations" + }, + { + "type": "varchar(255)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "title", + "entityType": "columns", + "schema": "public", + "table": "blog_posts_translations" + }, + { + "type": "varchar(255)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "friendlyUrl", + "entityType": "columns", + "schema": "public", + "table": "blog_posts_translations" + }, + { + "type": "text", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "content", + "entityType": "columns", + "schema": "public", + "table": "blog_posts_translations" + }, + { + "type": "varchar(255)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "coverImageAlt", + "entityType": "columns", + "schema": "public", + "table": "blog_posts_translations" + }, + { + "type": "serial", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "id", + "entityType": "columns", + "schema": "public", + "table": "example_advanced_articles" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "createdAt", + "entityType": "columns", + "schema": "public", + "table": "example_advanced_articles" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "updatedAt", + "entityType": "columns", + "schema": "public", + "table": "example_advanced_articles" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "publishedAt", + "entityType": "columns", + "schema": "public", + "table": "example_advanced_articles" + }, + { + "type": "varchar(32)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "'draft'", + "generated": null, + "identity": null, + "name": "status", + "entityType": "columns", + "schema": "public", + "table": "example_advanced_articles" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "1", + "generated": null, + "identity": null, + "name": "version", + "entityType": "columns", + "schema": "public", + "table": "example_advanced_articles" + }, + { + "type": "boolean", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "true", + "generated": null, + "identity": null, + "name": "syndicationIndexable", + "entityType": "columns", + "schema": "public", + "table": "example_advanced_articles" + }, + { + "type": "boolean", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "false", + "generated": null, + "identity": null, + "name": "syndicationNoIndex", + "entityType": "columns", + "schema": "public", + "table": "example_advanced_articles" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "5", + "generated": null, + "identity": null, + "name": "syndicationPriority", + "entityType": "columns", + "schema": "public", + "table": "example_advanced_articles" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "itemId", + "entityType": "columns", + "schema": "public", + "table": "example_advanced_articles_categories" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "relatedItemId", + "entityType": "columns", + "schema": "public", + "table": "example_advanced_articles_categories" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "position", + "entityType": "columns", + "schema": "public", + "table": "example_advanced_articles_categories" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "createdAt", + "entityType": "columns", + "schema": "public", + "table": "example_advanced_articles_categories" + }, + { + "type": "serial", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "id", + "entityType": "columns", + "schema": "public", + "table": "example_advanced_articles_faq" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "itemId", + "entityType": "columns", + "schema": "public", + "table": "example_advanced_articles_faq" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "position", + "entityType": "columns", + "schema": "public", + "table": "example_advanced_articles_faq" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "createdAt", + "entityType": "columns", + "schema": "public", + "table": "example_advanced_articles_faq" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "updatedAt", + "entityType": "columns", + "schema": "public", + "table": "example_advanced_articles_faq" + }, + { + "type": "varchar(200)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "question", + "entityType": "columns", + "schema": "public", + "table": "example_advanced_articles_faq" + }, + { + "type": "text", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "answer", + "entityType": "columns", + "schema": "public", + "table": "example_advanced_articles_faq" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "itemId", + "entityType": "columns", + "schema": "public", + "table": "example_advanced_articles_related_articles" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "relatedItemId", + "entityType": "columns", + "schema": "public", + "table": "example_advanced_articles_related_articles" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "position", + "entityType": "columns", + "schema": "public", + "table": "example_advanced_articles_related_articles" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "createdAt", + "entityType": "columns", + "schema": "public", + "table": "example_advanced_articles_related_articles" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "itemId", + "entityType": "columns", + "schema": "public", + "table": "example_advanced_articles_translations" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "languageId", + "entityType": "columns", + "schema": "public", + "table": "example_advanced_articles_translations" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "1", + "generated": null, + "identity": null, + "name": "version", + "entityType": "columns", + "schema": "public", + "table": "example_advanced_articles_translations" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "createdAt", + "entityType": "columns", + "schema": "public", + "table": "example_advanced_articles_translations" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "updatedAt", + "entityType": "columns", + "schema": "public", + "table": "example_advanced_articles_translations" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "publishedAt", + "entityType": "columns", + "schema": "public", + "table": "example_advanced_articles_translations" + }, + { + "type": "varchar(32)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "'draft'", + "generated": null, + "identity": null, + "name": "status", + "entityType": "columns", + "schema": "public", + "table": "example_advanced_articles_translations" + }, + { + "type": "varchar(200)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "title", + "entityType": "columns", + "schema": "public", + "table": "example_advanced_articles_translations" + }, + { + "type": "varchar(160)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "slug", + "entityType": "columns", + "schema": "public", + "table": "example_advanced_articles_translations" + }, + { + "type": "varchar(200)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "seoTitle", + "entityType": "columns", + "schema": "public", + "table": "example_advanced_articles_translations" + }, + { + "type": "text", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "seoDescription", + "entityType": "columns", + "schema": "public", + "table": "example_advanced_articles_translations" + }, + { + "type": "serial", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "id", + "entityType": "columns", + "schema": "public", + "table": "example_articles" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "createdAt", + "entityType": "columns", + "schema": "public", + "table": "example_articles" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "updatedAt", + "entityType": "columns", + "schema": "public", + "table": "example_articles" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "publishedAt", + "entityType": "columns", + "schema": "public", + "table": "example_articles" + }, + { + "type": "varchar(32)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "'draft'", + "generated": null, + "identity": null, + "name": "status", + "entityType": "columns", + "schema": "public", + "table": "example_articles" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "1", + "generated": null, + "identity": null, + "name": "version", + "entityType": "columns", + "schema": "public", + "table": "example_articles" + }, + { + "type": "varchar(200)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "title", + "entityType": "columns", + "schema": "public", + "table": "example_articles" + }, + { + "type": "varchar(160)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "slug", + "entityType": "columns", + "schema": "public", + "table": "example_articles" + }, + { + "type": "varchar(100)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "code", + "entityType": "columns", + "schema": "public", + "table": "example_articles" + }, + { + "type": "text", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "excerpt", + "entityType": "columns", + "schema": "public", + "table": "example_articles" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "0", + "generated": null, + "identity": null, + "name": "views", + "entityType": "columns", + "schema": "public", + "table": "example_articles" + }, + { + "type": "boolean", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "false", + "generated": null, + "identity": null, + "name": "featured", + "entityType": "columns", + "schema": "public", + "table": "example_articles" + }, + { + "type": "boolean", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "noIndex", + "entityType": "columns", + "schema": "public", + "table": "example_articles" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "author", + "entityType": "columns", + "schema": "public", + "table": "example_articles" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "animation", + "entityType": "columns", + "schema": "public", + "table": "example_articles" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "category", + "entityType": "columns", + "schema": "public", + "table": "example_articles" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "itemId", + "entityType": "columns", + "schema": "public", + "table": "example_articles_gallery" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "relatedItemId", + "entityType": "columns", + "schema": "public", + "table": "example_articles_gallery" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "position", + "entityType": "columns", + "schema": "public", + "table": "example_articles_gallery" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "createdAt", + "entityType": "columns", + "schema": "public", + "table": "example_articles_gallery" + }, + { + "type": "serial", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "id", + "entityType": "columns", + "schema": "public", + "table": "example_categories" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "createdAt", + "entityType": "columns", + "schema": "public", + "table": "example_categories" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "updatedAt", + "entityType": "columns", + "schema": "public", + "table": "example_categories" + }, + { + "type": "varchar(100)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "name", + "entityType": "columns", + "schema": "public", + "table": "example_categories" + }, + { + "type": "serial", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "id", + "entityType": "columns", + "schema": "public", + "table": "example_localized_articles" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "createdAt", + "entityType": "columns", + "schema": "public", + "table": "example_localized_articles" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "updatedAt", + "entityType": "columns", + "schema": "public", + "table": "example_localized_articles" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "publishedAt", + "entityType": "columns", + "schema": "public", + "table": "example_localized_articles" + }, + { + "type": "varchar(32)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "'draft'", + "generated": null, + "identity": null, + "name": "status", + "entityType": "columns", + "schema": "public", + "table": "example_localized_articles" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "1", + "generated": null, + "identity": null, + "name": "version", + "entityType": "columns", + "schema": "public", + "table": "example_localized_articles" + }, + { + "type": "boolean", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "false", + "generated": null, + "identity": null, + "name": "featured", + "entityType": "columns", + "schema": "public", + "table": "example_localized_articles" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "itemId", + "entityType": "columns", + "schema": "public", + "table": "example_localized_articles_translations" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "languageId", + "entityType": "columns", + "schema": "public", + "table": "example_localized_articles_translations" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "1", + "generated": null, + "identity": null, + "name": "version", + "entityType": "columns", + "schema": "public", + "table": "example_localized_articles_translations" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "createdAt", + "entityType": "columns", + "schema": "public", + "table": "example_localized_articles_translations" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "updatedAt", + "entityType": "columns", + "schema": "public", + "table": "example_localized_articles_translations" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "publishedAt", + "entityType": "columns", + "schema": "public", + "table": "example_localized_articles_translations" + }, + { + "type": "varchar(32)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "'draft'", + "generated": null, + "identity": null, + "name": "status", + "entityType": "columns", + "schema": "public", + "table": "example_localized_articles_translations" + }, + { + "type": "varchar(200)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "title", + "entityType": "columns", + "schema": "public", + "table": "example_localized_articles_translations" + }, + { + "type": "varchar(160)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "slug", + "entityType": "columns", + "schema": "public", + "table": "example_localized_articles_translations" + }, + { + "type": "text", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "body", + "entityType": "columns", + "schema": "public", + "table": "example_localized_articles_translations" + }, + { + "type": "serial", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "id", + "entityType": "columns", + "schema": "public", + "table": "example_pages" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "createdAt", + "entityType": "columns", + "schema": "public", + "table": "example_pages" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "updatedAt", + "entityType": "columns", + "schema": "public", + "table": "example_pages" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "publishedAt", + "entityType": "columns", + "schema": "public", + "table": "example_pages" + }, + { + "type": "varchar(32)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "'draft'", + "generated": null, + "identity": null, + "name": "status", + "entityType": "columns", + "schema": "public", + "table": "example_pages" + }, + { + "type": "varchar(200)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "title", + "entityType": "columns", + "schema": "public", + "table": "example_pages" + }, + { + "type": "varchar(160)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "slug", + "entityType": "columns", + "schema": "public", + "table": "example_pages" + }, + { + "type": "jsonb", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "'[]'", + "generated": null, + "identity": null, + "name": "content", + "entityType": "columns", + "schema": "public", + "table": "example_pages" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "roleId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_admin_permissions_role_id_idx", + "entityType": "indexes", + "schema": "public", + "table": "core_admin_permissions" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "userId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_admin_permissions_user_id_idx", + "entityType": "indexes", + "schema": "public", + "table": "core_admin_permissions" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "updatedAt", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "id", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_admin_permissions_updated_at_id_idx", + "entityType": "indexes", + "schema": "public", + "table": "core_admin_permissions" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "token", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_admin_sessions_token_idx", + "entityType": "indexes", + "schema": "public", + "table": "core_admin_sessions" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "userId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_admin_sessions_user_id_idx", + "entityType": "indexes", + "schema": "public", + "table": "core_admin_sessions" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "revisionId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "fileId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": true, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_content_file_refs_unique", + "entityType": "indexes", + "schema": "public", + "table": "core_content_file_refs" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "fileId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_content_file_refs_file_id_idx", + "entityType": "indexes", + "schema": "public", + "table": "core_content_file_refs" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "contentTypeId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "itemId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "version", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": true, + "where": "\"languageId\" IS NULL", + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_content_revisions_item_version_unique", + "entityType": "indexes", + "schema": "public", + "table": "core_content_revisions" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "contentTypeId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "itemId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "languageId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "version", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": true, + "where": "\"languageId\" IS NOT NULL", + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_content_revisions_translation_version_unique", + "entityType": "indexes", + "schema": "public", + "table": "core_content_revisions" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "contentTypeId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "itemId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "languageId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "version", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_content_revisions_language_idx", + "entityType": "indexes", + "schema": "public", + "table": "core_content_revisions" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "pluginId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_content_revisions_plugin_id_idx", + "entityType": "indexes", + "schema": "public", + "table": "core_content_revisions" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "actorUserId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_content_revisions_actor_user_id_idx", + "entityType": "indexes", + "schema": "public", + "table": "core_content_revisions" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "contentTypeId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "itemId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "action", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": true, + "where": "status = 'pending'", + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_content_schedules_active_unique", + "entityType": "indexes", + "schema": "public", + "table": "core_content_schedules" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "status", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "scheduledFor", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_content_schedules_due_idx", + "entityType": "indexes", + "schema": "public", + "table": "core_content_schedules" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "contentTypeId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "itemId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_content_schedules_item_idx", + "entityType": "indexes", + "schema": "public", + "table": "core_content_schedules" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "pluginId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_content_schedules_plugin_id_idx", + "entityType": "indexes", + "schema": "public", + "table": "core_content_schedules" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "createdBy", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_content_schedules_created_by_idx", + "entityType": "indexes", + "schema": "public", + "table": "core_content_schedules" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "contentTypeId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "slug", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": true, + "where": "\"languageId\" IS NULL", + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_content_slug_history_shared_unique", + "entityType": "indexes", + "schema": "public", + "table": "core_content_slug_history" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "contentTypeId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "languageId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "slug", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": true, + "where": "\"languageId\" IS NOT NULL", + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_content_slug_history_locale_unique", + "entityType": "indexes", + "schema": "public", + "table": "core_content_slug_history" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "contentTypeId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "itemId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "languageId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_content_slug_history_item_idx", + "entityType": "indexes", + "schema": "public", + "table": "core_content_slug_history" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "pluginId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_content_slug_history_plugin_id_idx", + "entityType": "indexes", + "schema": "public", + "table": "core_content_slug_history" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "lastRun", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "id", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_cron_last_run_id_idx", + "entityType": "indexes", + "schema": "public", + "table": "core_cron" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "userId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_admin_dashboard_user_id_idx", + "entityType": "indexes", + "schema": "public", + "table": "core_admin_dashboard" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "userId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_files_user_id_idx", + "entityType": "indexes", + "schema": "public", + "table": "core_files" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "createdAt", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "id", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_files_created_at_id_idx", + "entityType": "indexes", + "schema": "public", + "table": "core_files" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "code", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_languages_code_idx", + "entityType": "indexes", + "schema": "public", + "table": "core_languages" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "name", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_languages_name_idx", + "entityType": "indexes", + "schema": "public", + "table": "core_languages" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "languageCode", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_languages_words_lang_code_idx", + "entityType": "indexes", + "schema": "public", + "table": "core_languages_words" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "createdAt", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "id", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_logs_created_at_id_idx", + "entityType": "indexes", + "schema": "public", + "table": "core_logs" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "roleId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_moderators_permissions_role_id_idx", + "entityType": "indexes", + "schema": "public", + "table": "core_moderators_permissions" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "userId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_moderators_permissions_user_id_idx", + "entityType": "indexes", + "schema": "public", + "table": "core_moderators_permissions" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "updatedAt", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "id", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_moderators_permissions_updated_at_id_idx", + "entityType": "indexes", + "schema": "public", + "table": "core_moderators_permissions" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "position", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_navigation_position_idx", + "entityType": "indexes", + "schema": "public", + "table": "core_navigation" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "location", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_navigation_location_idx", + "entityType": "indexes", + "schema": "public", + "table": "core_navigation" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "parentId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_navigation_parent_idx", + "entityType": "indexes", + "schema": "public", + "table": "core_navigation" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "expiresAt", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_users_passkey_challenges_expires_at_idx", + "entityType": "indexes", + "schema": "public", + "table": "core_users_passkey_challenges" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "userId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_users_passkey_challenges_user_id_idx", + "entityType": "indexes", + "schema": "public", + "table": "core_users_passkey_challenges" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "userId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_users_passkeys_user_id_idx", + "entityType": "indexes", + "schema": "public", + "table": "core_users_passkeys" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "webauthnUserId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_users_passkeys_webauthn_user_id_idx", + "entityType": "indexes", + "schema": "public", + "table": "core_users_passkeys" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "status", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "availableAt", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_queue_status_available_at_idx", + "entityType": "indexes", + "schema": "public", + "table": "core_queue" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "createdAt", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "id", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_queue_created_at_id_idx", + "entityType": "indexes", + "schema": "public", + "table": "core_queue" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "updatedAt", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "id", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_roles_updated_at_id_idx", + "entityType": "indexes", + "schema": "public", + "table": "core_roles" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "search_vector", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "gin", + "concurrently": false, + "name": "core_search_index_search_vector_idx", + "entityType": "indexes", + "schema": "public", + "table": "core_search_index" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "createdAt", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_search_index_created_at_idx", + "entityType": "indexes", + "schema": "public", + "table": "core_search_index" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "authorIds", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "gin", + "concurrently": false, + "name": "core_search_index_author_ids_idx", + "entityType": "indexes", + "schema": "public", + "table": "core_search_index" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "itemType", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_search_index_item_type_idx", + "entityType": "indexes", + "schema": "public", + "table": "core_search_index" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "languageCode", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_search_index_language_code_idx", + "entityType": "indexes", + "schema": "public", + "table": "core_search_index" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "isPublic", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_search_index_is_public_idx", + "entityType": "indexes", + "schema": "public", + "table": "core_search_index" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "userId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_sessions_user_id_idx", + "entityType": "indexes", + "schema": "public", + "table": "core_sessions" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "ipAddress", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_sessions_known_devices_ip_address_idx", + "entityType": "indexes", + "schema": "public", + "table": "core_sessions_known_devices" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "nameCode", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_users_name_code_idx", + "entityType": "indexes", + "schema": "public", + "table": "core_users" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "name", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_users_name_idx", + "entityType": "indexes", + "schema": "public", + "table": "core_users" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "email", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_users_email_idx", + "entityType": "indexes", + "schema": "public", + "table": "core_users" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "avatarId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_users_avatar_id_idx", + "entityType": "indexes", + "schema": "public", + "table": "core_users" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "coverId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_users_cover_id_idx", + "entityType": "indexes", + "schema": "public", + "table": "core_users" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "createdAt", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "id", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_users_created_at_id_idx", + "entityType": "indexes", + "schema": "public", + "table": "core_users" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "userId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_users_secondary_roles_user_id_idx", + "entityType": "indexes", + "schema": "public", + "table": "core_users_secondary_roles" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "roleId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_users_secondary_roles_role_id_idx", + "entityType": "indexes", + "schema": "public", + "table": "core_users_secondary_roles" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "userId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_users_sso_user_id_idx", + "entityType": "indexes", + "schema": "public", + "table": "core_users_sso" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "createdAt", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "blog_categories_created_at_idx", + "entityType": "indexes", + "schema": "public", + "table": "blog_categories" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "updatedAt", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "blog_categories_updated_at_idx", + "entityType": "indexes", + "schema": "public", + "table": "blog_categories" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "languageId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "blog_categories_translations_language_id_idx", + "entityType": "indexes", + "schema": "public", + "table": "blog_categories_translations" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "status", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "createdAt", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "blog_posts_status_created_at_idx", + "entityType": "indexes", + "schema": "public", + "table": "blog_posts" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "coverImage", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "blog_posts_cover_image_idx", + "entityType": "indexes", + "schema": "public", + "table": "blog_posts" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "createdAt", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "blog_posts_created_at_idx", + "entityType": "indexes", + "schema": "public", + "table": "blog_posts" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "updatedAt", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "blog_posts_updated_at_idx", + "entityType": "indexes", + "schema": "public", + "table": "blog_posts" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "status", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "publishedAt", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "blog_posts_status_published_at_idx", + "entityType": "indexes", + "schema": "public", + "table": "blog_posts" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "itemId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "position", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": true, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "blog_posts_author_id_position_key", + "entityType": "indexes", + "schema": "public", + "table": "blog_posts_author_id" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "relatedItemId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "blog_posts_author_id_related_item_id_idx", + "entityType": "indexes", + "schema": "public", + "table": "blog_posts_author_id" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "itemId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "position", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": true, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "blog_posts_category_id_position_key", + "entityType": "indexes", + "schema": "public", + "table": "blog_posts_category_id" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "relatedItemId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "blog_posts_category_id_related_item_id_idx", + "entityType": "indexes", + "schema": "public", + "table": "blog_posts_category_id" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "languageId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "status", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "blog_posts_translations_language_id_status_idx", + "entityType": "indexes", + "schema": "public", + "table": "blog_posts_translations" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "languageId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "friendlyUrl", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": true, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "blog_posts_translations_language_id_friendly_url_key", + "entityType": "indexes", + "schema": "public", + "table": "blog_posts_translations" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "syndicationPriority", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "example_advanced_articles_syndication_priority_idx", + "entityType": "indexes", + "schema": "public", + "table": "example_advanced_articles" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "createdAt", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "example_advanced_articles_created_at_idx", + "entityType": "indexes", + "schema": "public", + "table": "example_advanced_articles" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "updatedAt", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "example_advanced_articles_updated_at_idx", + "entityType": "indexes", + "schema": "public", + "table": "example_advanced_articles" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "status", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "publishedAt", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "example_advanced_articles_status_published_at_idx", + "entityType": "indexes", + "schema": "public", + "table": "example_advanced_articles" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "itemId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "position", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": true, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "example_advanced_articles_categories_position_key", + "entityType": "indexes", + "schema": "public", + "table": "example_advanced_articles_categories" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "relatedItemId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "example_advanced_articles_categories_related_item_id_idx", + "entityType": "indexes", + "schema": "public", + "table": "example_advanced_articles_categories" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "itemId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "position", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": true, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "example_advanced_articles_faq_position_key", + "entityType": "indexes", + "schema": "public", + "table": "example_advanced_articles_faq" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "itemId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "position", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": true, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "example_advanced_articles_related_articles_position_key", + "entityType": "indexes", + "schema": "public", + "table": "example_advanced_articles_related_articles" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "relatedItemId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "example_advanced_articles_related_articles_related_item_id_idx", + "entityType": "indexes", + "schema": "public", + "table": "example_advanced_articles_related_articles" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "languageId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "status", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "example_advanced_articles_translations_language_id_status_idx", + "entityType": "indexes", + "schema": "public", + "table": "example_advanced_articles_translations" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "languageId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "slug", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": true, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "example_advanced_articles_translations_language_id_slug_key", + "entityType": "indexes", + "schema": "public", + "table": "example_advanced_articles_translations" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "status", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "createdAt", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "example_articles_status_created_at_idx", + "entityType": "indexes", + "schema": "public", + "table": "example_articles" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "slug", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": true, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "example_articles_slug_key", + "entityType": "indexes", + "schema": "public", + "table": "example_articles" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "code", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": true, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "example_articles_code_key", + "entityType": "indexes", + "schema": "public", + "table": "example_articles" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "author", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "example_articles_author_idx", + "entityType": "indexes", + "schema": "public", + "table": "example_articles" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "animation", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "example_articles_animation_idx", + "entityType": "indexes", + "schema": "public", + "table": "example_articles" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "category", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "example_articles_category_idx", + "entityType": "indexes", + "schema": "public", + "table": "example_articles" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "createdAt", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "example_articles_created_at_idx", + "entityType": "indexes", + "schema": "public", + "table": "example_articles" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "updatedAt", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "example_articles_updated_at_idx", + "entityType": "indexes", + "schema": "public", + "table": "example_articles" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "status", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "publishedAt", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "example_articles_status_published_at_idx", + "entityType": "indexes", + "schema": "public", + "table": "example_articles" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "itemId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "position", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": true, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "example_articles_gallery_position_key", + "entityType": "indexes", + "schema": "public", + "table": "example_articles_gallery" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "relatedItemId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "example_articles_gallery_related_item_id_idx", + "entityType": "indexes", + "schema": "public", + "table": "example_articles_gallery" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "createdAt", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "example_categories_created_at_idx", + "entityType": "indexes", + "schema": "public", + "table": "example_categories" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "updatedAt", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "example_categories_updated_at_idx", + "entityType": "indexes", + "schema": "public", + "table": "example_categories" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "createdAt", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "example_localized_articles_created_at_idx", + "entityType": "indexes", + "schema": "public", + "table": "example_localized_articles" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "updatedAt", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "example_localized_articles_updated_at_idx", + "entityType": "indexes", + "schema": "public", + "table": "example_localized_articles" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "status", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "publishedAt", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "example_localized_articles_status_published_at_idx", + "entityType": "indexes", + "schema": "public", + "table": "example_localized_articles" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "languageId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "status", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "example_localized_articles_translations_language_id_status_idx", + "entityType": "indexes", + "schema": "public", + "table": "example_localized_articles_translations" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "languageId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "slug", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": true, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "example_localized_articles_translations_language_id_slug_key", + "entityType": "indexes", + "schema": "public", + "table": "example_localized_articles_translations" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "slug", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": true, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "example_pages_slug_key", + "entityType": "indexes", + "schema": "public", + "table": "example_pages" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "createdAt", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "example_pages_created_at_idx", + "entityType": "indexes", + "schema": "public", + "table": "example_pages" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "updatedAt", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "example_pages_updated_at_idx", + "entityType": "indexes", + "schema": "public", + "table": "example_pages" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "status", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "publishedAt", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "example_pages_status_published_at_idx", + "entityType": "indexes", + "schema": "public", + "table": "example_pages" + }, + { + "nameExplicit": false, + "columns": [ + "roleId" + ], + "schemaTo": "public", + "tableTo": "core_roles", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "name": "core_admin_permissions_roleId_core_roles_id_fk", + "entityType": "fks", + "schema": "public", + "table": "core_admin_permissions" + }, + { + "nameExplicit": false, + "columns": [ + "userId" + ], + "schemaTo": "public", + "tableTo": "core_users", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "name": "core_admin_permissions_userId_core_users_id_fk", + "entityType": "fks", + "schema": "public", + "table": "core_admin_permissions" + }, + { + "nameExplicit": false, + "columns": [ + "userId" + ], + "schemaTo": "public", + "tableTo": "core_users", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "name": "core_admin_sessions_userId_core_users_id_fk", + "entityType": "fks", + "schema": "public", + "table": "core_admin_sessions" + }, + { + "nameExplicit": false, + "columns": [ + "deviceId" + ], + "schemaTo": "public", + "tableTo": "core_sessions_known_devices", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "name": "core_admin_sessions_deviceId_core_sessions_known_devices_id_fk", + "entityType": "fks", + "schema": "public", + "table": "core_admin_sessions" + }, + { + "nameExplicit": false, + "columns": [ + "revisionId" + ], + "schemaTo": "public", + "tableTo": "core_content_revisions", + "columnsTo": [ + "id" + ], + "onUpdate": "CASCADE", + "onDelete": "CASCADE", + "name": "core_content_file_refs_5get6SfhBPHr_fkey", + "entityType": "fks", + "schema": "public", + "table": "core_content_file_refs" + }, + { + "nameExplicit": false, + "columns": [ + "fileId" + ], + "schemaTo": "public", + "tableTo": "core_files", + "columnsTo": [ + "id" + ], + "onUpdate": "CASCADE", + "onDelete": "RESTRICT", + "name": "core_content_file_refs_fileId_core_files_id_fkey", + "entityType": "fks", + "schema": "public", + "table": "core_content_file_refs" + }, + { + "nameExplicit": false, + "columns": [ + "actorUserId" + ], + "schemaTo": "public", + "tableTo": "core_users", + "columnsTo": [ + "id" + ], + "onUpdate": "CASCADE", + "onDelete": "SET NULL", + "name": "core_content_revisions_actorUserId_core_users_id_fk", + "entityType": "fks", + "schema": "public", + "table": "core_content_revisions" + }, + { + "nameExplicit": false, + "columns": [ + "createdBy" + ], + "schemaTo": "public", + "tableTo": "core_users", + "columnsTo": [ + "id" + ], + "onUpdate": "CASCADE", + "onDelete": "SET NULL", + "name": "core_content_schedules_createdBy_core_users_id_fk", + "entityType": "fks", + "schema": "public", + "table": "core_content_schedules" + }, + { + "nameExplicit": false, + "columns": [ + "userId" + ], + "schemaTo": "public", + "tableTo": "core_users", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "name": "core_admin_dashboard_userId_core_users_id_fk", + "entityType": "fks", + "schema": "public", + "table": "core_admin_dashboard" + }, + { + "nameExplicit": false, + "columns": [ + "userId" + ], + "schemaTo": "public", + "tableTo": "core_users", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "SET NULL", + "name": "core_files_userId_core_users_id_fk", + "entityType": "fks", + "schema": "public", + "table": "core_files" + }, + { + "nameExplicit": false, + "columns": [ + "languageCode" + ], + "schemaTo": "public", + "tableTo": "core_languages", + "columnsTo": [ + "code" + ], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "name": "core_languages_words_languageCode_core_languages_code_fk", + "entityType": "fks", + "schema": "public", + "table": "core_languages_words" + }, + { + "nameExplicit": false, + "columns": [ + "userId" + ], + "schemaTo": "public", + "tableTo": "core_users", + "columnsTo": [ + "id" + ], + "onUpdate": "CASCADE", + "onDelete": "SET NULL", + "name": "core_logs_userId_core_users_id_fk", + "entityType": "fks", + "schema": "public", + "table": "core_logs" + }, + { + "nameExplicit": false, + "columns": [ + "roleId" + ], + "schemaTo": "public", + "tableTo": "core_roles", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "name": "core_moderators_permissions_roleId_core_roles_id_fk", + "entityType": "fks", + "schema": "public", + "table": "core_moderators_permissions" + }, + { + "nameExplicit": false, + "columns": [ + "userId" + ], + "schemaTo": "public", + "tableTo": "core_users", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "name": "core_moderators_permissions_userId_core_users_id_fk", + "entityType": "fks", + "schema": "public", + "table": "core_moderators_permissions" + }, + { + "nameExplicit": false, + "columns": [ + "parentId" + ], + "schemaTo": "public", + "tableTo": "core_navigation", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "SET NULL", + "name": "core_navigation_parentId_core_navigation_id_fkey", + "entityType": "fks", + "schema": "public", + "table": "core_navigation" + }, + { + "nameExplicit": false, + "columns": [ + "userId" + ], + "schemaTo": "public", + "tableTo": "core_users", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "name": "core_users_passkey_challenges_userId_core_users_id_fkey", + "entityType": "fks", + "schema": "public", + "table": "core_users_passkey_challenges" + }, + { + "nameExplicit": false, + "columns": [ + "userId" + ], + "schemaTo": "public", + "tableTo": "core_users", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "name": "core_users_passkeys_userId_core_users_id_fkey", + "entityType": "fks", + "schema": "public", + "table": "core_users_passkeys" + }, + { + "nameExplicit": false, + "columns": [ + "userId" + ], + "schemaTo": "public", + "tableTo": "core_users", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "name": "core_sessions_userId_core_users_id_fk", + "entityType": "fks", + "schema": "public", + "table": "core_sessions" + }, + { + "nameExplicit": false, + "columns": [ + "deviceId" + ], + "schemaTo": "public", + "tableTo": "core_sessions_known_devices", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "name": "core_sessions_deviceId_core_sessions_known_devices_id_fk", + "entityType": "fks", + "schema": "public", + "table": "core_sessions" + }, + { + "nameExplicit": false, + "columns": [ + "roleId" + ], + "schemaTo": "public", + "tableTo": "core_roles", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "NO ACTION", + "name": "core_users_roleId_core_roles_id_fk", + "entityType": "fks", + "schema": "public", + "table": "core_users" + }, + { + "nameExplicit": false, + "columns": [ + "language" + ], + "schemaTo": "public", + "tableTo": "core_languages", + "columnsTo": [ + "code" + ], + "onUpdate": "NO ACTION", + "onDelete": "SET DEFAULT", + "name": "core_users_language_core_languages_code_fk", + "entityType": "fks", + "schema": "public", + "table": "core_users" + }, + { + "nameExplicit": false, + "columns": [ + "avatarId" + ], + "schemaTo": "public", + "tableTo": "core_files", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "SET NULL", + "name": "core_users_avatarId_core_files_id_fkey", + "entityType": "fks", + "schema": "public", + "table": "core_users" + }, + { + "nameExplicit": false, + "columns": [ + "coverId" + ], + "schemaTo": "public", + "tableTo": "core_files", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "SET NULL", + "name": "core_users_coverId_core_files_id_fkey", + "entityType": "fks", + "schema": "public", + "table": "core_users" + }, + { + "nameExplicit": false, + "columns": [ + "userId" + ], + "schemaTo": "public", + "tableTo": "core_users", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "name": "core_users_confirm_emails_userId_core_users_id_fk", + "entityType": "fks", + "schema": "public", + "table": "core_users_confirm_emails" + }, + { + "nameExplicit": false, + "columns": [ + "userId" + ], + "schemaTo": "public", + "tableTo": "core_users", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "name": "core_users_forgot_password_userId_core_users_id_fk", + "entityType": "fks", + "schema": "public", + "table": "core_users_forgot_password" + }, + { + "nameExplicit": false, + "columns": [ + "userId" + ], + "schemaTo": "public", + "tableTo": "core_users", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "name": "core_users_secondary_roles_userId_core_users_id_fk", + "entityType": "fks", + "schema": "public", + "table": "core_users_secondary_roles" + }, + { + "nameExplicit": false, + "columns": [ + "roleId" + ], + "schemaTo": "public", + "tableTo": "core_roles", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "name": "core_users_secondary_roles_roleId_core_roles_id_fk", + "entityType": "fks", + "schema": "public", + "table": "core_users_secondary_roles" + }, + { + "nameExplicit": false, + "columns": [ + "userId" + ], + "schemaTo": "public", + "tableTo": "core_users", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "name": "core_users_sso_userId_core_users_id_fk", + "entityType": "fks", + "schema": "public", + "table": "core_users_sso" + }, + { + "nameExplicit": false, + "columns": [ + "itemId" + ], + "schemaTo": "public", + "tableTo": "blog_categories", + "columnsTo": [ + "id" + ], + "onUpdate": "CASCADE", + "onDelete": "CASCADE", + "name": "blog_categories_translations_itemId_blog_categories_id_fk", + "entityType": "fks", + "schema": "public", + "table": "blog_categories_translations" + }, + { + "nameExplicit": false, + "columns": [ + "languageId" + ], + "schemaTo": "public", + "tableTo": "core_languages", + "columnsTo": [ + "id" + ], + "onUpdate": "CASCADE", + "onDelete": "RESTRICT", + "name": "blog_categories_translations_languageId_core_languages_id_fk", + "entityType": "fks", + "schema": "public", + "table": "blog_categories_translations" + }, + { + "nameExplicit": false, + "columns": [ + "coverImage" + ], + "schemaTo": "public", + "tableTo": "core_files", + "columnsTo": [ + "id" + ], + "onUpdate": "CASCADE", + "onDelete": "RESTRICT", + "name": "blog_posts_coverImage_core_files_id_fkey", + "entityType": "fks", + "schema": "public", + "table": "blog_posts" + }, + { + "nameExplicit": false, + "columns": [ + "itemId" + ], + "schemaTo": "public", + "tableTo": "blog_posts", + "columnsTo": [ + "id" + ], + "onUpdate": "CASCADE", + "onDelete": "CASCADE", + "name": "blog_posts_author_id_itemId_blog_posts_id_fk", + "entityType": "fks", + "schema": "public", + "table": "blog_posts_author_id" + }, + { + "nameExplicit": false, + "columns": [ + "relatedItemId" + ], + "schemaTo": "public", + "tableTo": "core_users", + "columnsTo": [ + "id" + ], + "onUpdate": "CASCADE", + "onDelete": "CASCADE", + "name": "blog_posts_author_id_relatedItemId_core_users_id_fk", + "entityType": "fks", + "schema": "public", + "table": "blog_posts_author_id" + }, + { + "nameExplicit": false, + "columns": [ + "itemId" + ], + "schemaTo": "public", + "tableTo": "blog_posts", + "columnsTo": [ + "id" + ], + "onUpdate": "CASCADE", + "onDelete": "CASCADE", + "name": "blog_posts_category_id_itemId_blog_posts_id_fk", + "entityType": "fks", + "schema": "public", + "table": "blog_posts_category_id" + }, + { + "nameExplicit": false, + "columns": [ + "relatedItemId" + ], + "schemaTo": "public", + "tableTo": "blog_categories", + "columnsTo": [ + "id" + ], + "onUpdate": "CASCADE", + "onDelete": "RESTRICT", + "name": "blog_posts_category_id_relatedItemId_blog_categories_id_fk", + "entityType": "fks", + "schema": "public", + "table": "blog_posts_category_id" + }, + { + "nameExplicit": false, + "columns": [ + "itemId" + ], + "schemaTo": "public", + "tableTo": "blog_posts", + "columnsTo": [ + "id" + ], + "onUpdate": "CASCADE", + "onDelete": "CASCADE", + "name": "blog_posts_translations_itemId_blog_posts_id_fk", + "entityType": "fks", + "schema": "public", + "table": "blog_posts_translations" + }, + { + "nameExplicit": false, + "columns": [ + "languageId" + ], + "schemaTo": "public", + "tableTo": "core_languages", + "columnsTo": [ + "id" + ], + "onUpdate": "CASCADE", + "onDelete": "RESTRICT", + "name": "blog_posts_translations_languageId_core_languages_id_fk", + "entityType": "fks", + "schema": "public", + "table": "blog_posts_translations" + }, + { + "nameExplicit": false, + "columns": [ + "itemId" + ], + "schemaTo": "public", + "tableTo": "example_advanced_articles", + "columnsTo": [ + "id" + ], + "onUpdate": "CASCADE", + "onDelete": "CASCADE", + "name": "example_advanced_articles_categories_itemId_example_advanced_ar", + "entityType": "fks", + "schema": "public", + "table": "example_advanced_articles_categories" + }, + { + "nameExplicit": false, + "columns": [ + "relatedItemId" + ], + "schemaTo": "public", + "tableTo": "example_categories", + "columnsTo": [ + "id" + ], + "onUpdate": "CASCADE", + "onDelete": "RESTRICT", + "name": "example_advanced_articles_categories_relatedItemId_example_cate", + "entityType": "fks", + "schema": "public", + "table": "example_advanced_articles_categories" + }, + { + "nameExplicit": false, + "columns": [ + "itemId" + ], + "schemaTo": "public", + "tableTo": "example_advanced_articles", + "columnsTo": [ + "id" + ], + "onUpdate": "CASCADE", + "onDelete": "CASCADE", + "name": "example_advanced_articles_faq_itemId_example_advanced_articles_", + "entityType": "fks", + "schema": "public", + "table": "example_advanced_articles_faq" + }, + { + "nameExplicit": false, + "columns": [ + "itemId" + ], + "schemaTo": "public", + "tableTo": "example_advanced_articles", + "columnsTo": [ + "id" + ], + "onUpdate": "CASCADE", + "onDelete": "CASCADE", + "name": "example_advanced_articles_related_articles_itemId_example_advan", + "entityType": "fks", + "schema": "public", + "table": "example_advanced_articles_related_articles" + }, + { + "nameExplicit": false, + "columns": [ + "relatedItemId" + ], + "schemaTo": "public", + "tableTo": "example_advanced_articles", + "columnsTo": [ + "id" + ], + "onUpdate": "CASCADE", + "onDelete": "CASCADE", + "name": "example_advanced_articles_related_articles_relatedItemId_exampl", + "entityType": "fks", + "schema": "public", + "table": "example_advanced_articles_related_articles" + }, + { + "nameExplicit": false, + "columns": [ + "itemId" + ], + "schemaTo": "public", + "tableTo": "example_advanced_articles", + "columnsTo": [ + "id" + ], + "onUpdate": "CASCADE", + "onDelete": "CASCADE", + "name": "example_advanced_articles_translations_itemId_example_advanced_", + "entityType": "fks", + "schema": "public", + "table": "example_advanced_articles_translations" + }, + { + "nameExplicit": false, + "columns": [ + "languageId" + ], + "schemaTo": "public", + "tableTo": "core_languages", + "columnsTo": [ + "id" + ], + "onUpdate": "CASCADE", + "onDelete": "RESTRICT", + "name": "example_advanced_articles_translations_languageId_core_language", + "entityType": "fks", + "schema": "public", + "table": "example_advanced_articles_translations" + }, + { + "nameExplicit": false, + "columns": [ + "author" + ], + "schemaTo": "public", + "tableTo": "core_users", + "columnsTo": [ + "id" + ], + "onUpdate": "CASCADE", + "onDelete": "SET NULL", + "name": "example_articles_author_core_users_id_fk", + "entityType": "fks", + "schema": "public", + "table": "example_articles" + }, + { + "nameExplicit": false, + "columns": [ + "animation" + ], + "schemaTo": "public", + "tableTo": "core_files", + "columnsTo": [ + "id" + ], + "onUpdate": "CASCADE", + "onDelete": "RESTRICT", + "name": "example_articles_animation_core_files_id_fkey", + "entityType": "fks", + "schema": "public", + "table": "example_articles" + }, + { + "nameExplicit": false, + "columns": [ + "category" + ], + "schemaTo": "public", + "tableTo": "example_categories", + "columnsTo": [ + "id" + ], + "onUpdate": "CASCADE", + "onDelete": "RESTRICT", + "name": "example_articles_category_example_categories_id_fk", + "entityType": "fks", + "schema": "public", + "table": "example_articles" + }, + { + "nameExplicit": false, + "columns": [ + "itemId" + ], + "schemaTo": "public", + "tableTo": "example_articles", + "columnsTo": [ + "id" + ], + "onUpdate": "CASCADE", + "onDelete": "CASCADE", + "name": "example_articles_gallery_itemId_example_articles_id_fkey", + "entityType": "fks", + "schema": "public", + "table": "example_articles_gallery" + }, + { + "nameExplicit": false, + "columns": [ + "relatedItemId" + ], + "schemaTo": "public", + "tableTo": "core_files", + "columnsTo": [ + "id" + ], + "onUpdate": "CASCADE", + "onDelete": "RESTRICT", + "name": "example_articles_gallery_relatedItemId_core_files_id_fkey", + "entityType": "fks", + "schema": "public", + "table": "example_articles_gallery" + }, + { + "nameExplicit": false, + "columns": [ + "itemId" + ], + "schemaTo": "public", + "tableTo": "example_localized_articles", + "columnsTo": [ + "id" + ], + "onUpdate": "CASCADE", + "onDelete": "CASCADE", + "name": "example_localized_articles_translations_itemId_example_localize", + "entityType": "fks", + "schema": "public", + "table": "example_localized_articles_translations" + }, + { + "nameExplicit": false, + "columns": [ + "languageId" + ], + "schemaTo": "public", + "tableTo": "core_languages", + "columnsTo": [ + "id" + ], + "onUpdate": "CASCADE", + "onDelete": "RESTRICT", + "name": "example_localized_articles_translations_languageId_core_languag", + "entityType": "fks", + "schema": "public", + "table": "example_localized_articles_translations" + }, + { + "columns": [ + "userId", + "roleId" + ], + "nameExplicit": false, + "name": "core_users_secondary_roles_userId_roleId_pk", + "entityType": "pks", + "schema": "public", + "table": "core_users_secondary_roles" + }, + { + "columns": [ + "itemId", + "languageId" + ], + "nameExplicit": true, + "name": "blog_categories_translations_item_id_language_id_pk", + "entityType": "pks", + "schema": "public", + "table": "blog_categories_translations" + }, + { + "columns": [ + "itemId", + "relatedItemId" + ], + "nameExplicit": true, + "name": "blog_posts_author_id_pk", + "entityType": "pks", + "schema": "public", + "table": "blog_posts_author_id" + }, + { + "columns": [ + "itemId", + "relatedItemId" + ], + "nameExplicit": true, + "name": "blog_posts_category_id_pk", + "entityType": "pks", + "schema": "public", + "table": "blog_posts_category_id" + }, + { + "columns": [ + "itemId", + "languageId" + ], + "nameExplicit": true, + "name": "blog_posts_translations_item_id_language_id_pk", + "entityType": "pks", + "schema": "public", + "table": "blog_posts_translations" + }, + { + "columns": [ + "itemId", + "relatedItemId" + ], + "nameExplicit": true, + "name": "example_advanced_articles_categories_pk", + "entityType": "pks", + "schema": "public", + "table": "example_advanced_articles_categories" + }, + { + "columns": [ + "itemId", + "relatedItemId" + ], + "nameExplicit": true, + "name": "example_advanced_articles_related_articles_pk", + "entityType": "pks", + "schema": "public", + "table": "example_advanced_articles_related_articles" + }, + { + "columns": [ + "itemId", + "languageId" + ], + "nameExplicit": true, + "name": "example_advanced_articles_translations_item_id_language_id_pk", + "entityType": "pks", + "schema": "public", + "table": "example_advanced_articles_translations" + }, + { + "columns": [ + "itemId", + "relatedItemId" + ], + "nameExplicit": true, + "name": "example_articles_gallery_pk", + "entityType": "pks", + "schema": "public", + "table": "example_articles_gallery" + }, + { + "columns": [ + "itemId", + "languageId" + ], + "nameExplicit": true, + "name": "example_localized_articles_translations_item_id_language_id_pk", + "entityType": "pks", + "schema": "public", + "table": "example_localized_articles_translations" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "core_admin_permissions_pkey", + "schema": "public", + "table": "core_admin_permissions", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "core_admin_sessions_pkey", + "schema": "public", + "table": "core_admin_sessions", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "core_content_file_refs_pkey", + "schema": "public", + "table": "core_content_file_refs", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "core_content_revisions_pkey", + "schema": "public", + "table": "core_content_revisions", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "core_content_schedules_pkey", + "schema": "public", + "table": "core_content_schedules", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "core_content_slug_history_pkey", + "schema": "public", + "table": "core_content_slug_history", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "core_cron_pkey", + "schema": "public", + "table": "core_cron", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "core_admin_dashboard_pkey", + "schema": "public", + "table": "core_admin_dashboard", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "core_files_pkey", + "schema": "public", + "table": "core_files", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "core_languages_pkey", + "schema": "public", + "table": "core_languages", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "core_languages_words_pkey", + "schema": "public", + "table": "core_languages_words", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "core_logs_pkey", + "schema": "public", + "table": "core_logs", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "core_moderators_permissions_pkey", + "schema": "public", + "table": "core_moderators_permissions", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "core_navigation_pkey", + "schema": "public", + "table": "core_navigation", + "entityType": "pks" + }, + { + "columns": [ + "pageId" + ], + "nameExplicit": false, + "name": "core_page_layouts_pkey", + "schema": "public", + "table": "core_page_layouts", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "core_users_passkey_challenges_pkey", + "schema": "public", + "table": "core_users_passkey_challenges", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "core_users_passkeys_pkey", + "schema": "public", + "table": "core_users_passkeys", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "core_queue_pkey", + "schema": "public", + "table": "core_queue", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "core_roles_pkey", + "schema": "public", + "table": "core_roles", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "core_search_index_pkey", + "schema": "public", + "table": "core_search_index", + "entityType": "pks" + }, + { + "columns": [ + "name" + ], + "nameExplicit": false, + "name": "core_secrets_pkey", + "schema": "public", + "table": "core_secrets", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "core_sessions_pkey", + "schema": "public", + "table": "core_sessions", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "core_sessions_known_devices_pkey", + "schema": "public", + "table": "core_sessions_known_devices", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "core_users_pkey", + "schema": "public", + "table": "core_users", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "core_users_confirm_emails_pkey", + "schema": "public", + "table": "core_users_confirm_emails", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "core_users_forgot_password_pkey", + "schema": "public", + "table": "core_users_forgot_password", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "blog_categories_pkey", + "schema": "public", + "table": "blog_categories", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "blog_posts_pkey", + "schema": "public", + "table": "blog_posts", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "example_advanced_articles_pkey", + "schema": "public", + "table": "example_advanced_articles", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "example_advanced_articles_faq_pkey", + "schema": "public", + "table": "example_advanced_articles_faq", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "example_articles_pkey", + "schema": "public", + "table": "example_articles", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "example_categories_pkey", + "schema": "public", + "table": "example_categories", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "example_localized_articles_pkey", + "schema": "public", + "table": "example_localized_articles", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "example_pages_pkey", + "schema": "public", + "table": "example_pages", + "entityType": "pks" + }, + { + "nameExplicit": true, + "columns": [ + "itemType", + "itemId", + "languageCode" + ], + "nullsNotDistinct": false, + "name": "core_search_index_item_unique", + "entityType": "uniques", + "schema": "public", + "table": "core_search_index" + }, + { + "nameExplicit": false, + "columns": [ + "token" + ], + "nullsNotDistinct": false, + "name": "core_admin_sessions_token_unique", + "schema": "public", + "table": "core_admin_sessions", + "entityType": "uniques" + }, + { + "nameExplicit": false, + "columns": [ + "userId" + ], + "nullsNotDistinct": false, + "name": "core_admin_dashboard_userId_unique", + "schema": "public", + "table": "core_admin_dashboard", + "entityType": "uniques" + }, + { + "nameExplicit": false, + "columns": [ + "key" + ], + "nullsNotDistinct": false, + "name": "core_files_key_unique", + "schema": "public", + "table": "core_files", + "entityType": "uniques" + }, + { + "nameExplicit": false, + "columns": [ + "code" + ], + "nullsNotDistinct": false, + "name": "core_languages_code_unique", + "schema": "public", + "table": "core_languages", + "entityType": "uniques" + }, + { + "nameExplicit": false, + "columns": [ + "tokenHash" + ], + "nullsNotDistinct": false, + "name": "core_users_passkey_challenges_tokenHash_key", + "schema": "public", + "table": "core_users_passkey_challenges", + "entityType": "uniques" + }, + { + "nameExplicit": false, + "columns": [ + "credentialId" + ], + "nullsNotDistinct": false, + "name": "core_users_passkeys_credentialId_key", + "schema": "public", + "table": "core_users_passkeys", + "entityType": "uniques" + }, + { + "nameExplicit": false, + "columns": [ + "token" + ], + "nullsNotDistinct": false, + "name": "core_sessions_token_unique", + "schema": "public", + "table": "core_sessions", + "entityType": "uniques" + }, + { + "nameExplicit": false, + "columns": [ + "publicId" + ], + "nullsNotDistinct": false, + "name": "core_sessions_known_devices_publicId_unique", + "schema": "public", + "table": "core_sessions_known_devices", + "entityType": "uniques" + }, + { + "nameExplicit": false, + "columns": [ + "nameCode" + ], + "nullsNotDistinct": false, + "name": "core_users_nameCode_unique", + "schema": "public", + "table": "core_users", + "entityType": "uniques" + }, + { + "nameExplicit": false, + "columns": [ + "name" + ], + "nullsNotDistinct": false, + "name": "core_users_name_unique", + "schema": "public", + "table": "core_users", + "entityType": "uniques" + }, + { + "nameExplicit": false, + "columns": [ + "email" + ], + "nullsNotDistinct": false, + "name": "core_users_email_unique", + "schema": "public", + "table": "core_users", + "entityType": "uniques" + }, + { + "nameExplicit": false, + "columns": [ + "token" + ], + "nullsNotDistinct": false, + "name": "core_users_confirm_emails_token_unique", + "schema": "public", + "table": "core_users_confirm_emails", + "entityType": "uniques" + }, + { + "nameExplicit": false, + "columns": [ + "userId" + ], + "nullsNotDistinct": false, + "name": "core_users_forgot_password_userId_unique", + "schema": "public", + "table": "core_users_forgot_password", + "entityType": "uniques" + }, + { + "nameExplicit": false, + "columns": [ + "token" + ], + "nullsNotDistinct": false, + "name": "core_users_forgot_password_token_unique", + "schema": "public", + "table": "core_users_forgot_password", + "entityType": "uniques" + } + ], + "renames": [] +} \ No newline at end of file diff --git a/apps/api/src/vitnode.api.config.ts b/apps/api/src/vitnode.api.config.ts index 66b7f0b22..941af0c46 100644 --- a/apps/api/src/vitnode.api.config.ts +++ b/apps/api/src/vitnode.api.config.ts @@ -94,6 +94,7 @@ export const vitNodeApiConfig = buildApiConfig({ }, authorization: { + passkeys: true, ssoAdapters: [ DiscordSSOApiPlugin({ clientId: process.env.DISCORD_CLIENT_ID, diff --git a/apps/web/.env.example b/apps/web/.env.example index 692c239d6..07b629748 100644 --- a/apps/web/.env.example +++ b/apps/web/.env.example @@ -15,6 +15,14 @@ REDIS_URL=redis://localhost:6379 VITNODE_WEB_URL=http://localhost:3000 VITNODE_API_URL=http://localhost:3000 +# === Passkeys (WebAuthn) === +# Off unless `authorization.passkeys` is set in `vitnode.api.config.ts`. +# `passkeys: true` binds passkeys to the hostname of `VITNODE_WEB_URL` and only +# accepts that origin. Browsers allow them on HTTPS or on localhost. To share +# passkeys between `example.com` and `forum.example.com`, pass +# `{ rpId: "example.com", origins: [...] }` instead. Changing the RP ID later +# makes every existing passkey unusable. + # === CRON Secret for Internal API Calls === CRON_SECRET=your-secure-cron-secret-key diff --git a/apps/web/content/docs/dev/advanced/auth.mdx b/apps/web/content/docs/dev/advanced/auth.mdx index 4f1684b9c..f72c8b869 100644 --- a/apps/web/content/docs/dev/advanced/auth.mdx +++ b/apps/web/content/docs/dev/advanced/auth.mdx @@ -36,7 +36,7 @@ export const vitNodeApiConfig = buildApiConfig({ | **Known Device** | `vitnode_device` | 1 year | `core_sessions_known_devices` | Device authorization tracking | - Signing out of the AdminCP does not terminate the user's public session, and vice versa. An administrator compromised in a public context cannot access the AdminCP without re-authenticating with staff credentials. + Signing out of the AdminCP does not terminate the user's public session, and vice versa. An administrator compromised in a public context cannot access the AdminCP without re-authenticating with staff credentials - a password or a user-verified passkey, checked against live staff access. Neither a public session nor social SSO ever becomes an AdminCP session. --- @@ -117,9 +117,46 @@ The rules are per-provider, because they are only true per-provider: description: "Array of configured OAuth2 single sign-on adapters.", type: "SSOApiPlugin[]", }, + password: { + default: "true", + description: "Set to false to switch off email + password sign-in and sign-up for members. Config only - there is no AdminCP toggle. AdminCP sign-in at /admin keeps using passwords (and passkeys, if enabled).", + type: "boolean", + }, + passkeys: { + default: "false", + description: "WebAuthn passkeys: true for the defaults, or { rpId, rpName, origins } to override them. A public passkey sign-in creates a normal public session, never an AdminCP one; the AdminCP has its own passkey flow. See /docs/dev/passkeys and /docs/dev/passkeys/admincp.", + type: "boolean | PasskeysConfig", + }, }} /> +## Turning off password sign-in + +Running a passkeys-only or social-login-only community? Switch passwords off: + +```ts title="apps/api/src/vitnode.api.config.ts" +export const vitNodeApiConfig = buildApiConfig({ + // [!code ++:3] + authorization: { + password: false, + }, +}) +``` + +With passwords off: + +- `/login` hides the email + password form and **Forgot password?**. It keeps [SSO](/docs/dev/sso) buttons and **Sign in with a passkey** - if neither is configured, members see a "Signing in is unavailable" notice. +- `/register` hides the sign-up form. New accounts can still come in through SSO; with no SSO provider, the page says registration is unavailable and the "Sign up" link on `/login` goes away. +- `/login/reset-password` answers "not found". +- The API refuses `POST /users/sign_in` (for members), `/users/sign_up`, `/users/reset-password`, `/users/change-password` and password-based SSO linking with `403`. +- A stored password no longer counts as a way into the account, so members can't remove their last [passkey](/docs/dev/passkeys) unless they have another passkey or a linked SSO provider. + + + The AdminCP sign-in at `/admin` keeps its email + password form, so switching member passwords off never locks your staff out. With passkeys enabled, staff can also use [AdminCP passkey sign-in](/docs/dev/passkeys/admincp). Signing out works exactly as before. + + +Password hashes already in the database are left alone, so switching `enabled` back to `true` restores password sign-in for everyone who had one. + ## Learn More diff --git a/apps/web/content/docs/dev/events/built-in-events.mdx b/apps/web/content/docs/dev/events/built-in-events.mdx index 79ac19f8d..06fff643a 100644 --- a/apps/web/content/docs/dev/events/built-in-events.mdx +++ b/apps/web/content/docs/dev/events/built-in-events.mdx @@ -38,7 +38,7 @@ core event as for one of your own. ## Core events (`@vitnode/core`) -Nine names, all declared in `VitNodeEvents` in +Twelve names, all declared in `VitNodeEvents` in `packages/vitnode/src/api/models/events.ts`. Every one of them fires **after** the write it describes has committed. @@ -48,6 +48,9 @@ the write it describes has committed. | `user.updated` | `{ userId, email, name }` | A user is edited in the AdminCP (profile fields and/or role assignments) | | `user.deleted` | `{ userId, email }` | Never - the name is declared for plugins, core has no deletion flow | | `user.sso.linked` | `{ userId, email, providerId }` | A visitor proves an existing account is theirs with its password and an SSO identity is linked to it | +| `user.passkey.created` | `{ userId, passkeyId }` | A member adds a [passkey](/docs/dev/passkeys) in **Settings → Security** | +| `user.passkey.updated` | `{ userId, passkeyId, name }` | A member renames one of their passkeys | +| `user.passkey.deleted` | `{ userId, passkeyId }` | A member removes one of their passkeys | | `user.avatar.updated` | `{ userId, fileId }` | An avatar is uploaded or removed - by the user on their profile, or by staff in the AdminCP | | `user.cover.updated` | `{ userId, fileId }` | A profile cover is uploaded or removed - by the user on their profile, or by staff in the AdminCP | | `role.created` | `{ roleId }` | A role is created in the AdminCP | diff --git a/apps/web/content/docs/dev/meta.json b/apps/web/content/docs/dev/meta.json index cd3d742ca..68a352357 100644 --- a/apps/web/content/docs/dev/meta.json +++ b/apps/web/content/docs/dev/meta.json @@ -33,6 +33,7 @@ "email", "captcha", "sso", + "passkeys", "cron", "websocket", "advanced", diff --git a/apps/web/content/docs/dev/passkeys/admincp.mdx b/apps/web/content/docs/dev/passkeys/admincp.mdx new file mode 100644 index 000000000..31a544708 --- /dev/null +++ b/apps/web/content/docs/dev/passkeys/admincp.mdx @@ -0,0 +1,118 @@ +--- +title: AdminCP passkey sign-in +description: Let staff sign in to the VitNode AdminCP with a user-verified passkey. How enrollment, sign-in and recovery work, why user verification is required, and how AdminCP sessions stay separate from public ones. +icon: ShieldCheck +--- + +Staff can sign in to the AdminCP at `/admin` with the same passkey they use on the community site - fingerprint, face, PIN or security key, no password to type. It uses the passkeys you already [configured](/docs/dev/passkeys), so there is nothing extra to switch on. If passkeys are enabled, the **Sign in with a passkey** button shows up on the AdminCP login screen. + +The AdminCP is still the high-security zone, so this flow is stricter than the public one: + +- a **fresh AdminCP-only challenge** that lives for **two minutes** and works exactly once +- **user verification is required** - just tapping a key doesn't count +- **staff access is checked live** on the server at sign-in +- it creates an **AdminCP session only**, and never upgrades a public one + +## For staff: the short version + + + + +### Add a passkey (once) + +1. Sign in to the AdminCP at `/admin` with your **email and password**. +2. In the **same browser**, go to the community site, sign in, and open **Settings → Security**. +3. Select **Add a passkey** and confirm with your fingerprint, face or PIN. + + + + +### Sign in with it + +1. Go to `/admin`. +2. Select **Sign in with a passkey**. +3. Pick your passkey and confirm with your fingerprint, face or PIN. + +You'll land wherever you were heading in the AdminCP. + + + + + + Why the password first? Your account has AdminCP access, so VitNode only lets + it add a passkey while an AdminCP session for **you** is open in that browser. + That way a hijacked social login can't quietly add a passkey and walk into the + AdminCP. + + +## Enrollment + +Passkeys are enrolled in one place - **Settings → Security** - and one passkey works for both the site and the AdminCP. Nothing is stored per area, so there's no second passkey to manage. + +For accounts that **currently have AdminCP access**, both enrollment steps (`/register/options` and `/register`) also require the browser to hold a valid AdminCP session for the same user. Without one, the API answers `403 admin_session_required` and the settings page explains what to do. Members without staff access enroll exactly as before. + +The check runs at both steps, so an AdminCP session that expires or is signed out mid-ceremony stops the passkey from being saved. + +## Sign-in + +1. `POST /api/@vitnode/core/users/passkeys/admin-sign-in/options` issues request options with `userVerification: "required"` and no `allowCredentials`, so the browser offers any passkey saved for your RP ID. It stores a new challenge with the ceremony `admin_sign_in` and sets it in the HttpOnly cookie `_passkey_admin_sign_in`. +2. The browser asks the authenticator for an assertion. +3. `POST /api/@vitnode/core/users/passkeys/admin-sign-in` then: + - **consumes** the challenge atomically, scoped to the cookie's token hash, the `admin_sign_in` ceremony and "not expired". A public sign-in or registration challenge never matches, and a replay always fails. + - **verifies** the assertion with SimpleWebAuthn against your configured `origins` and `rpId`, with `requireUserVerification: true`, the same user-handle and signature-counter checks as public sign-in. + - **resolves the user from the stored credential** - never from anything else the browser sends, and never from the public session cookie. + - **checks staff access right now** with `SessionAdminModel.checkIfUserIsAdmin()`. Someone whose access was removed gets `403 not_staff`, even though their passkey is still valid. + - only then calls `SessionAdminModel.createSessionByUserId()` - the same call the password form makes - which sets the `vitnode_auth_admin` cookie. + +The routes live under `users/passkeys` rather than `admin/` on purpose: every `/admin/*` API path demands an AdminCP session, which nobody has yet while signing in. + +## Sessions stay separate + +| | Public passkey sign-in (`/login`) | AdminCP passkey sign-in (`/admin`) | +| ------------------------------ | --------------------------------- | -------------------------------------- | +| Challenge ceremony | `authentication` | `admin_sign_in` | +| Challenge lifetime | 5 minutes | 2 minutes | +| User verification | Required | Required | +| Staff access checked | No | Yes, at sign-in time | +| Creates | Public session (`vitnode_auth`) | AdminCP session (`vitnode_auth_admin`) | +| Reads the other area's session | No | No | + +A public session never turns into an AdminCP session - not after a passkey sign-in, not after SSO, not after a password sign-in. Signing out of one area leaves the other alone, exactly as described in [Authentication](/docs/dev/advanced/auth). + +Social SSO never issues an AdminCP session either. SSO providers only sign people in to the public site, and the staff enrollment rule above means an SSO-only session can't be turned into an AdminCP passkey. + +Once signed in, an AdminCP session behaves like one started with a password: staff access is re-checked on every request, so revoking it ends the session on the next request. + +## Recovery + +- **Password stays.** The AdminCP password form is untouched. It's the way in for staff who haven't added a passkey yet, and the fallback when a passkey is lost. +- **Lost the passkey?** Sign in to the AdminCP with your password, then add a new passkey in **Settings → Security** and remove the old one there. +- **Forgot the password too?** Use **Forgot password?** on the public `/login` page, then sign in to the AdminCP with the new password. +- The [last-recovery-method rule](/docs/dev/passkeys#keeping-members-locked-in-the-good-way) still applies, so you can't remove the last way into your account. + +## What people see + +| Message | Why | +| ------------------------------ | ----------------------------------------------------------------------------------------------------------- | +| Passkey sign-in cancelled | The browser prompt was closed or timed out. Try again. | +| That took a little too long | The two-minute challenge expired, was already used, or the cookie was blocked. Start again. | +| Passkey not recognized | The passkey was removed, belongs to another RP ID, or failed verification (including no user verification). | +| No AdminCP access | The passkey is valid, but the account doesn't have AdminCP access right now. | +| Passkeys aren't supported here | The browser or device can't use passkeys. The button is disabled and the password form still works. | +| Open the AdminCP first | (Settings → Security) A staff account tried to add a passkey without an AdminCP session in that browser. | + +## For developers + +Error codes (JSON body `{ error }`): + +| Status | Code | Route | +| ------ | ------------------------ | -------------------------------- | +| `400` | `invalid_challenge` | `/admin-sign-in` | +| `403` | `verification_failed` | `/admin-sign-in` | +| `403` | `not_staff` | `/admin-sign-in` | +| `403` | `admin_session_required` | `/register/options`, `/register` | +| `404` | `passkeys_disabled` | all | + +On the frontend, `useAdminPasskeySignInAction` from `@vitnode/core/tanstack/admin` runs the ceremony, drops the AdminCP identity caches and navigates to the sanitized `returnTo` - the same things the AdminCP password form does. It leaves the public session cache alone, because nothing about the public session changed. + +If you registered your own auth transport with `setAuthTransport`, implement `startAdminPasskeySignIn` and `finishAdminPasskeySignIn` as well, and relay cookies on both. The challenge cookie is set by the first call and read by the second, and the second sets the AdminCP cookie. The server-side versions are exported as `startAdminPasskeySignInOnApi` and `finishAdminPasskeySignInOnApi` from `@vitnode/core/tanstack/auth/server`. diff --git a/apps/web/content/docs/dev/passkeys/index.mdx b/apps/web/content/docs/dev/passkeys/index.mdx new file mode 100644 index 000000000..4108bf0d9 --- /dev/null +++ b/apps/web/content/docs/dev/passkeys/index.mdx @@ -0,0 +1,214 @@ +--- +title: Passkeys (WebAuthn) +description: Let members sign in with Face ID, Touch ID, Windows Hello or a security key. Configure the RP ID and origins, run the migration, and learn how the WebAuthn ceremonies work in VitNode. +icon: Fingerprint +--- + +import { Tab, Tabs } from "fumadocs-ui/components/tabs" +import { TypeTable } from "fumadocs-ui/components/type-table" + +Passkeys let members sign in with their fingerprint, face or screen lock instead of a password. They are built on WebAuthn, can't be phished, and there is nothing to leak - the server only ever stores a **public** key. + +VitNode ships passkeys in core. Members add them under **Settings → Security** and use them with **Sign in with a passkey** on `/login`. Staff can use the same passkey on the AdminCP login at `/admin` - see [AdminCP passkey sign-in](/docs/dev/passkeys/admincp). Under the hood it uses [SimpleWebAuthn](https://simplewebauthn.dev) (`@simplewebauthn/server` and `@simplewebauthn/browser`) - the same libraries Better Auth uses - while sessions, users and permissions stay 100% VitNode. + + + Looking for the member-facing walkthrough? See [Using + passkeys](/docs/dev/passkeys/using-passkeys) - it's written so you can link + your community straight to it. + + +## Quick start + +Passkeys are **opt-in**: they stay off until you set `authorization.passkeys`. `true` turns them on, with everything derived from `VITNODE_WEB_URL`: + +- **origin** - `VITNODE_WEB_URL` itself, e.g. `https://community.example.com` +- **RP ID** - its hostname, e.g. `community.example.com` +- **RP name** - `metadata.shortTitle`, falling back to `metadata.title` + +```ts title="apps/api/src/vitnode.api.config.ts" +import { buildApiConfig } from "@vitnode/core/vitnode.config" + +export const vitNodeApiConfig = buildApiConfig({ + // [!code ++:3] + authorization: { + passkeys: true, + }, +}) +``` + +Need something the defaults can't give you - several origins, or one RP ID shared across subdomains? Pass an object instead, and set only what differs: + +```ts title="apps/api/src/vitnode.api.config.ts" +authorization: { + passkeys: { + rpId: "example.com", + origins: ["https://example.com", "https://forum.example.com"], + }, +}, +``` + +Then run the migration (below) and you're done. The login page shows the passkey button as soon as the API reports passkeys as enabled. + +## Database migration + +Passkeys add two tables to core: + +| Table | What's in it | +| ------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `core_users_passkeys` | One row per credential: unique credential ID, COSE public key, signature counter, WebAuthn user handle, transports, device type, backup state, AAGUID, name, timestamps | +| `core_users_passkey_challenges` | Short-lived challenges: a SHA-256 hash of the browser token, the ceremony, the challenge, the user (for registration) and `expiresAt` | + +Both reference `core_users` with `ON DELETE CASCADE`. Apply them like any other core migration: + + + +```bash tab="bun" +bun run db:migrate +``` + +```bash tab="pnpm" +pnpm db:migrate +``` + +```bash tab="npm" +npm run db:migrate +``` + + + +In development `pnpm dev` runs `vitnode db:prepare`, which applies it for you. + +## RP ID, origins and HTTPS + +A passkey belongs to one **RP ID** (a domain). The browser only offers it on pages whose hostname is that domain or a subdomain of it, and VitNode only accepts responses from the **origins** you list. + +- **HTTPS is required**, with one exception: `localhost` (and `*.localhost`) works over plain HTTP, so local development needs no certificates. +- **IP addresses can't be RP IDs.** Open `http://localhost:3000`, not `http://127.0.0.1:3000` - the browser will refuse. +- **Origins are bare**: `https://example.com`, not `https://example.com/` or `https://example.com/login`. +- **Sharing across subdomains**: set `rpId: "example.com"` and list every origin, e.g. `https://example.com` and `https://forum.example.com`. + + + Choose the RP ID before members create passkeys. **Changing it later makes + every existing passkey unusable** - they are cryptographically bound to the + old domain. Members would have to sign in another way and add new ones. + + +VitNode checks the configuration when the API boots: + +- A `passkeys` setting that can't work (plain HTTP on a real domain, an RP ID that isn't a parent of an origin, an IP address - including a derived one like `VITNODE_WEB_URL=http://192.168.1.10:3000`) **fails the boot** with a message naming the problem. + +Turn passkeys off by removing `passkeys`, or with `passkeys: false`. The **Sign in with a passkey** button disappears from `/login`, the **Security** item disappears from settings (`/settings/security` answers "not found"), and every passkey route answers `404 passkeys_disabled`. Saved passkeys stay in the database, so switching passkeys back on brings them straight back. + + + +## How it works + +Every ceremony is two requests: **options** (the server issues a challenge) and **verify** (the browser answers it). All routes live under `/api/@vitnode/core/users/passkeys`. + +| Method | Path | Who | Does | +| -------- | ------------------- | ------------------- | ---------------------------------------------------------------------------------------------- | +| `POST` | `/register/options` | signed-in member | Issues creation options and a registration challenge bound to this browser **and** this member | +| `POST` | `/register` | signed-in member | Verifies the attestation and saves the passkey | +| `POST` | `/sign-in/options` | anyone | Issues request options with **no** `allowCredentials`, so the browser offers any saved passkey | +| `POST` | `/sign-in` | anyone | Verifies the assertion and starts a normal session | +| `POST` | `/admin-sign-in/options` | anyone | Issues a two-minute, AdminCP-only challenge with `userVerification: "required"` | +| `POST` | `/admin-sign-in` | anyone | Verifies the assertion, checks staff access live, and starts an **AdminCP** session only | +| `GET` | `/` | signed-in member | Lists the member's own passkeys | +| `PATCH` | `/{id}` | the passkey's owner | Renames it | +| `DELETE` | `/{id}` | the passkey's owner | Removes it - refused with `409 last_recovery_method` if it's the account's last way in | + +From the frontend, call them with the typed fetcher - `module: "users/passkeys"` - like any other core route: + +```ts +import { fetcherClient } from "@vitnode/core/lib/fetcher-client" + +const res = await fetcherClient({ + plugin: "@vitnode/core", + method: "get", + module: "users/passkeys", + path: "/", + options: { credentials: "include" }, +}) +``` + +### Challenges + +- Each options call creates a random token, stores **only its SHA-256 hash** with the challenge, and sets it in an HttpOnly cookie named `_passkey_registration`, `_passkey_authentication` or `_passkey_admin_sign_in` (so `vitnode_auth_passkey_...` by default). +- Challenges expire after **five minutes** - AdminCP sign-in challenges after **two**. Expired rows are swept every time a new ceremony starts. +- Verification consumes the challenge with a single `DELETE ... RETURNING` scoped to the token hash, the ceremony, "not expired" and - for registration - the signed-in user. It's atomic: two concurrent requests with the same cookie can never both win, and a replayed response always fails. +- Because the lookup is by the cookie's token, a challenge can't be used from another browser, by another account, or for another ceremony - a public sign-in challenge never opens the AdminCP, and the other way round. + +### Verification + +The server calls SimpleWebAuthn's `verifyRegistrationResponse` / `verifyAuthenticationResponse` with the stored challenge, your configured `origins` and `rpId`, and `requireUserVerification: true` (options also ask for `userVerification: "required"` and a discoverable credential with `residentKey: "required"`). + +On sign-in, the user comes **only** from the stored credential - anything the browser sends besides the WebAuthn response is ignored. If the authenticator returns a user handle, it must match the one saved with that credential. + +### Signature counters + +- Authenticators that always report `0` (most synced passkeys - iCloud Keychain, Google Password Manager) are accepted every time. +- Authenticators with a real counter must go **up**. A counter that stays the same or goes backwards - including dropping back to `0` - is rejected as a possible cloned key. +- The new counter is written with a compare-and-set (`WHERE counter = `), so two sign-ins racing on one credential can't both succeed. + +### Sessions + +A successful passkey sign-in calls `SessionModel.createSessionByUserId()` - exactly what password sign-in does - so it sets the usual `vitnode_auth` cookie on the current device. It **never** creates an AdminCP session, even for staff. The AdminCP has its own passkey flow at `/admin` with a separate challenge, a live staff check and its own session - see [AdminCP passkey sign-in](/docs/dev/passkeys/admincp). + +Accounts with AdminCP access can only **add** a passkey while an AdminCP session for the same user is open in that browser (`403 admin_session_required` otherwise). Everyone else enrolls as usual. + +On the client, `usePasskeySignInAction` clears identity-specific caches (the AdminCP entries, files, devices and passkeys) before navigating, and keeps the `returnTo` destination, just like the password form. + +### Keeping members locked in (the good way) + +A member can't remove their **last** passkey unless the account still has a password or a linked SSO provider. A password only counts while [password sign-in](/docs/dev/advanced/auth#turning-off-password-sign-in) is switched on. The check runs in a transaction that locks the user row, so two tabs deleting "one of two" passkeys at once can't leave the account with nothing. + +## Events + +| Event | Payload | Fires when | +| ---------------------- | ----------------------------- | -------------------------- | +| `user.passkey.created` | `{ passkeyId, userId }` | A member adds a passkey | +| `user.passkey.updated` | `{ passkeyId, userId, name }` | A member renames a passkey | +| `user.passkey.deleted` | `{ passkeyId, userId }` | A member removes a passkey | + +See [Built-in events](/docs/dev/events/built-in-events) for how to listen to them. + +## Deployment checklist + +- **Serve the site over HTTPS** on the exact origins you configured. A reverse proxy is fine - what matters is the URL in the member's address bar. +- **Set `VITNODE_WEB_URL`** in every environment, and set `rpId` in the config if you share passkeys across subdomains. +- **Preview deployments** on generated hostnames (e.g. `my-app-git-branch.vercel.app`) are different origins - passkeys created on production won't work there, and a preview needs its own origin in `origins` to register new ones. +- **Separate API server?** Nothing changes: the API checks the _web_ origin the ceremony ran on, not its own URL. Make sure `cookieDomain`/CORS already let the browser send credentialed requests to it. +- **Custom auth transport?** If you registered one with `setAuthTransport`, implement `startPasskeySignIn`, `finishPasskeySignIn`, `startAdminPasskeySignIn` and `finishAdminPasskeySignIn`, and relay cookies on all four - the challenge cookie is set by the first call and read by the second. +- **Rate limiting** - the options routes create a database row per call. Keep the [rate limiter](/docs/dev/advanced/rate-limiter) on in production. + +## Troubleshooting + +In development, VitNode logs why a ceremony failed (wrong origin, RP ID mismatch, counter error...) to the API console. The browser only ever receives a generic error code. + +| You see | Likely cause | +| ------------------------------------------------ | ------------------------------------------------------------------------------------------- | +| No passkey button on `/login` | Passkeys disabled, or the derived config was invalid - check the boot log | +| "The RP ID is neither ... nor a parent domain" | `rpId` doesn't match the hostname of an origin | +| Browser error "invalid domain" / `SecurityError` | The page's hostname isn't the RP ID or a subdomain of it, or the page isn't HTTPS/localhost | +| "That took a little too long" | The five-minute challenge expired, or the cookie was blocked - start again | +| "Passkey not recognized" | The passkey was removed on the site, or it belongs to another RP ID | diff --git a/apps/web/content/docs/dev/passkeys/meta.json b/apps/web/content/docs/dev/passkeys/meta.json new file mode 100644 index 000000000..f6761fcec --- /dev/null +++ b/apps/web/content/docs/dev/passkeys/meta.json @@ -0,0 +1,6 @@ +{ + "title": "Passkeys", + "description": "Passwordless sign-in with WebAuthn passkeys - Face ID, Touch ID, Windows Hello or a security key", + "icon": "Fingerprint", + "pages": ["index", "using-passkeys", "admincp"] +} diff --git a/apps/web/content/docs/dev/passkeys/using-passkeys.mdx b/apps/web/content/docs/dev/passkeys/using-passkeys.mdx new file mode 100644 index 000000000..b751fe2a5 --- /dev/null +++ b/apps/web/content/docs/dev/passkeys/using-passkeys.mdx @@ -0,0 +1,71 @@ +--- +title: Using passkeys +description: A friendly guide for members - how to create a passkey, sign in with it, and remove it when you no longer need it. +icon: KeyRound +--- + +A passkey lets you sign in with your fingerprint, your face or your device's screen lock - no password to remember, and nothing a phishing site can steal. It's the same thing your phone already uses to unlock itself, just pointed at this site. + +Your passkey is stored on your device or in your password manager (iCloud Keychain, Google Password Manager, 1Password, Bitwarden and friends). The site only ever keeps a public key, which is useless to anyone who gets hold of it. + +## Before you start + +- Use a recent browser: Chrome, Safari, Edge or Firefox. +- Your device needs a screen lock - a PIN, pattern, password, fingerprint or face unlock. A hardware security key with a PIN works too. +- You need to be signed in the usual way to add your first passkey. + +## Create a passkey + +1. Sign in with your email and password (or your social login). +2. Open your avatar menu, go to **Settings**, then **Security**. +3. Select **Add a passkey**. +4. Your browser asks you to confirm - use your fingerprint, face, PIN or security key. +5. Done! You'll see "Passkey added", and the new passkey shows up in the list. + +The passkey gets a name automatically, like "iCloud Keychain" or "Chrome (Mac OS)". Want something clearer, like "Work laptop"? Select the pencil icon next to it, type a new name and select **Save**. + + + If you see "Already set up", this device or password manager already has a + passkey for your account - you're good to go. + + +You can add as many passkeys as you like - for example one on your phone and one on your laptop. If your password manager syncs passkeys, one passkey follows you to all your devices; these show a **Synced** badge. Passkeys marked **This device only** live on one device or security key. + +## Sign in with a passkey + +1. Go to the login page. +2. Select **Sign in with a passkey**. You don't need to type your email. +3. Pick your passkey in the browser prompt and confirm with your fingerprint, face or PIN. + +That's it - you'll land wherever you were heading. Signing in on a computer with a passkey that lives on your phone? Most browsers offer a QR code to scan with your phone. + + + **On the staff team?** The same passkey signs you in to the AdminCP too. To + add one, sign in to the AdminCP with your password first, then add the + passkey from **Settings → Security** in the same browser. See [AdminCP + passkey sign-in](/docs/dev/passkeys/admincp). + + +## Remove a passkey + +1. Go to **Settings → Security**. +2. Select the red bin icon next to the passkey. +3. Confirm with **Remove**. + +The site forgets the passkey straight away, so it can't be used to sign in anymore. It may still appear in your password manager - delete it there too to keep things tidy. + + + You can't remove your **last** passkey if it's the only way into your account. + Add another passkey, or set a password with "Forgot password?" on the login + page first. This keeps you from accidentally locking yourself out. + + +## Something went wrong? + +| Message | What to do | +| ------------------------------ | ------------------------------------------------------------------------------------------ | +| Passkey sign-in cancelled | The prompt was closed or timed out. Just try again. | +| That took a little too long | The request expired after five minutes. Start again. | +| Passkey not recognized | That passkey was removed or belongs to another site. Try another one or use your password. | +| Passkeys aren't supported here | Your browser or device can't use passkeys. Update your browser or sign in another way. | +| Passkeys are turned off | This site has switched passkeys off for now. Use your password or social login. | diff --git a/apps/web/src/locales/@vitnode/core/pl.json b/apps/web/src/locales/@vitnode/core/pl.json index 6be4b8b2c..a931a929a 100644 --- a/apps/web/src/locales/@vitnode/core/pl.json +++ b/apps/web/src/locales/@vitnode/core/pl.json @@ -921,6 +921,76 @@ "showRealName": "Pokazuj moje prawdziwe imię", "showRealNameDesc": "Twój profil pokazuje imię i nazwisko zamiast pseudonimu. Do czasu ich ustawienia używany jest pseudonim." }, + "passkeys": { + "add": "Dodaj klucz dostępu", + "add_success": { + "desc": "„{name}” jest gotowy. Następnym razem wybierz „Zaloguj się kluczem dostępu” na stronie logowania.", + "title": "Dodano klucz dostępu" + }, + "added": "Dodano", + "delete": { + "action": "Usuń klucz dostępu", + "confirm": "Usuń", + "desc": "Nie zalogujesz się już kluczem „{name}”. Może on nadal być widoczny w menedżerze haseł, dopóki go tam nie usuniesz.", + "success": "Usunięto klucz dostępu", + "success_desc": "Klucza „{name}” nie można już użyć do logowania.", + "title": "Usunąć ten klucz dostępu?" + }, + "desc": "Loguj się odciskiem palca, twarzą lub blokadą ekranu zamiast hasła. Klucze dostępu są przechowywane na Twoich urządzeniach lub w menedżerze haseł i nie da się ich wyłudzić.", + "device_bound": "Tylko to urządzenie", + "empty": "Nie dodano jeszcze żadnego klucza dostępu.", + "errors": { + "already_registered": { + "desc": "To urządzenie lub menedżer haseł ma już klucz dostępu do Twojego konta.", + "title": "Już skonfigurowano" + }, + "cancelled": { + "desc": "Okno zostało zamknięte przed utworzeniem klucza. Spróbuj ponownie, kiedy zechcesz.", + "title": "Nie dodano klucza dostępu" + }, + "expired": { + "desc": "Żądanie wygasło lub nie udało się go zweryfikować. Spróbuj ponownie.", + "title": "Trwało to trochę za długo" + }, + "failed": { + "desc": "Przeglądarka lub urządzenie zgłosiły problem. Spróbuj ponownie.", + "title": "Nie udało się utworzyć klucza dostępu" + }, + "last_recovery_method": { + "desc": "To ostatni sposób dostępu do Twojego konta. Najpierw dodaj inny klucz dostępu lub ustaw hasło przez „Nie pamiętasz hasła?” na stronie logowania.", + "desc_passwordless": "To ostatni sposób dostępu do Twojego konta. Najpierw dodaj inny klucz dostępu, aby nadal móc się logować.", + "title": "Zachowaj sposób logowania" + }, + "not_found": { + "desc": "Mógł zostać usunięty na innym urządzeniu. Lista została odświeżona.", + "title": "Nie znaleziono klucza dostępu" + }, + "unavailable": { + "desc": "Ta strona nie przyjmuje teraz nowych kluczy dostępu.", + "title": "Klucze dostępu są wyłączone" + }, + "unsupported": { + "desc": "Ta przeglądarka lub urządzenie nie potrafi tworzyć kluczy dostępu z blokadą ekranu lub biometrią.", + "title": "Klucze dostępu nie są tu obsługiwane" + }, + "admin_session_required": { + "title": "Najpierw otwórz AdminCP", + "desc": "Twoje konto ma dostęp do AdminCP, więc dodanie klucza dostępu wymaga dodatkowego potwierdzenia. Zaloguj się do AdminCP hasłem w tej przeglądarce i spróbuj ponownie." + } + }, + "last_used": "Ostatnio użyty", + "never_used": "Jeszcze nieużyty", + "rename": { + "action": "Zmień nazwę klucza dostępu", + "label": "Nazwa klucza dostępu", + "save": "Zapisz", + "success": "Zmieniono nazwę klucza dostępu", + "success_desc": "Teraz wyświetla się jako „{name}”." + }, + "synced": "Synchronizowany", + "title": "Klucze dostępu", + "unsupported_hint": "Ta przeglądarka nie potrafi tworzyć kluczy dostępu. Użyj aktualnej wersji Chrome, Safari, Edge lub Firefox." + }, "title": "Ustawienia" }, "sign_in": { @@ -936,12 +1006,50 @@ "title": "Nieprawidłowe dane logowania" } }, + "passkey": { + "action": "Zaloguj się kluczem dostępu", + "cancelled": { + "desc": "Nic się nie stało - spróbuj ponownie, kiedy zechcesz.", + "title": "Anulowano logowanie kluczem dostępu" + }, + "errors": { + "expired": { + "desc": "Żądanie logowania wygasło. Spróbuj ponownie.", + "title": "Trwało to trochę za długo" + }, + "failed": { + "desc": "Przeglądarka lub urządzenie zgłosiły problem. Spróbuj ponownie lub wybierz inny sposób logowania.", + "title": "Nie udało się użyć klucza dostępu" + }, + "rejected": { + "desc": "Ten klucz dostępu nie jest powiązany z żadnym kontem lub został usunięty. Użyj innego klucza albo zaloguj się hasłem.", + "title": "Nie rozpoznano klucza dostępu" + }, + "unavailable": { + "desc": "Ta strona nie przyjmuje teraz kluczy dostępu. Wybierz inny sposób logowania.", + "title": "Klucze dostępu są wyłączone" + }, + "unsupported": { + "desc": "Ta przeglądarka lub urządzenie nie obsługuje kluczy dostępu. Wybierz inny sposób logowania.", + "title": "Klucze dostępu nie są tu obsługiwane" + }, + "not_staff": { + "title": "Brak dostępu do AdminCP", + "desc": "Klucz dostępu jest prawidłowy, ale to konto nie może otworzyć AdminCP. Jeśli uważasz, że to pomyłka, skontaktuj się z administratorem." + } + }, + "unsupported_hint": "Ta przeglądarka nie obsługuje kluczy dostępu. Zaloguj się adresem e-mail i hasłem." + }, "password": { "label": "Hasło", "required": "Hasło jest wymagane.", "reset": "Nie pamiętasz hasła?" }, - "submit": "Zaloguj się" + "submit": "Zaloguj się", + "unavailable": { + "desc": "Na tej stronie nie jest teraz włączony żaden sposób logowania. Zajrzyj później.", + "title": "Logowanie jest niedostępne" + } }, "sign_up": { "already_have_account": "Masz już konto? Zaloguj się.", @@ -977,6 +1085,10 @@ "label": "Akceptuję regulamin", "required": "Musisz zaakceptować regulamin." }, + "unavailable": { + "desc": "Ta strona nie przyjmuje teraz nowych kont. Zajrzyj później.", + "title": "Rejestracja jest niedostępna" + }, "username": { "exists": "Ta nazwa użytkownika jest już zajęta.", "label": "Nazwa użytkownika", diff --git a/apps/web/src/vitnode.api.config.ts b/apps/web/src/vitnode.api.config.ts index 50cd44cf7..623c3ba95 100644 --- a/apps/web/src/vitnode.api.config.ts +++ b/apps/web/src/vitnode.api.config.ts @@ -45,6 +45,9 @@ export const vitNodeApiConfig = buildApiConfig({ * file and the email half cannot drift from the UI half. */ i18n: { ...vitNodeConfig.i18n, messages: appMessages }, + authorization: { + passkeys: true, + }, dbProvider: drizzle({ connection: POSTGRES_URL, relations: coreRelations, diff --git a/packages/config/eslint.react.config.mjs b/packages/config/eslint.react.config.mjs index 355a82ccb..96a5599bd 100644 --- a/packages/config/eslint.react.config.mjs +++ b/packages/config/eslint.react.config.mjs @@ -4,6 +4,7 @@ import eslintReact from "@eslint-react/eslint-plugin"; import hooksPlugin from "eslint-plugin-react-hooks"; import reactYouMightNotNeedAnEffect from "eslint-plugin-react-you-might-not-need-an-effect"; import jsxA11y from "eslint-plugin-jsx-a11y"; +import { plugin as shadcn } from "@shadcn/lint"; export default [ reactYouMightNotNeedAnEffect.configs.recommended, @@ -27,6 +28,7 @@ export default [ plugins: { "react-hooks": hooksPlugin, "jsx-a11y": jsxA11y, + shadcn, }, rules: { "react/react-in-jsx-scope": "off", diff --git a/packages/config/package.json b/packages/config/package.json index 765a4b353..ac1b924e6 100644 --- a/packages/config/package.json +++ b/packages/config/package.json @@ -48,6 +48,7 @@ "dependencies": { "@eslint-react/eslint-plugin": "^5.19.0", "@eslint/js": "^10.0.1", + "@shadcn/lint": "^0.2.0", "eslint-config-prettier": "^10.1.8", "eslint-plugin-jsx-a11y": "^6.10.2", "eslint-plugin-perfectionist": "^5.11.0", diff --git a/packages/vitnode/components.json b/packages/vitnode/components.json index 53bf8f335..90e3a32af 100644 --- a/packages/vitnode/components.json +++ b/packages/vitnode/components.json @@ -5,7 +5,7 @@ "tsx": true, "tailwind": { "config": "", - "css": "src/views/global.css", + "css": "src/globals.css", "baseColor": "zinc", "cssVariables": true, "prefix": "" diff --git a/packages/vitnode/package.json b/packages/vitnode/package.json index 80022b0dd..e5a797d16 100644 --- a/packages/vitnode/package.json +++ b/packages/vitnode/package.json @@ -227,6 +227,8 @@ "@ferrucc-io/emoji-picker": "^0.1.2", "@hono/swagger-ui": "^0.6.1", "@inquirer/prompts": "^8.7.2", + "@simplewebauthn/browser": "^14.0.0", + "@simplewebauthn/server": "^14.0.3", "@tiptap/extension-audio": "^3.31.3", "@tiptap/extension-drag-handle": "^3.31.3", "@tiptap/extension-drag-handle-react": "^3.31.3", diff --git a/packages/vitnode/src/api/lib/passkey-config.test.ts b/packages/vitnode/src/api/lib/passkey-config.test.ts new file mode 100644 index 000000000..7eabe4ecd --- /dev/null +++ b/packages/vitnode/src/api/lib/passkey-config.test.ts @@ -0,0 +1,122 @@ +import { describe, expect, it } from "vitest"; + +import { passkeyConfigProblems, resolvePasskeysConfig } from "./passkey-config"; + +describe("resolvePasskeysConfig", () => { + it("is off when the config has no passkeys block", () => { + expect( + resolvePasskeysConfig({ + config: undefined, + rpNameFallback: "VitNode", + webOrigin: "https://example.com", + }), + ).toEqual({ enabled: false, problems: [] }); + }); + + it("is off when set to false", () => { + expect( + resolvePasskeysConfig({ + config: false, + rpNameFallback: "VitNode", + webOrigin: "https://example.com", + }), + ).toEqual({ enabled: false, problems: [] }); + }); + + it.each([true, {}])( + "derives the RP ID and origin from the web origin for %j", + config => { + expect( + resolvePasskeysConfig({ + config, + rpNameFallback: "VitNode", + webOrigin: "http://localhost:3000", + }), + ).toEqual({ + enabled: true, + origins: ["http://localhost:3000"], + rpId: "localhost", + rpName: "VitNode", + }); + }, + ); + + it("fails fast when true meets a web origin that cannot host passkeys", () => { + expect(() => + resolvePasskeysConfig({ + config: true, + rpNameFallback: "VitNode", + webOrigin: "http://192.168.1.10:3000", + }), + ).toThrow(/IP address/); + }); + + it("accepts a parent-domain RP ID shared by several origins", () => { + expect( + resolvePasskeysConfig({ + config: { + origins: ["https://example.com", "https://forum.example.com"], + rpId: "example.com", + rpName: "Example", + }, + rpNameFallback: "VitNode", + webOrigin: "https://example.com", + }), + ).toMatchObject({ enabled: true, rpId: "example.com", rpName: "Example" }); + }); + + it("fails fast when the web origin cannot host passkeys", () => { + expect(() => + resolvePasskeysConfig({ + config: {}, + rpNameFallback: "VitNode", + webOrigin: "http://192.168.1.10:3000", + }), + ).toThrow(/plain HTTP/); + }); + + it("fails fast on an RP ID that does not cover the origins", () => { + expect(() => + resolvePasskeysConfig({ + config: { origins: ["https://example.com"], rpId: "other.com" }, + rpNameFallback: "VitNode", + webOrigin: "https://example.com", + }), + ).toThrow(/other\.com/); + }); +}); + +describe("passkeyConfigProblems", () => { + it("allows plain HTTP only on localhost", () => { + expect( + passkeyConfigProblems({ + origins: ["http://app.localhost:3000"], + rpId: "localhost", + }), + ).toEqual([]); + expect( + passkeyConfigProblems({ + origins: ["http://example.com"], + rpId: "example.com", + }), + ).toHaveLength(1); + }); + + it("refuses an IP address as the RP ID", () => { + expect( + passkeyConfigProblems({ + origins: ["https://127.0.0.1"], + rpId: "127.0.0.1", + }), + ).not.toEqual([]); + }); + + it("refuses an origin with a path and a look-alike domain", () => { + expect( + passkeyConfigProblems({ + origins: ["https://example.com/", "https://notexample.com"], + rpId: "example.com", + }), + ).toHaveLength(2); + }); +}); diff --git a/packages/vitnode/src/api/lib/passkey-config.ts b/packages/vitnode/src/api/lib/passkey-config.ts new file mode 100644 index 000000000..6db2c5196 --- /dev/null +++ b/packages/vitnode/src/api/lib/passkey-config.ts @@ -0,0 +1,108 @@ +export interface PasskeysConfig { + origins?: string[]; + rpId?: string; + rpName?: string; +} + +export type ResolvedPasskeysConfig = + | { enabled: false; problems: string[] } + | { enabled: true; origins: string[]; rpId: string; rpName: string }; + +const IPV4 = /^\d{1,3}(\.\d{1,3}){3}$/; + +const isLocalhost = (hostname: string): boolean => + hostname === "localhost" || hostname.endsWith(".localhost"); + +const isIpAddress = (hostname: string): boolean => + IPV4.test(hostname) || hostname.includes(":") || hostname.startsWith("["); + +const belongsToRpId = (hostname: string, rpId: string): boolean => + hostname === rpId || hostname.endsWith(`.${rpId}`); + +const parseOrigin = (value: string): null | URL => { + try { + return new URL(value); + } catch { + return null; + } +}; + +export const passkeyConfigProblems = ({ + origins, + rpId, +}: { + origins: string[]; + rpId: string; +}): string[] => { + const problems: string[] = []; + + if (!rpId) problems.push("The RP ID is empty."); + else if (rpId !== rpId.toLowerCase()) { + problems.push(`The RP ID "${rpId}" must be lowercase.`); + } else if (isIpAddress(rpId)) { + problems.push( + `The RP ID "${rpId}" is an IP address. WebAuthn needs a domain name - use "localhost" in development.`, + ); + } + + if (origins.length === 0) problems.push("No origins are configured."); + + for (const origin of origins) { + const url = parseOrigin(origin); + + if (!url || (url.protocol !== "https:" && url.protocol !== "http:")) { + problems.push(`"${origin}" is not an http(s) origin.`); + continue; + } + + if (url.origin !== origin) { + problems.push( + `"${origin}" is not a bare origin. Use "${url.origin}" (no path or trailing slash).`, + ); + } + + if (url.protocol === "http:" && !isLocalhost(url.hostname)) { + problems.push( + `"${origin}" uses plain HTTP. Browsers only allow passkeys on HTTPS or on localhost.`, + ); + } + + if (rpId && !belongsToRpId(url.hostname, rpId)) { + problems.push( + `The RP ID "${rpId}" is neither "${url.hostname}" nor a parent domain of it.`, + ); + } + } + + return problems; +}; + +export const resolvePasskeysConfig = ({ + config, + rpNameFallback, + webOrigin, +}: { + config: boolean | PasskeysConfig | undefined; + rpNameFallback: string; + webOrigin: string; +}): ResolvedPasskeysConfig => { + if (!config) return { enabled: false, problems: [] }; + + const overrides = config === true ? {} : config; + const origins = overrides.origins ?? [webOrigin]; + const rpId = overrides.rpId ?? parseOrigin(origins[0] ?? "")?.hostname ?? ""; + const problems = passkeyConfigProblems({ origins, rpId }); + + if (problems.length > 0) { + throw new Error( + `[VitNode] Passkeys are misconfigured: ${problems.join(" ")}`, + ); + } + + return { + enabled: true, + origins, + rpId, + rpName: overrides.rpName ?? rpNameFallback, + }; +}; diff --git a/packages/vitnode/src/api/lib/password-sign-in.ts b/packages/vitnode/src/api/lib/password-sign-in.ts new file mode 100644 index 000000000..25c190612 --- /dev/null +++ b/packages/vitnode/src/api/lib/password-sign-in.ts @@ -0,0 +1,12 @@ +import type { Context } from "hono"; + +import { HTTPException } from "hono/http-exception"; + +export const isPasswordSignInEnabled = (c: Context): boolean => + c.get("core").authorization.password.enabled; + +export const assertPasswordSignInEnabled = (c: Context): void => { + if (!isPasswordSignInEnabled(c)) { + throw new HTTPException(403, { message: "Password sign-in is disabled" }); + } +}; diff --git a/packages/vitnode/src/api/middlewares/global.middleware.ts b/packages/vitnode/src/api/middlewares/global.middleware.ts index 6efecd166..25984651a 100644 --- a/packages/vitnode/src/api/middlewares/global.middleware.ts +++ b/packages/vitnode/src/api/middlewares/global.middleware.ts @@ -42,6 +42,7 @@ import { realtime } from "@/ws/registry"; import type { BuildCronReturn } from "../lib/cron"; import type { RegisteredEditablePage } from "../lib/editable-pages"; import type { EventListenerConfig } from "../lib/events"; +import type { ResolvedPasskeysConfig } from "../lib/passkey-config"; import type { PermissionStaffCatalogEntry } from "../lib/permission-staff"; import type { BuildQueueTaskReturn } from "../lib/queue"; import type { WebSocketConfig } from "../lib/websocket"; @@ -64,6 +65,7 @@ import { type LoggerMiddlewareType, } from "../lib/logger-middleware"; import { collectNavigationPresets } from "../lib/navigation-presets"; +import { resolvePasskeysConfig } from "../lib/passkey-config"; import { normalizePermissionStaffModules } from "../lib/permission-staff"; declare module "hono" { @@ -112,6 +114,8 @@ export interface EnvVariablesVitNode { cookieSecure: boolean; deviceCookieExpires: number; deviceCookieName: string; + passkeys: ResolvedPasskeysConfig; + password: { enabled: boolean }; ssoAdapters: SSOApiPlugin[]; }; captcha?: Pick["captcha"]; @@ -362,6 +366,12 @@ export const globalMiddleware = ({ const navigationMetadata: NavigationPreset[] = collectNavigationPresets(plugins); + const passkeysMetadata = resolvePasskeysConfig({ + config: authorization?.passkeys, + rpNameFallback: metadata.shortTitle ?? metadata.title, + webOrigin: CONFIG.web.origin, + }); + const permissionStaffMetadata: PermissionStaffCatalogEntry[] = plugins.map( plugin => ({ pluginId: plugin.pluginId, @@ -426,6 +436,8 @@ export const globalMiddleware = ({ // No default on purpose: absent means host-only, which is correct on // localhost, on a generated preview hostname and in production alike. cookieDomain: authorization?.cookieDomain, + passkeys: passkeysMetadata, + password: { enabled: authorization?.password ?? true }, }, captcha, personalInformationFields: resolvePersonalInformationFields( diff --git a/packages/vitnode/src/api/models/device.test.ts b/packages/vitnode/src/api/models/device.test.ts index f2419a5f9..3a32ef9e0 100644 --- a/packages/vitnode/src/api/models/device.test.ts +++ b/packages/vitnode/src/api/models/device.test.ts @@ -21,6 +21,8 @@ const AUTHORIZATION: Authorization = { cookieSecure: true, deviceCookieExpires: 1000 * 60 * 60 * 24 * 365, deviceCookieName: "vitnode_device", + passkeys: { enabled: false, problems: [] }, + password: { enabled: true }, ssoAdapters: [], }; diff --git a/packages/vitnode/src/api/models/events.ts b/packages/vitnode/src/api/models/events.ts index bcf2d78b3..48253eebf 100644 --- a/packages/vitnode/src/api/models/events.ts +++ b/packages/vitnode/src/api/models/events.ts @@ -55,6 +55,19 @@ export interface VitNodeEvents { email: string; userId: number; }; + "user.passkey.created": { + passkeyId: number; + userId: number; + }; + "user.passkey.deleted": { + passkeyId: number; + userId: number; + }; + "user.passkey.updated": { + name: string; + passkeyId: number; + userId: number; + }; "user.sso.linked": { email: string; providerId: string; diff --git a/packages/vitnode/src/api/models/passkey-names.ts b/packages/vitnode/src/api/models/passkey-names.ts new file mode 100644 index 000000000..92ebc3282 --- /dev/null +++ b/packages/vitnode/src/api/models/passkey-names.ts @@ -0,0 +1,46 @@ +import { parseUserAgent } from "@/lib/api/parse-user-agent"; +import { PASSKEY_NAME_MAX_LENGTH } from "@/lib/passkey"; + +const AUTHENTICATOR_NAMES: Readonly> = { + "08987058-cadc-4b81-b6e1-30de50dcbe96": "Windows Hello", + "50726f74-6f6e-5061-7373-50726f746f6e": "Proton Pass", + "531126d6-e717-415c-9320-3d9aa6981239": "Dashlane", + "53414d53-554e-4700-0000-000000000000": "Samsung Pass", + "6028b017-b1d4-4c02-b4b3-afcdafc96bb2": "Windows Hello", + "9ddd1817-af5a-4672-a2b9-3e3dd95000a9": "Windows Hello", + "adce0002-35bc-c60a-648b-0b25f1f05503": "Chrome on Mac", + "b84e4048-15dc-4dd0-8640-f4f60813c8af": "NordPass", + "bada5566-a7aa-401f-bd96-45619a55120d": "1Password", + "d548826e-79b4-db40-a3d8-11116f7e8349": "Bitwarden", + "dd4ec289-e01d-41c9-bb89-70fa845d4bf2": "iCloud Keychain", + "ea9b8d66-4d01-1d21-3ce4-b6b48cb575d4": "Google Password Manager", + "fbfc3007-154e-4ecc-8c0b-6e020557d7bd": "iCloud Keychain", + "fdb141b2-5d84-443e-8a35-4698c205a502": "KeePassXC", +}; + +const UNKNOWN_USER_AGENT_PART = "Unknown"; + +export const FALLBACK_PASSKEY_NAME = "Passkey"; + +export const normalizePasskeyName = (name: string): string => + name.trim().replace(/\s+/g, " ").slice(0, PASSKEY_NAME_MAX_LENGTH); + +export const defaultPasskeyName = ({ + aaguid, + userAgent, +}: { + aaguid: null | string | undefined; + userAgent: string | undefined; +}): string => { + const known = aaguid ? AUTHENTICATOR_NAMES[aaguid.toLowerCase()] : undefined; + if (known) return known; + + const { browser, os } = parseUserAgent(userAgent ?? ""); + const parts = [browser.replace(/\s+[\d.]+$/, ""), os].filter( + part => part !== UNKNOWN_USER_AGENT_PART, + ); + + if (parts.length === 2) return `${parts[0]} (${parts[1]})`; + + return parts[0] ?? FALLBACK_PASSKEY_NAME; +}; diff --git a/packages/vitnode/src/api/models/passkey-store.ts b/packages/vitnode/src/api/models/passkey-store.ts new file mode 100644 index 000000000..15247b524 --- /dev/null +++ b/packages/vitnode/src/api/models/passkey-store.ts @@ -0,0 +1,256 @@ +import type { Context } from "hono"; + +import { and, asc, count, eq, gt, isNull, lte, ne } from "drizzle-orm"; + +import { + core_users_passkey_challenges, + core_users_passkeys, +} from "@/database/passkeys"; +import { core_users, core_users_sso } from "@/database/users"; + +export type PasskeyCeremony = + "admin_sign_in" | "authentication" | "registration"; + +export interface PasskeyRecord { + aaguid: null | string; + backedUp: boolean; + counter: number; + createdAt: Date; + credentialId: string; + deviceType: string; + id: number; + lastUsedAt: Date | null; + name: string; + publicKey: string; + transports: string[]; + updatedAt: Date; + userId: number; + webauthnUserId: string; +} + +export type NewPasskey = Omit< + PasskeyRecord, + "createdAt" | "id" | "lastUsedAt" | "updatedAt" +>; + +export interface PasskeyChallengeRecord { + ceremony: PasskeyCeremony; + challenge: string; + expiresAt: Date; + tokenHash: string; + userId: null | number; + webauthnUserId: null | string; +} + +export interface PasskeyRecoveryFacts { + hasPassword: boolean; + otherPasskeys: number; + ssoAccounts: number; +} + +export type DeletePasskeyOutcome = "blocked" | "deleted" | "not_found"; + +export interface PasskeyStore { + consumeChallenge: (args: { + ceremony: PasskeyCeremony; + now: Date; + tokenHash: string; + userId: null | number; + }) => Promise; + createPasskey: (values: NewPasskey) => Promise; + deleteChallenge: (tokenHash: string) => Promise; + deleteExpiredChallenges: (now: Date) => Promise; + deletePasskey: (args: { + canDelete: (facts: PasskeyRecoveryFacts) => boolean; + id: number; + userId: number; + }) => Promise; + findPasskeyByCredentialId: ( + credentialId: string, + ) => Promise; + listPasskeys: (userId: number) => Promise; + recordSignIn: (args: { + backedUp: boolean; + counter: number; + deviceType: string; + id: number; + previousCounter: number; + usedAt: Date; + }) => Promise; + renamePasskey: (args: { + id: number; + name: string; + userId: number; + }) => Promise; + saveChallenge: (values: PasskeyChallengeRecord) => Promise; +} + +type Db = Context["var"]["db"]; + +const asChallenge = ( + row: typeof core_users_passkey_challenges.$inferSelect, + ceremony: PasskeyCeremony, +): PasskeyChallengeRecord => ({ + ceremony, + challenge: row.challenge, + expiresAt: row.expiresAt, + tokenHash: row.tokenHash, + userId: row.userId, + webauthnUserId: row.webauthnUserId, +}); + +export const drizzlePasskeyStore = (db: Db): PasskeyStore => ({ + consumeChallenge: async ({ ceremony, now, tokenHash, userId }) => { + const [row] = await db + .delete(core_users_passkey_challenges) + .where( + and( + eq(core_users_passkey_challenges.tokenHash, tokenHash), + eq(core_users_passkey_challenges.ceremony, ceremony), + gt(core_users_passkey_challenges.expiresAt, now), + userId === null + ? isNull(core_users_passkey_challenges.userId) + : eq(core_users_passkey_challenges.userId, userId), + ), + ) + .returning(); + + return row ? asChallenge(row, ceremony) : null; + }, + + createPasskey: async values => { + const [row] = await db + .insert(core_users_passkeys) + .values(values) + .onConflictDoNothing({ target: core_users_passkeys.credentialId }) + .returning(); + + return row ?? null; + }, + + deleteChallenge: async tokenHash => { + await db + .delete(core_users_passkey_challenges) + .where(eq(core_users_passkey_challenges.tokenHash, tokenHash)); + }, + + deleteExpiredChallenges: async now => { + await db + .delete(core_users_passkey_challenges) + .where(lte(core_users_passkey_challenges.expiresAt, now)); + }, + + deletePasskey: async ({ canDelete, id, userId }) => + await db.transaction(async tx => { + const [user] = await tx + .select({ password: core_users.password }) + .from(core_users) + .where(eq(core_users.id, userId)) + .for("update"); + + const [passkey] = await tx + .select({ id: core_users_passkeys.id }) + .from(core_users_passkeys) + .where( + and( + eq(core_users_passkeys.id, id), + eq(core_users_passkeys.userId, userId), + ), + ); + + if (!(user && passkey)) return "not_found"; + + const [[others], [sso]] = await Promise.all([ + tx + .select({ value: count() }) + .from(core_users_passkeys) + .where( + and( + eq(core_users_passkeys.userId, userId), + ne(core_users_passkeys.id, id), + ), + ), + tx + .select({ value: count() }) + .from(core_users_sso) + .where(eq(core_users_sso.userId, userId)), + ]); + + const allowed = canDelete({ + hasPassword: !!user.password, + otherPasskeys: others?.value ?? 0, + ssoAccounts: sso?.value ?? 0, + }); + + if (!allowed) return "blocked"; + + await tx + .delete(core_users_passkeys) + .where( + and( + eq(core_users_passkeys.id, id), + eq(core_users_passkeys.userId, userId), + ), + ); + + return "deleted"; + }), + + findPasskeyByCredentialId: async credentialId => { + const [row] = await db + .select() + .from(core_users_passkeys) + .where(eq(core_users_passkeys.credentialId, credentialId)) + .limit(1); + + return row ?? null; + }, + + listPasskeys: async userId => + await db + .select() + .from(core_users_passkeys) + .where(eq(core_users_passkeys.userId, userId)) + .orderBy(asc(core_users_passkeys.createdAt), asc(core_users_passkeys.id)), + + recordSignIn: async ({ + backedUp, + counter, + deviceType, + id, + previousCounter, + usedAt, + }) => { + const rows = await db + .update(core_users_passkeys) + .set({ backedUp, counter, deviceType, lastUsedAt: usedAt }) + .where( + and( + eq(core_users_passkeys.id, id), + eq(core_users_passkeys.counter, previousCounter), + ), + ) + .returning({ id: core_users_passkeys.id }); + + return rows.length > 0; + }, + + renamePasskey: async ({ id, name, userId }) => { + const [row] = await db + .update(core_users_passkeys) + .set({ name }) + .where( + and( + eq(core_users_passkeys.id, id), + eq(core_users_passkeys.userId, userId), + ), + ) + .returning(); + + return row ?? null; + }, + + saveChallenge: async values => { + await db.insert(core_users_passkey_challenges).values(values); + }, +}); diff --git a/packages/vitnode/src/api/models/passkey.ts b/packages/vitnode/src/api/models/passkey.ts new file mode 100644 index 000000000..1a69a4b2c --- /dev/null +++ b/packages/vitnode/src/api/models/passkey.ts @@ -0,0 +1,442 @@ +import type { + AuthenticationResponseJSON, + RegistrationResponseJSON, +} from "@simplewebauthn/server"; +import type { Context } from "hono"; + +import { + generateAuthenticationOptions, + generateRegistrationOptions, + verifyAuthenticationResponse, + verifyRegistrationResponse, +} from "@simplewebauthn/server"; +import { isoBase64URL } from "@simplewebauthn/server/helpers"; +import { getCookie } from "hono/cookie"; + +import type { PasskeyErrorCode } from "@/api/modules/users/passkeys/schema"; + +import { deleteAuthCookie, setAuthCookie } from "@/api/lib/auth-cookie"; +import { describeError } from "@/api/lib/error-details"; +import { isPasswordSignInEnabled } from "@/api/lib/password-sign-in"; +import { hashSessionToken } from "@/api/lib/session-token"; +import { CONFIG } from "@/lib/config"; + +import type { + PasskeyCeremony, + PasskeyRecord, + PasskeyRecoveryFacts, + PasskeyStore, +} from "./passkey-store"; + +import { defaultPasskeyName, normalizePasskeyName } from "./passkey-names"; +import { drizzlePasskeyStore } from "./passkey-store"; +import { SessionAdminModel } from "./session-admin"; + +export const PASSKEY_CHALLENGE_TTL_MS = 5 * 60_000; + +export const PASSKEY_ADMIN_CHALLENGE_TTL_MS = 2 * 60_000; + +const CHALLENGE_TTL_MS: Record = { + admin_sign_in: PASSKEY_ADMIN_CHALLENGE_TTL_MS, + authentication: PASSKEY_CHALLENGE_TTL_MS, + registration: PASSKEY_CHALLENGE_TTL_MS, +}; + +export type PasskeySignInCeremony = Exclude; + +const ZERO_AAGUID = "00000000-0000-0000-0000-000000000000"; + +const TRANSPORTS: readonly string[] = [ + "ble", + "cable", + "hybrid", + "internal", + "nfc", + "smart-card", + "usb", +]; + +const isTransport = (value: string): boolean => TRANSPORTS.includes(value); + +export type PasskeyErrorStatus = 400 | 403 | 404 | 409; + +export class PasskeyError extends Error { + constructor(code: PasskeyErrorCode, status: PasskeyErrorStatus) { + super(code); + this.name = "PasskeyError"; + this.code = code; + this.status = status; + } + + readonly code: PasskeyErrorCode; + readonly status: PasskeyErrorStatus; +} + +export const passkeyChallengeCookieName = ( + c: Context, + ceremony: PasskeyCeremony, +): string => `${c.get("core").authorization.cookieName}_passkey_${ceremony}`; + +export const keepsRecoveryMethod = ({ + hasPassword, + otherPasskeys, + ssoAccounts, +}: PasskeyRecoveryFacts): boolean => + hasPassword || otherPasskeys > 0 || ssoAccounts > 0; + +export const toPublicPasskey = (passkey: PasskeyRecord) => ({ + backedUp: passkey.backedUp, + createdAt: passkey.createdAt, + deviceType: + passkey.deviceType === "multiDevice" + ? ("multiDevice" as const) + : ("singleDevice" as const), + id: passkey.id, + lastUsedAt: passkey.lastUsedAt, + name: passkey.name, + transports: passkey.transports, +}); + +export type PublicPasskey = ReturnType; + +const randomToken = (): string => { + const bytes = crypto.getRandomValues(new Uint8Array(32)); + + return Array.from(bytes, byte => byte.toString(16).padStart(2, "0")).join(""); +}; + +export class PasskeyModel { + constructor(c: Context) { + this.c = c; + } + + protected readonly c: Context; + + private async assertStaffEnrollmentAllowed(userId: number) { + const admin = new SessionAdminModel(this.c); + if (!(await admin.checkIfUserIsAdmin(userId))) return; + + const adminUser = await admin.getUser(); + if (adminUser?.id !== userId) { + throw new PasskeyError("admin_session_required", 403); + } + } + + private async consumeChallenge( + ceremony: PasskeyCeremony, + userId: null | number, + ) { + const cookieName = passkeyChallengeCookieName(this.c, ceremony); + const token = getCookie(this.c, cookieName); + deleteAuthCookie(this.c, cookieName); + + if (!token) throw new PasskeyError("invalid_challenge", 400); + + const challenge = await this.store.consumeChallenge({ + ceremony, + now: new Date(), + tokenHash: await hashSessionToken(token), + userId, + }); + + if (!challenge) throw new PasskeyError("invalid_challenge", 400); + + return challenge; + } + + private enabledConfig() { + const config = this.c.get("core").authorization.passkeys; + if (!config.enabled) throw new PasskeyError("passkeys_disabled", 404); + + return config; + } + + private async issueChallenge({ + ceremony, + challenge, + now, + userId, + webauthnUserId, + }: { + ceremony: PasskeyCeremony; + challenge: string; + now: Date; + userId: null | number; + webauthnUserId: null | string; + }) { + const store = this.store; + const cookieName = passkeyChallengeCookieName(this.c, ceremony); + const previous = getCookie(this.c, cookieName); + if (previous) await store.deleteChallenge(await hashSessionToken(previous)); + + const token = randomToken(); + const expiresAt = new Date(now.getTime() + CHALLENGE_TTL_MS[ceremony]); + + await store.saveChallenge({ + ceremony, + challenge, + expiresAt, + tokenHash: await hashSessionToken(token), + userId, + webauthnUserId, + }); + + setAuthCookie(this.c, cookieName, token, { expires: expiresAt }); + } + + private warnInDevelopment(ceremony: PasskeyCeremony, error: unknown) { + if (!CONFIG.node_development) return; + + // eslint-disable-next-line no-console + console.warn( + `\x1b[34m[VitNode]\x1b[0m \x1b[33mPasskey ${ceremony} failed:\x1b[0m ${describeError(error)}`, + ); + } + + async authenticationOptions(ceremony: PasskeySignInCeremony) { + const { rpId } = this.enabledConfig(); + const now = new Date(); + + await this.store.deleteExpiredChallenges(now); + + const options = await generateAuthenticationOptions({ + rpID: rpId, + timeout: CHALLENGE_TTL_MS[ceremony], + userVerification: "required", + }); + + await this.issueChallenge({ + ceremony, + challenge: options.challenge, + now, + userId: null, + webauthnUserId: null, + }); + + return options; + } + + async deletePasskey({ id, userId }: { id: number; userId: number }) { + this.enabledConfig(); + const passwordEnabled = isPasswordSignInEnabled(this.c); + const outcome = await this.store.deletePasskey({ + canDelete: facts => + keepsRecoveryMethod({ + ...facts, + hasPassword: passwordEnabled && facts.hasPassword, + }), + id, + userId, + }); + + if (outcome === "not_found") throw new PasskeyError("not_found", 404); + if (outcome === "blocked") { + throw new PasskeyError("last_recovery_method", 409); + } + + await this.c + .get("events") + .emit("user.passkey.deleted", { passkeyId: id, userId }); + } + + async listPasskeys(userId: number): Promise { + this.enabledConfig(); + const passkeys = await this.store.listPasskeys(userId); + + return passkeys.map(toPublicPasskey); + } + + async registrationOptions(user: { email: string; id: number; name: string }) { + const { rpId, rpName } = this.enabledConfig(); + await this.assertStaffEnrollmentAllowed(user.id); + const store = this.store; + const now = new Date(); + + await store.deleteExpiredChallenges(now); + const existing = await store.listPasskeys(user.id); + const webauthnUserId = + existing[0]?.webauthnUserId ?? + isoBase64URL.fromBuffer(crypto.getRandomValues(new Uint8Array(32))); + + const options = await generateRegistrationOptions({ + attestationType: "none", + authenticatorSelection: { + requireResidentKey: true, + residentKey: "required", + userVerification: "required", + }, + excludeCredentials: existing.map(passkey => ({ + id: passkey.credentialId, + transports: passkey.transports.filter(isTransport), + })), + rpID: rpId, + rpName, + timeout: PASSKEY_CHALLENGE_TTL_MS, + userDisplayName: user.name, + userID: isoBase64URL.toBuffer(webauthnUserId), + userName: user.email, + }); + + await this.issueChallenge({ + ceremony: "registration", + challenge: options.challenge, + now, + userId: user.id, + webauthnUserId, + }); + + return options; + } + + async renamePasskey({ + id, + name, + userId, + }: { + id: number; + name: string; + userId: number; + }): Promise { + this.enabledConfig(); + const normalized = normalizePasskeyName(name); + const passkey = normalized + ? await this.store.renamePasskey({ id, name: normalized, userId }) + : null; + + if (!passkey) throw new PasskeyError("not_found", 404); + + await this.c.get("events").emit("user.passkey.updated", { + name: passkey.name, + passkeyId: passkey.id, + userId, + }); + + return toPublicPasskey(passkey); + } + + async verifyAuthentication( + response: AuthenticationResponseJSON, + ceremony: PasskeySignInCeremony, + ): Promise<{ userId: number }> { + const { origins, rpId } = this.enabledConfig(); + const challenge = await this.consumeChallenge(ceremony, null); + const store = this.store; + const denied = new PasskeyError("verification_failed", 403); + + const passkey = await store.findPasskeyByCredentialId(response.id); + if (!passkey) throw denied; + + const { userHandle } = response.response; + if (userHandle !== undefined && userHandle !== passkey.webauthnUserId) { + throw denied; + } + + const verification = await verifyAuthenticationResponse({ + credential: { + counter: passkey.counter, + id: passkey.credentialId, + publicKey: isoBase64URL.toBuffer(passkey.publicKey), + transports: passkey.transports.filter(isTransport), + }, + expectedChallenge: challenge.challenge, + expectedOrigin: origins, + expectedRPID: rpId, + requireUserVerification: true, + response, + }).catch((error: unknown) => { + this.warnInDevelopment(ceremony, error); + + return null; + }); + + if (!verification?.verified) throw denied; + + const { credentialBackedUp, credentialDeviceType, newCounter } = + verification.authenticationInfo; + + const recorded = await store.recordSignIn({ + backedUp: credentialBackedUp, + counter: newCounter, + deviceType: credentialDeviceType, + id: passkey.id, + previousCounter: passkey.counter, + usedAt: new Date(), + }); + + if (!recorded) throw denied; + + return { userId: passkey.userId }; + } + + async verifyRegistration({ + name, + response, + userId, + }: { + name?: string; + response: RegistrationResponseJSON; + userId: number; + }): Promise { + const { origins, rpId } = this.enabledConfig(); + const challenge = await this.consumeChallenge("registration", userId); + await this.assertStaffEnrollmentAllowed(userId); + + if (!challenge.webauthnUserId) { + throw new PasskeyError("invalid_challenge", 400); + } + + const verification = await verifyRegistrationResponse({ + expectedChallenge: challenge.challenge, + expectedOrigin: origins, + expectedRPID: rpId, + requireUserPresence: true, + requireUserVerification: true, + response, + }).catch((error: unknown) => { + this.warnInDevelopment("registration", error); + + return null; + }); + + if (!verification?.verified) { + throw new PasskeyError("verification_failed", 400); + } + + const { aaguid, credential, credentialBackedUp, credentialDeviceType } = + verification.registrationInfo; + const knownAaguid = aaguid && aaguid !== ZERO_AAGUID ? aaguid : null; + const chosenName = name ? normalizePasskeyName(name) : ""; + + const passkey = await this.store.createPasskey({ + aaguid: knownAaguid, + backedUp: credentialBackedUp, + counter: credential.counter, + credentialId: credential.id, + deviceType: credentialDeviceType, + name: + chosenName || + defaultPasskeyName({ + aaguid: knownAaguid, + userAgent: this.c.req.header("user-agent"), + }), + publicKey: isoBase64URL.fromBuffer(credential.publicKey), + transports: (credential.transports ?? []) + .filter(isTransport) + .slice(0, TRANSPORTS.length), + userId, + webauthnUserId: challenge.webauthnUserId, + }); + + if (!passkey) throw new PasskeyError("already_registered", 409); + + await this.c + .get("events") + .emit("user.passkey.created", { passkeyId: passkey.id, userId }); + + return toPublicPasskey(passkey); + } + + get store(): PasskeyStore { + return drizzlePasskeyStore(this.c.get("db")); + } +} diff --git a/packages/vitnode/src/api/models/session-cookies.test.ts b/packages/vitnode/src/api/models/session-cookies.test.ts index 9b7f80a6c..0aa14ee19 100644 --- a/packages/vitnode/src/api/models/session-cookies.test.ts +++ b/packages/vitnode/src/api/models/session-cookies.test.ts @@ -29,6 +29,8 @@ const AUTHORIZATION: Authorization = { cookieSecure: true, deviceCookieExpires: 1000 * 60 * 60 * 24 * 365, deviceCookieName: "vitnode_device", + passkeys: { enabled: false, problems: [] }, + password: { enabled: true }, ssoAdapters: [], }; diff --git a/packages/vitnode/src/api/models/sso.ts b/packages/vitnode/src/api/models/sso.ts index f717b8ede..926c39417 100644 --- a/packages/vitnode/src/api/models/sso.ts +++ b/packages/vitnode/src/api/models/sso.ts @@ -6,6 +6,7 @@ import { HTTPException } from "hono/http-exception"; import crypto from "node:crypto"; import { deleteAuthCookie, setAuthCookie } from "@/api/lib/auth-cookie"; +import { isPasswordSignInEnabled } from "@/api/lib/password-sign-in"; import { ensureServerSecret } from "@/api/lib/server-secret"; import { ssoConfirmsEmail } from "@/api/lib/sso-email-confirmation"; import { @@ -172,7 +173,8 @@ export class SSOModel { return { email: userWithEmail.email, - hasPassword: userWithEmail.password !== null, + hasPassword: + isPasswordSignInEnabled(this.c) && userWithEmail.password !== null, kind: "link_required", linkToken: await this.mintLinkToken({ email: userFromSSO.email, diff --git a/packages/vitnode/src/api/modules/middleware/route.ts b/packages/vitnode/src/api/modules/middleware/route.ts index aa1318638..67ab72c69 100644 --- a/packages/vitnode/src/api/modules/middleware/route.ts +++ b/packages/vitnode/src/api/modules/middleware/route.ts @@ -29,6 +29,8 @@ export const routeMiddlewareSchema = z.object({ }), ), isEmail: z.boolean(), + passkeys: z.boolean(), + password: z.boolean(), navigation: z.array(zodPublicNavigationNodeSchema), bottomBar: z.array(zodPublicNavigationItemSchema), captcha: z @@ -63,6 +65,8 @@ export const routeMiddleware = buildRoute({ { ai: { models: c.get("ai").models() }, isEmail: !!c.get("core").email?.adapter, + passkeys: c.get("core").authorization.passkeys.enabled, + password: c.get("core").authorization.password.enabled, navigation: await loadPublicNavigation(c), bottomBar: await loadPublicBottomBar(c), sso: sso.map(s => ({ id: s.id, name: s.name, icon: s.icon })), diff --git a/packages/vitnode/src/api/modules/users/passkeys/failure.ts b/packages/vitnode/src/api/modules/users/passkeys/failure.ts new file mode 100644 index 000000000..fd03bfd8d --- /dev/null +++ b/packages/vitnode/src/api/modules/users/passkeys/failure.ts @@ -0,0 +1,28 @@ +import type { Context } from "hono"; + +import { HTTPException } from "hono/http-exception"; + +import { PasskeyError } from "@/api/models/passkey"; + +export const passkeyFailure = (c: Context, error: unknown) => { + if (!(error instanceof PasskeyError)) throw error; + + const body = { error: error.code }; + + switch (error.status) { + case 400: + return c.json(body, 400); + case 403: + return c.json(body, 403); + case 404: + return c.json(body, 404); + case 409: + return c.json(body, 409); + } +}; + +export const requireSignedInUser = (user: null | User): User => { + if (!user) throw new HTTPException(401, { message: "Unauthorized" }); + + return user; +}; diff --git a/packages/vitnode/src/api/modules/users/passkeys/passkeys.module.ts b/packages/vitnode/src/api/modules/users/passkeys/passkeys.module.ts new file mode 100644 index 000000000..83e5197ef --- /dev/null +++ b/packages/vitnode/src/api/modules/users/passkeys/passkeys.module.ts @@ -0,0 +1,28 @@ +import { buildModule } from "@/api/lib/module"; +import { CONFIG_PLUGIN } from "@/config"; + +import { passkeyAdminSignInOptionsRoute } from "./routes/admin-sign-in-options.route"; +import { passkeyAdminSignInVerifyRoute } from "./routes/admin-sign-in-verify.route"; +import { passkeyAuthenticationOptionsRoute } from "./routes/authentication-options.route"; +import { passkeyAuthenticationVerifyRoute } from "./routes/authentication-verify.route"; +import { deletePasskeyRoute } from "./routes/delete.route"; +import { listPasskeysRoute } from "./routes/list.route"; +import { passkeyRegistrationOptionsRoute } from "./routes/registration-options.route"; +import { passkeyRegistrationVerifyRoute } from "./routes/registration-verify.route"; +import { renamePasskeyRoute } from "./routes/rename.route"; + +export const passkeysUserModule = buildModule({ + pluginId: CONFIG_PLUGIN.pluginId, + name: "passkeys", + routes: [ + listPasskeysRoute, + passkeyRegistrationOptionsRoute, + passkeyRegistrationVerifyRoute, + passkeyAuthenticationOptionsRoute, + passkeyAuthenticationVerifyRoute, + passkeyAdminSignInOptionsRoute, + passkeyAdminSignInVerifyRoute, + renamePasskeyRoute, + deletePasskeyRoute, + ], +}); diff --git a/packages/vitnode/src/api/modules/users/passkeys/passkeys.test.ts b/packages/vitnode/src/api/modules/users/passkeys/passkeys.test.ts new file mode 100644 index 000000000..29005b75d --- /dev/null +++ b/packages/vitnode/src/api/modules/users/passkeys/passkeys.test.ts @@ -0,0 +1,1164 @@ +// @vitest-environment node +import type { Context } from "hono"; + +import { OpenAPIHono } from "@hono/zod-openapi"; +import { afterEach, describe, expect, it, vi } from "vitest"; + +import type { EnvVariablesVitNode } from "@/api/middlewares/global.middleware"; + +import { PasskeyModel } from "@/api/models/passkey"; +import { SessionModel } from "@/api/models/session"; +import { SessionAdminModel } from "@/api/models/session-admin"; +import { + createMemoryPasskeyStore, + type MemoryPasskeyAccount, +} from "@/tests/passkey-store"; +import { + type CeremonyOverrides, + createSoftwareAuthenticator, +} from "@/tests/webauthn"; + +import { passkeysUserModule } from "./passkeys.module"; + +const ORIGIN = "https://community.example.com"; +const RP_ID = "example.com"; + +type Authorization = EnvVariablesVitNode["core"]["authorization"]; + +const AUTHORIZATION: Authorization = { + adminCookieExpires: 1000 * 60 * 60 * 24, + adminCookieName: "vitnode_auth_admin", + cookieDomain: undefined, + cookie_expires: 1000 * 60 * 60 * 24 * 90, + cookieName: "vitnode_auth", + cookieSecure: true, + deviceCookieExpires: 1000 * 60 * 60 * 24 * 365, + deviceCookieName: "vitnode_device", + passkeys: { + enabled: true, + origins: [ORIGIN], + rpId: RP_ID, + rpName: "VitNode", + }, + password: { enabled: true }, + ssoAdapters: [], +}; + +const ALICE = { email: "alice@example.com", id: 1, name: "Alice" }; +const BOB = { email: "bob@example.com", id: 2, name: "Bob" }; + +type Viewer = typeof ALICE; + +interface RegistrationOptions { + authenticatorSelection: { residentKey: string; userVerification: string }; + challenge: string; + excludeCredentials: { id: string }[]; + user: { id: string }; +} + +interface AuthenticationOptions { + allowCredentials?: unknown[]; + challenge: string; + userVerification: string; +} + +const json = (body: unknown): RequestInit => ({ + body: JSON.stringify(body), + headers: { "content-type": "application/json" }, + method: "POST", +}); + +const harness = ({ + accounts = { + [ALICE.id]: { hasPassword: true, ssoAccounts: 0 }, + [BOB.id]: { hasPassword: true, ssoAccounts: 0 }, + }, + passkeys: initialPasskeys = AUTHORIZATION.passkeys, +}: { + accounts?: Record; + passkeys?: Authorization["passkeys"]; +} = {}) => { + let passkeys = initialPasskeys; + let password = AUTHORIZATION.password; + const memory = createMemoryPasskeyStore(accounts); + vi.spyOn(PasskeyModel.prototype, "store", "get").mockReturnValue( + memory.store, + ); + const createSession = vi + .spyOn(SessionModel.prototype, "createSessionByUserId") + .mockResolvedValue({ token: "session-token" }); + const createAdminSession = vi + .spyOn(SessionAdminModel.prototype, "createSessionByUserId") + .mockResolvedValue({ token: "admin-token" }); + const staff = new Set(); + vi.spyOn( + SessionAdminModel.prototype, + "checkIfUserIsAdmin", + ).mockImplementation(async userId => Promise.resolve(staff.has(userId))); + let adminViewer: null | Viewer = null; + vi.spyOn(SessionAdminModel.prototype, "getUser").mockImplementation( + async () => + Promise.resolve( + adminViewer as unknown as Awaited< + ReturnType + >, + ), + ); + const emit = vi.fn(async () => Promise.resolve(undefined)); + + let viewer: null | Viewer = null; + const app = new OpenAPIHono(); + app.use("*", async (c, next) => { + c.set("core", { + authorization: { ...AUTHORIZATION, passkeys, password }, + } as EnvVariablesVitNode["core"]); + c.set("user", viewer as unknown as Context["var"]["user"]); + c.set("events", { emit } as unknown as Context["var"]["events"]); + await next(); + }); + app.route("/", passkeysUserModule.hono); + + let jar = new Map(); + + const request = async (path: string, init: RequestInit = {}) => { + const cookie = [...jar].map(([name, value]) => `${name}=${value}`); + const headers = new Headers(init.headers); + if (cookie.length > 0) headers.set("cookie", cookie.join("; ")); + headers.set("user-agent", "Mozilla/5.0 (Macintosh; Mac OS X) Firefox/140"); + + const response = await app.request(path, { ...init, headers }); + + for (const line of response.headers.getSetCookie()) { + const [pair = ""] = line.split(";"); + const [name = "", value = ""] = pair.split("="); + if (/max-age=0/i.test(line) || value === "") jar.delete(name); + else jar.set(name, value); + } + + return response; + }; + + return { + ...memory, + createAdminSession, + createSession, + emit, + request, + cookies: () => new Map(jar), + restoreCookies: (cookies: Map) => { + jar = new Map(cookies); + }, + disablePasskeys: () => { + passkeys = { enabled: false, problems: [] }; + }, + disablePasswordSignIn: () => { + password = { enabled: false }; + }, + grantStaff: (user: Viewer) => { + staff.add(user.id); + }, + revokeStaff: (user: Viewer) => { + staff.delete(user.id); + }, + signInToAdminAs: (user: null | Viewer) => { + adminViewer = user; + }, + signInAs: (user: null | Viewer) => { + viewer = user; + }, + switchBrowser: () => { + jar = new Map(); + }, + }; +}; + +type Harness = ReturnType; +type Authenticator = ReturnType; + +const startRegistration = async (h: Harness) => { + const response = await h.request("/register/options", { method: "POST" }); + expect(response.status).toBe(200); + + return (await response.json()) as RegistrationOptions; +}; + +const register = async ( + h: Harness, + authenticator: Authenticator, + { name, overrides }: { name?: string; overrides?: CeremonyOverrides } = {}, +) => { + const options = await startRegistration(h); + const credential = authenticator.createCredential({ + challenge: options.challenge, + overrides, + userId: options.user.id, + }); + + return await h.request("/register", json({ name, response: credential })); +}; + +const startSignIn = async (h: Harness) => { + const response = await h.request("/sign-in/options", { method: "POST" }); + expect(response.status).toBe(200); + + return (await response.json()) as AuthenticationOptions; +}; + +const signIn = async ( + h: Harness, + authenticator: Authenticator, + overrides?: CeremonyOverrides, +) => { + const options = await startSignIn(h); + const assertion = authenticator.getAssertion({ + challenge: options.challenge, + overrides, + }); + + return await h.request("/sign-in", json({ response: assertion })); +}; + +const newAuthenticator = (aaguid?: string) => + createSoftwareAuthenticator({ aaguid, origin: ORIGIN, rpId: RP_ID }); + +afterEach(() => { + vi.restoreAllMocks(); + vi.useRealTimers(); +}); + +describe("passkey registration", () => { + it("saves a passkey for the signed-in user", async () => { + const h = harness(); + h.signInAs(ALICE); + + const response = await register(h, newAuthenticator(), { + name: " Work laptop ", + }); + + expect(response.status).toBe(201); + const { passkey } = (await response.json()) as { + passkey: { name: string }; + }; + expect(passkey.name).toBe("Work laptop"); + + const [saved] = [...h.passkeys.values()]; + expect(saved).toMatchObject({ + backedUp: true, + deviceType: "multiDevice", + transports: ["internal", "hybrid"], + userId: ALICE.id, + }); + expect(h.emit).toHaveBeenCalledWith("user.passkey.created", { + passkeyId: saved?.id, + userId: ALICE.id, + }); + }); + + it("asks for a discoverable, user-verified credential", async () => { + const h = harness(); + h.signInAs(ALICE); + + const options = await startRegistration(h); + + expect(options.authenticatorSelection).toMatchObject({ + residentKey: "required", + userVerification: "required", + }); + }); + + it("names a passkey after its authenticator when no name is given", async () => { + const h = harness(); + h.signInAs(ALICE); + + await register(h, newAuthenticator("fbfc3007-154e-4ecc-8c0b-6e020557d7bd")); + await register(h, newAuthenticator()); + + expect([...h.passkeys.values()].map(passkey => passkey.name)).toEqual([ + "iCloud Keychain", + "Firefox (Mac OS)", + ]); + }); + + it("reuses one WebAuthn user id and excludes registered credentials", async () => { + const h = harness(); + h.signInAs(ALICE); + const first = newAuthenticator(); + await register(h, first); + + const options = await startRegistration(h); + + expect(options.user.id).toBe([...h.passkeys.values()][0]?.webauthnUserId); + expect(options.excludeCredentials.map(({ id }) => id)).toEqual([ + first.credentialId, + ]); + }); + + it("refuses a guest", async () => { + const h = harness(); + + const response = await h.request("/register/options", { method: "POST" }); + + expect(response.status).toBe(401); + }); + + it("refuses to register the same credential twice", async () => { + const h = harness(); + h.signInAs(ALICE); + const authenticator = newAuthenticator(); + await register(h, authenticator); + + const response = await register(h, authenticator); + + expect(response.status).toBe(409); + expect(await response.json()).toEqual({ error: "already_registered" }); + }); + + it("answers 404 when passkeys are disabled", async () => { + const h = harness({ passkeys: { enabled: false, problems: [] } }); + h.signInAs(ALICE); + + const response = await h.request("/register/options", { method: "POST" }); + + expect(response.status).toBe(404); + expect(await response.json()).toEqual({ error: "passkeys_disabled" }); + }); +}); + +describe("passkey registration challenges", () => { + it("rejects a credential signed over a different challenge", async () => { + const h = harness(); + h.signInAs(ALICE); + + const response = await register(h, newAuthenticator(), { + overrides: { challenge: "c29tZS1vdGhlci1jaGFsbGVuZ2U" }, + }); + + expect(response.status).toBe(400); + expect(await response.json()).toEqual({ error: "verification_failed" }); + expect(h.passkeys.size).toBe(0); + }); + + it("consumes the challenge, so a replayed response is refused", async () => { + const h = harness(); + h.signInAs(ALICE); + const options = await startRegistration(h); + const cookies = h.cookies(); + const credential = newAuthenticator().createCredential({ + challenge: options.challenge, + userId: options.user.id, + }); + + const first = await h.request("/register", json({ response: credential })); + h.restoreCookies(cookies); + const replay = await h.request("/register", json({ response: credential })); + + expect(first.status).toBe(201); + expect(replay.status).toBe(400); + expect(await replay.json()).toEqual({ error: "invalid_challenge" }); + expect(h.challenges.size).toBe(0); + }); + + it("refuses a challenge issued to another browser", async () => { + const h = harness(); + h.signInAs(ALICE); + const options = await startRegistration(h); + const credential = newAuthenticator().createCredential({ + challenge: options.challenge, + userId: options.user.id, + }); + + h.switchBrowser(); + const response = await h.request( + "/register", + json({ response: credential }), + ); + + expect(response.status).toBe(400); + expect(await response.json()).toEqual({ error: "invalid_challenge" }); + }); + + it("refuses a challenge issued to another account", async () => { + const h = harness(); + h.signInAs(ALICE); + const options = await startRegistration(h); + const credential = newAuthenticator().createCredential({ + challenge: options.challenge, + userId: options.user.id, + }); + + h.signInAs(BOB); + const response = await h.request( + "/register", + json({ response: credential }), + ); + + expect(response.status).toBe(400); + expect(h.passkeys.size).toBe(0); + }); + + it("refuses a challenge older than five minutes", async () => { + vi.useFakeTimers({ toFake: ["Date"] }); + const h = harness(); + h.signInAs(ALICE); + const options = await startRegistration(h); + const credential = newAuthenticator().createCredential({ + challenge: options.challenge, + userId: options.user.id, + }); + + vi.setSystemTime(Date.now() + 5 * 60_000 + 1); + const response = await h.request( + "/register", + json({ response: credential }), + ); + + expect(response.status).toBe(400); + expect(await response.json()).toEqual({ error: "invalid_challenge" }); + }); + + it("clears expired challenges when a new ceremony starts", async () => { + vi.useFakeTimers({ toFake: ["Date"] }); + const h = harness(); + h.signInAs(ALICE); + await startRegistration(h); + h.switchBrowser(); + + vi.setSystemTime(Date.now() + 6 * 60_000); + await startSignIn(h); + + expect([...h.challenges.values()].map(row => row.ceremony)).toEqual([ + "authentication", + ]); + }); + + it("refuses a sign-in challenge presented to registration", async () => { + const h = harness(); + h.signInAs(ALICE); + await startSignIn(h); + const signInToken = h.cookies().get("vitnode_auth_passkey_authentication"); + const options = await startRegistration(h); + const credential = newAuthenticator().createCredential({ + challenge: options.challenge, + userId: options.user.id, + }); + + h.restoreCookies( + new Map([["vitnode_auth_passkey_registration", signInToken ?? ""]]), + ); + const response = await h.request( + "/register", + json({ response: credential }), + ); + + expect(response.status).toBe(400); + expect(await response.json()).toEqual({ error: "invalid_challenge" }); + }); + + it("rejects a response from the wrong origin", async () => { + const h = harness(); + h.signInAs(ALICE); + + const response = await register(h, newAuthenticator(), { + overrides: { origin: "https://evil.example" }, + }); + + expect(response.status).toBe(400); + expect(h.passkeys.size).toBe(0); + }); + + it("rejects a credential scoped to another RP ID", async () => { + const h = harness(); + h.signInAs(ALICE); + + const response = await register(h, newAuthenticator(), { + overrides: { rpId: "evil.example" }, + }); + + expect(response.status).toBe(400); + expect(h.passkeys.size).toBe(0); + }); + + it("rejects a credential created without user verification", async () => { + const h = harness(); + h.signInAs(ALICE); + + const response = await register(h, newAuthenticator(), { + overrides: { userVerified: false }, + }); + + expect(response.status).toBe(400); + expect(h.passkeys.size).toBe(0); + }); +}); + +describe("passkey sign-in", () => { + const registered = async (setup?: (authenticator: Authenticator) => void) => { + const h = harness(); + const authenticator = newAuthenticator(); + setup?.(authenticator); + h.signInAs(ALICE); + await register(h, authenticator); + h.signInAs(null); + h.switchBrowser(); + + return { authenticator, h }; + }; + + it("offers discoverable credentials without asking for an email", async () => { + const { h } = await registered(); + + const options = await startSignIn(h); + + expect(options.allowCredentials ?? []).toEqual([]); + expect(options.userVerification).toBe("required"); + }); + + it("creates a normal session for the passkey's owner", async () => { + const { authenticator, h } = await registered(); + + const response = await signIn(h, authenticator); + + expect(response.status).toBe(201); + expect(await response.json()).toEqual({ id: ALICE.id }); + expect(h.createSession).toHaveBeenCalledWith(ALICE.id); + expect(h.createAdminSession).not.toHaveBeenCalled(); + expect([...h.passkeys.values()][0]?.lastUsedAt).toBeInstanceOf(Date); + }); + + it("never trusts a user id supplied by the browser", async () => { + const { authenticator, h } = await registered(); + const options = await startSignIn(h); + const assertion = authenticator.getAssertion({ + challenge: options.challenge, + }); + + const response = await h.request( + "/sign-in", + json({ response: assertion, userId: BOB.id }), + ); + + expect(response.status).toBe(201); + expect(h.createSession).toHaveBeenCalledWith(ALICE.id); + }); + + it("refuses a user handle that does not belong to the credential", async () => { + const { authenticator, h } = await registered(); + + const response = await signIn(h, authenticator, { + userHandle: "Ym9iLWhhbmRsZQ", + }); + + expect(response.status).toBe(403); + expect(h.createSession).not.toHaveBeenCalled(); + }); + + it("refuses a reused sign-in challenge", async () => { + const { authenticator, h } = await registered(); + const options = await startSignIn(h); + const cookies = h.cookies(); + const assertion = authenticator.getAssertion({ + challenge: options.challenge, + }); + + const first = await h.request("/sign-in", json({ response: assertion })); + h.restoreCookies(cookies); + const replay = await h.request("/sign-in", json({ response: assertion })); + + expect(first.status).toBe(201); + expect(replay.status).toBe(400); + expect(await replay.json()).toEqual({ error: "invalid_challenge" }); + expect(h.createSession).toHaveBeenCalledTimes(1); + }); + + it("refuses an assertion over the wrong challenge", async () => { + const { authenticator, h } = await registered(); + + const response = await signIn(h, authenticator, { + challenge: "bm90LXRoZS1pc3N1ZWQtY2hhbGxlbmdl", + }); + + expect(response.status).toBe(403); + expect(h.createSession).not.toHaveBeenCalled(); + }); + + it("refuses an assertion from the wrong origin", async () => { + const { authenticator, h } = await registered(); + + const response = await signIn(h, authenticator, { + origin: "https://community.example.com.evil.example", + }); + + expect(response.status).toBe(403); + expect(h.createSession).not.toHaveBeenCalled(); + }); + + it("refuses an assertion for the wrong RP ID", async () => { + const { authenticator, h } = await registered(); + + const response = await signIn(h, authenticator, { rpId: "evil.example" }); + + expect(response.status).toBe(403); + expect(h.createSession).not.toHaveBeenCalled(); + }); + + it("refuses an assertion without user verification", async () => { + const { authenticator, h } = await registered(); + + const response = await signIn(h, authenticator, { userVerified: false }); + + expect(response.status).toBe(403); + }); + + it("refuses an unknown credential", async () => { + const { h } = await registered(); + + const response = await signIn(h, newAuthenticator()); + + expect(response.status).toBe(403); + expect(await response.json()).toEqual({ error: "verification_failed" }); + }); + + it("accepts authenticators that always report a zero counter", async () => { + const { authenticator, h } = await registered(); + + const first = await signIn(h, authenticator); + const second = await signIn(h, authenticator); + + expect([first.status, second.status]).toEqual([201, 201]); + expect([...h.passkeys.values()][0]?.counter).toBe(0); + }); + + it("stores an increasing counter and refuses one that goes backwards", async () => { + const { authenticator, h } = await registered(a => a.setCounter(5)); + + const forward = await signIn(h, authenticator); + expect(forward.status).toBe(201); + expect([...h.passkeys.values()][0]?.counter).toBe(6); + + const cloned = await signIn(h, authenticator, { counter: 6 }); + expect(cloned.status).toBe(403); + + const reset = await signIn(h, authenticator, { counter: 0 }); + expect(reset.status).toBe(403); + expect(h.createSession).toHaveBeenCalledTimes(1); + }); +}); + +describe("passkey management", () => { + const withPasskeys = async ( + accounts?: Record, + ) => { + const h = harness({ accounts }); + h.signInAs(ALICE); + await register(h, newAuthenticator(), { name: "Alice phone" }); + h.signInAs(BOB); + await register(h, newAuthenticator(), { name: "Bob laptop" }); + const [alicePasskey, bobPasskey] = [...h.passkeys.values()]; + + return { + alicePasskeyId: alicePasskey?.id ?? 0, + bobPasskeyId: bobPasskey?.id ?? 0, + h, + }; + }; + + it("lists only the signed-in user's passkeys", async () => { + const { h } = await withPasskeys(); + h.signInAs(ALICE); + + const response = await h.request("/"); + + expect(response.status).toBe(200); + const body = (await response.json()) as { + passkeys: { name: string }[]; + }; + expect(body.passkeys.map(({ name }) => name)).toEqual(["Alice phone"]); + }); + + it("renames a passkey", async () => { + const { alicePasskeyId, h } = await withPasskeys(); + h.signInAs(ALICE); + + const response = await h.request(`/${alicePasskeyId}`, { + ...json({ name: "Travel phone" }), + method: "PATCH", + }); + + expect(response.status).toBe(200); + expect(h.passkeys.get(alicePasskeyId)?.name).toBe("Travel phone"); + expect(h.emit).toHaveBeenCalledWith("user.passkey.updated", { + name: "Travel phone", + passkeyId: alicePasskeyId, + userId: ALICE.id, + }); + }); + + it("refuses to rename another user's passkey", async () => { + const { bobPasskeyId, h } = await withPasskeys(); + h.signInAs(ALICE); + + const response = await h.request(`/${bobPasskeyId}`, { + ...json({ name: "Mine now" }), + method: "PATCH", + }); + + expect(response.status).toBe(404); + expect(h.passkeys.get(bobPasskeyId)?.name).toBe("Bob laptop"); + }); + + it("refuses to delete another user's passkey", async () => { + const { bobPasskeyId, h } = await withPasskeys(); + h.signInAs(ALICE); + + const response = await h.request(`/${bobPasskeyId}`, { method: "DELETE" }); + + expect(response.status).toBe(404); + expect(h.passkeys.has(bobPasskeyId)).toBe(true); + }); + + it("refuses management routes to a guest", async () => { + const { alicePasskeyId, h } = await withPasskeys(); + h.signInAs(null); + + const list = await h.request("/"); + const remove = await h.request(`/${alicePasskeyId}`, { method: "DELETE" }); + + expect([list.status, remove.status]).toEqual([401, 401]); + expect(h.passkeys.has(alicePasskeyId)).toBe(true); + }); + + it("deletes a passkey when the account keeps its password", async () => { + const { alicePasskeyId, h } = await withPasskeys(); + h.signInAs(ALICE); + + const response = await h.request(`/${alicePasskeyId}`, { + method: "DELETE", + }); + + expect(response.status).toBe(200); + expect(h.passkeys.has(alicePasskeyId)).toBe(false); + expect(h.emit).toHaveBeenCalledWith("user.passkey.deleted", { + passkeyId: alicePasskeyId, + userId: ALICE.id, + }); + }); + + it("keeps the last passkey of an account with no other way to sign in", async () => { + const { alicePasskeyId, h } = await withPasskeys({ + [ALICE.id]: { hasPassword: false, ssoAccounts: 0 }, + }); + h.signInAs(ALICE); + + const response = await h.request(`/${alicePasskeyId}`, { + method: "DELETE", + }); + + expect(response.status).toBe(409); + expect(await response.json()).toEqual({ error: "last_recovery_method" }); + expect(h.passkeys.has(alicePasskeyId)).toBe(true); + }); + + it("lets a passwordless account delete its last passkey when SSO is linked", async () => { + const { alicePasskeyId, h } = await withPasskeys({ + [ALICE.id]: { hasPassword: false, ssoAccounts: 1 }, + }); + h.signInAs(ALICE); + + const response = await h.request(`/${alicePasskeyId}`, { + method: "DELETE", + }); + + expect(response.status).toBe(200); + }); + + it("closes every management route when passkeys are switched off", async () => { + const { alicePasskeyId, h } = await withPasskeys(); + h.signInAs(ALICE); + h.disablePasskeys(); + + const list = await h.request("/"); + const rename = await h.request(`/${alicePasskeyId}`, { + ...json({ name: "Nope" }), + method: "PATCH", + }); + const remove = await h.request(`/${alicePasskeyId}`, { method: "DELETE" }); + + expect([list.status, rename.status, remove.status]).toEqual([ + 404, 404, 404, + ]); + expect(await list.json()).toEqual({ error: "passkeys_disabled" }); + expect(h.passkeys.get(alicePasskeyId)?.name).toBe("Alice phone"); + }); + + it("stops counting a password as a way in once password sign-in is off", async () => { + const { alicePasskeyId, h } = await withPasskeys(); + h.signInAs(ALICE); + h.disablePasswordSignIn(); + + const response = await h.request(`/${alicePasskeyId}`, { + method: "DELETE", + }); + + expect(response.status).toBe(409); + expect(await response.json()).toEqual({ error: "last_recovery_method" }); + expect(h.passkeys.has(alicePasskeyId)).toBe(true); + }); +}); + +const ADMIN_CHALLENGE_COOKIE = "vitnode_auth_passkey_admin_sign_in"; +const PUBLIC_CHALLENGE_COOKIE = "vitnode_auth_passkey_authentication"; + +const startAdminSignIn = async (h: Harness) => { + const response = await h.request("/admin-sign-in/options", { + method: "POST", + }); + expect(response.status).toBe(200); + + return (await response.json()) as AuthenticationOptions; +}; + +const adminSignIn = async ( + h: Harness, + authenticator: Authenticator, + overrides?: CeremonyOverrides, +) => { + const options = await startAdminSignIn(h); + const assertion = authenticator.getAssertion({ + challenge: options.challenge, + overrides, + }); + + return await h.request("/admin-sign-in", json({ response: assertion })); +}; + +const enrolled = async ({ asStaff = true }: { asStaff?: boolean } = {}) => { + const h = harness(); + const authenticator = newAuthenticator(); + if (asStaff) { + h.grantStaff(ALICE); + h.signInToAdminAs(ALICE); + } + h.signInAs(ALICE); + const registration = await register(h, authenticator); + expect(registration.status).toBe(201); + h.signInAs(null); + h.signInToAdminAs(null); + h.switchBrowser(); + + return { authenticator, h }; +}; + +describe("AdminCP passkey sign-in", () => { + it("issues a fresh two-minute AdminCP challenge that requires user verification", async () => { + vi.useFakeTimers({ + now: new Date("2026-09-28T12:00:00Z"), + toFake: ["Date"], + }); + const { h } = await enrolled(); + + const publicOptions = await startSignIn(h); + const options = await startAdminSignIn(h); + + expect(options.userVerification).toBe("required"); + expect(options.allowCredentials ?? []).toEqual([]); + expect(options.challenge).not.toBe(publicOptions.challenge); + expect(h.cookies().has(ADMIN_CHALLENGE_COOKIE)).toBe(true); + + const adminChallenge = [...h.challenges.values()].find( + row => row.ceremony === "admin_sign_in", + ); + expect(adminChallenge).toMatchObject({ + challenge: options.challenge, + expiresAt: new Date("2026-09-28T12:02:00Z"), + userId: null, + }); + }); + + it("starts an AdminCP session for a staff member's passkey", async () => { + const { authenticator, h } = await enrolled(); + h.grantStaff(ALICE); + + const response = await adminSignIn(h, authenticator); + + expect(response.status).toBe(201); + expect(await response.json()).toEqual({ id: ALICE.id }); + expect(h.createAdminSession).toHaveBeenCalledExactlyOnceWith(ALICE.id); + expect(h.createSession).not.toHaveBeenCalled(); + expect(h.cookies().has(ADMIN_CHALLENGE_COOKIE)).toBe(false); + }); + + it("refuses a regular member's passkey", async () => { + const { authenticator, h } = await enrolled({ asStaff: false }); + + const response = await adminSignIn(h, authenticator); + + expect(response.status).toBe(403); + expect(await response.json()).toEqual({ error: "not_staff" }); + expect(h.createAdminSession).not.toHaveBeenCalled(); + expect(h.createSession).not.toHaveBeenCalled(); + }); + + it("refuses a staff member whose access was removed after enrolling", async () => { + const { authenticator, h } = await enrolled(); + h.revokeStaff(ALICE); + + const response = await adminSignIn(h, authenticator); + + expect(response.status).toBe(403); + expect(await response.json()).toEqual({ error: "not_staff" }); + expect(h.createAdminSession).not.toHaveBeenCalled(); + }); + + it("refuses an assertion made without user verification", async () => { + const { authenticator, h } = await enrolled(); + h.grantStaff(ALICE); + + const response = await adminSignIn(h, authenticator, { + userVerified: false, + }); + + expect(response.status).toBe(403); + expect(await response.json()).toEqual({ error: "verification_failed" }); + expect(h.createAdminSession).not.toHaveBeenCalled(); + }); + + it("refuses a challenge older than two minutes", async () => { + vi.useFakeTimers({ toFake: ["Date"] }); + const { authenticator, h } = await enrolled(); + h.grantStaff(ALICE); + const options = await startAdminSignIn(h); + const assertion = authenticator.getAssertion({ + challenge: options.challenge, + }); + + vi.setSystemTime(Date.now() + 2 * 60_000 + 1); + const response = await h.request( + "/admin-sign-in", + json({ response: assertion }), + ); + + expect(response.status).toBe(400); + expect(await response.json()).toEqual({ error: "invalid_challenge" }); + expect(h.createAdminSession).not.toHaveBeenCalled(); + }); + + it("refuses a reused AdminCP challenge", async () => { + const { authenticator, h } = await enrolled(); + h.grantStaff(ALICE); + const options = await startAdminSignIn(h); + const cookies = h.cookies(); + const assertion = authenticator.getAssertion({ + challenge: options.challenge, + }); + + const first = await h.request( + "/admin-sign-in", + json({ response: assertion }), + ); + h.restoreCookies(cookies); + const replay = await h.request( + "/admin-sign-in", + json({ response: assertion }), + ); + + expect(first.status).toBe(201); + expect(replay.status).toBe(400); + expect(await replay.json()).toEqual({ error: "invalid_challenge" }); + expect(h.createAdminSession).toHaveBeenCalledTimes(1); + }); + + it("refuses a public sign-in challenge presented to the AdminCP", async () => { + const { authenticator, h } = await enrolled(); + h.grantStaff(ALICE); + const options = await startSignIn(h); + const assertion = authenticator.getAssertion({ + challenge: options.challenge, + }); + + h.restoreCookies( + new Map([ + [ + ADMIN_CHALLENGE_COOKIE, + h.cookies().get(PUBLIC_CHALLENGE_COOKIE) ?? "", + ], + ]), + ); + const response = await h.request( + "/admin-sign-in", + json({ response: assertion }), + ); + + expect(response.status).toBe(400); + expect(await response.json()).toEqual({ error: "invalid_challenge" }); + expect(h.createAdminSession).not.toHaveBeenCalled(); + }); + + it("refuses an AdminCP challenge presented to public sign-in", async () => { + const { authenticator, h } = await enrolled(); + h.grantStaff(ALICE); + const options = await startAdminSignIn(h); + const assertion = authenticator.getAssertion({ + challenge: options.challenge, + }); + + h.restoreCookies( + new Map([ + [ + PUBLIC_CHALLENGE_COOKIE, + h.cookies().get(ADMIN_CHALLENGE_COOKIE) ?? "", + ], + ]), + ); + const response = await h.request("/sign-in", json({ response: assertion })); + + expect(response.status).toBe(400); + expect(h.createSession).not.toHaveBeenCalled(); + expect(h.createAdminSession).not.toHaveBeenCalled(); + }); + + it("refuses a registration challenge presented to the AdminCP", async () => { + const { authenticator, h } = await enrolled(); + h.grantStaff(ALICE); + h.signInAs(ALICE); + h.signInToAdminAs(ALICE); + const registration = await startRegistration(h); + const assertion = authenticator.getAssertion({ + challenge: registration.challenge, + }); + + h.restoreCookies( + new Map([ + [ + ADMIN_CHALLENGE_COOKIE, + h.cookies().get("vitnode_auth_passkey_registration") ?? "", + ], + ]), + ); + const response = await h.request( + "/admin-sign-in", + json({ response: assertion }), + ); + + expect(response.status).toBe(400); + expect(h.createAdminSession).not.toHaveBeenCalled(); + }); + + it("refuses an assertion from the wrong origin", async () => { + const { authenticator, h } = await enrolled(); + h.grantStaff(ALICE); + + const response = await adminSignIn(h, authenticator, { + origin: "https://admin.evil.example", + }); + + expect(response.status).toBe(403); + expect(h.createAdminSession).not.toHaveBeenCalled(); + }); + + it("refuses an assertion for the wrong RP ID", async () => { + const { authenticator, h } = await enrolled(); + h.grantStaff(ALICE); + + const response = await adminSignIn(h, authenticator, { + rpId: "evil.example", + }); + + expect(response.status).toBe(403); + expect(h.createAdminSession).not.toHaveBeenCalled(); + }); + + it("never turns a public session into an AdminCP session", async () => { + const { h } = await enrolled(); + h.grantStaff(ALICE); + h.signInAs(ALICE); + + const withoutChallenge = await h.request( + "/admin-sign-in", + json({ response: newAuthenticator().getAssertion({ challenge: "" }) }), + ); + + expect(withoutChallenge.status).toBe(400); + expect(h.createAdminSession).not.toHaveBeenCalled(); + }); + + it("keeps public passkey sign-in public, even for staff", async () => { + const { authenticator, h } = await enrolled(); + h.grantStaff(ALICE); + + const response = await signIn(h, authenticator); + + expect(response.status).toBe(201); + expect(h.createSession).toHaveBeenCalledExactlyOnceWith(ALICE.id); + expect(h.createAdminSession).not.toHaveBeenCalled(); + }); + + it("answers 404 when passkeys are disabled", async () => { + const h = harness({ passkeys: { enabled: false, problems: [] } }); + + const response = await h.request("/admin-sign-in/options", { + method: "POST", + }); + + expect(response.status).toBe(404); + expect(await response.json()).toEqual({ error: "passkeys_disabled" }); + }); +}); + +describe("staff passkey enrollment", () => { + it("asks staff to open the AdminCP before adding a passkey", async () => { + const h = harness(); + h.grantStaff(ALICE); + h.signInAs(ALICE); + + const response = await h.request("/register/options", { method: "POST" }); + + expect(response.status).toBe(403); + expect(await response.json()).toEqual({ error: "admin_session_required" }); + expect(h.challenges.size).toBe(0); + }); + + it("refuses an AdminCP session that belongs to someone else", async () => { + const h = harness(); + h.grantStaff(ALICE); + h.signInAs(ALICE); + h.signInToAdminAs(BOB); + + const response = await h.request("/register/options", { method: "POST" }); + + expect(response.status).toBe(403); + }); + + it("refuses to save the passkey when the AdminCP session ended mid-ceremony", async () => { + const h = harness(); + h.grantStaff(ALICE); + h.signInAs(ALICE); + h.signInToAdminAs(ALICE); + const options = await startRegistration(h); + const credential = newAuthenticator().createCredential({ + challenge: options.challenge, + userId: options.user.id, + }); + + h.signInToAdminAs(null); + const response = await h.request( + "/register", + json({ response: credential }), + ); + + expect(response.status).toBe(403); + expect(await response.json()).toEqual({ error: "admin_session_required" }); + expect(h.passkeys.size).toBe(0); + }); + + it("lets staff with their own AdminCP session add a passkey", async () => { + const h = harness(); + h.grantStaff(ALICE); + h.signInAs(ALICE); + h.signInToAdminAs(ALICE); + + const response = await register(h, newAuthenticator()); + + expect(response.status).toBe(201); + expect(h.passkeys.size).toBe(1); + }); +}); diff --git a/packages/vitnode/src/api/modules/users/passkeys/routes/admin-sign-in-options.route.ts b/packages/vitnode/src/api/modules/users/passkeys/routes/admin-sign-in-options.route.ts new file mode 100644 index 000000000..f8c7919cd --- /dev/null +++ b/packages/vitnode/src/api/modules/users/passkeys/routes/admin-sign-in-options.route.ts @@ -0,0 +1,41 @@ +import { buildRoute } from "@/api/lib/route"; +import { PasskeyModel } from "@/api/models/passkey"; +import { CONFIG_PLUGIN } from "@/config"; + +import { passkeyFailure } from "../failure"; +import { + PASSKEY_ERROR_RESPONSES, + zodPasskeyAuthenticationOptionsSchema, +} from "../schema"; + +export const passkeyAdminSignInOptionsRoute = buildRoute({ + pluginId: CONFIG_PLUGIN.pluginId, + route: { + method: "post", + description: + "Start signing in to the AdminCP with a passkey. Issues a fresh, two-minute, single-use challenge that only the AdminCP sign-in accepts.", + path: "/admin-sign-in/options", + responses: { + 200: { + content: { + "application/json": { + schema: zodPasskeyAuthenticationOptionsSchema, + }, + }, + description: "WebAuthn request options for the browser", + }, + ...PASSKEY_ERROR_RESPONSES, + }, + }, + handler: async c => { + try { + const options = await new PasskeyModel(c).authenticationOptions( + "admin_sign_in", + ); + + return c.json(options, 200); + } catch (error) { + return passkeyFailure(c, error); + } + }, +}); diff --git a/packages/vitnode/src/api/modules/users/passkeys/routes/admin-sign-in-verify.route.ts b/packages/vitnode/src/api/modules/users/passkeys/routes/admin-sign-in-verify.route.ts new file mode 100644 index 000000000..2d75fe36e --- /dev/null +++ b/packages/vitnode/src/api/modules/users/passkeys/routes/admin-sign-in-verify.route.ts @@ -0,0 +1,64 @@ +import { z } from "@hono/zod-openapi"; + +import { buildRoute } from "@/api/lib/route"; +import { PasskeyError, PasskeyModel } from "@/api/models/passkey"; +import { SessionAdminModel } from "@/api/models/session-admin"; +import { CONFIG_PLUGIN } from "@/config"; + +import { passkeyFailure } from "../failure"; +import { + PASSKEY_ERROR_RESPONSES, + zodPasskeyAuthenticationResponseSchema, +} from "../schema"; + +export const passkeyAdminSignInVerifyRoute = buildRoute({ + pluginId: CONFIG_PLUGIN.pluginId, + route: { + method: "post", + description: + "Finish signing in to the AdminCP with a user-verified passkey and start an AdminCP session. The account must hold staff access right now; a public session is never read or upgraded.", + path: "/admin-sign-in", + request: { + body: { + required: true, + content: { + "application/json": { + schema: z.object({ + response: zodPasskeyAuthenticationResponseSchema, + }), + }, + }, + }, + }, + responses: { + 201: { + content: { + "application/json": { + schema: z.object({ id: z.number() }), + }, + }, + description: "Signed in to the AdminCP", + }, + ...PASSKEY_ERROR_RESPONSES, + }, + }, + handler: async c => { + const { response } = c.req.valid("json"); + + try { + const { userId } = await new PasskeyModel(c).verifyAuthentication( + response, + "admin_sign_in", + ); + const admin = new SessionAdminModel(c); + if (!(await admin.checkIfUserIsAdmin(userId))) { + throw new PasskeyError("not_staff", 403); + } + await admin.createSessionByUserId(userId); + + return c.json({ id: userId }, 201); + } catch (error) { + return passkeyFailure(c, error); + } + }, +}); diff --git a/packages/vitnode/src/api/modules/users/passkeys/routes/authentication-options.route.ts b/packages/vitnode/src/api/modules/users/passkeys/routes/authentication-options.route.ts new file mode 100644 index 000000000..c5c306267 --- /dev/null +++ b/packages/vitnode/src/api/modules/users/passkeys/routes/authentication-options.route.ts @@ -0,0 +1,41 @@ +import { buildRoute } from "@/api/lib/route"; +import { PasskeyModel } from "@/api/models/passkey"; +import { CONFIG_PLUGIN } from "@/config"; + +import { passkeyFailure } from "../failure"; +import { + PASSKEY_ERROR_RESPONSES, + zodPasskeyAuthenticationOptionsSchema, +} from "../schema"; + +export const passkeyAuthenticationOptionsRoute = buildRoute({ + pluginId: CONFIG_PLUGIN.pluginId, + route: { + method: "post", + description: + "Start signing in with a passkey. No email is needed - the browser offers the passkeys it holds for this site.", + path: "/sign-in/options", + responses: { + 200: { + content: { + "application/json": { + schema: zodPasskeyAuthenticationOptionsSchema, + }, + }, + description: "WebAuthn request options for the browser", + }, + ...PASSKEY_ERROR_RESPONSES, + }, + }, + handler: async c => { + try { + const options = await new PasskeyModel(c).authenticationOptions( + "authentication", + ); + + return c.json(options, 200); + } catch (error) { + return passkeyFailure(c, error); + } + }, +}); diff --git a/packages/vitnode/src/api/modules/users/passkeys/routes/authentication-verify.route.ts b/packages/vitnode/src/api/modules/users/passkeys/routes/authentication-verify.route.ts new file mode 100644 index 000000000..278d8be68 --- /dev/null +++ b/packages/vitnode/src/api/modules/users/passkeys/routes/authentication-verify.route.ts @@ -0,0 +1,60 @@ +import { z } from "@hono/zod-openapi"; + +import { buildRoute } from "@/api/lib/route"; +import { PasskeyModel } from "@/api/models/passkey"; +import { SessionModel } from "@/api/models/session"; +import { CONFIG_PLUGIN } from "@/config"; + +import { passkeyFailure } from "../failure"; +import { + PASSKEY_ERROR_RESPONSES, + zodPasskeyAuthenticationResponseSchema, +} from "../schema"; + +export const passkeyAuthenticationVerifyRoute = buildRoute({ + pluginId: CONFIG_PLUGIN.pluginId, + route: { + method: "post", + description: + "Finish signing in with a passkey and start a normal session. Never starts an AdminCP session.", + path: "/sign-in", + request: { + body: { + required: true, + content: { + "application/json": { + schema: z.object({ + response: zodPasskeyAuthenticationResponseSchema, + }), + }, + }, + }, + }, + responses: { + 201: { + content: { + "application/json": { + schema: z.object({ id: z.number() }), + }, + }, + description: "Signed in", + }, + ...PASSKEY_ERROR_RESPONSES, + }, + }, + handler: async c => { + const { response } = c.req.valid("json"); + + try { + const { userId } = await new PasskeyModel(c).verifyAuthentication( + response, + "authentication", + ); + await new SessionModel(c).createSessionByUserId(userId); + + return c.json({ id: userId }, 201); + } catch (error) { + return passkeyFailure(c, error); + } + }, +}); diff --git a/packages/vitnode/src/api/modules/users/passkeys/routes/delete.route.ts b/packages/vitnode/src/api/modules/users/passkeys/routes/delete.route.ts new file mode 100644 index 000000000..c6ae66783 --- /dev/null +++ b/packages/vitnode/src/api/modules/users/passkeys/routes/delete.route.ts @@ -0,0 +1,40 @@ +import { z } from "@hono/zod-openapi"; + +import { buildRoute } from "@/api/lib/route"; +import { PasskeyModel } from "@/api/models/passkey"; +import { CONFIG_PLUGIN } from "@/config"; + +import { passkeyFailure, requireSignedInUser } from "../failure"; +import { PASSKEY_ERROR_RESPONSES, zodPasskeyIdParam } from "../schema"; + +export const deletePasskeyRoute = buildRoute({ + pluginId: CONFIG_PLUGIN.pluginId, + route: { + method: "delete", + description: + "Remove one of the signed-in user's passkeys. Refused when it is the account's last way to sign in.", + path: "/{id}", + request: { + params: z.object({ id: zodPasskeyIdParam }), + }, + responses: { + 200: { description: "Passkey removed" }, + ...PASSKEY_ERROR_RESPONSES, + }, + }, + handler: async c => { + const user = requireSignedInUser(c.get("user")); + const { id } = c.req.valid("param"); + + try { + await new PasskeyModel(c).deletePasskey({ + id: Number(id), + userId: user.id, + }); + + return c.body(null, 200); + } catch (error) { + return passkeyFailure(c, error); + } + }, +}); diff --git a/packages/vitnode/src/api/modules/users/passkeys/routes/list.route.ts b/packages/vitnode/src/api/modules/users/passkeys/routes/list.route.ts new file mode 100644 index 000000000..2138d81a1 --- /dev/null +++ b/packages/vitnode/src/api/modules/users/passkeys/routes/list.route.ts @@ -0,0 +1,41 @@ +import { z } from "@hono/zod-openapi"; + +import { buildRoute } from "@/api/lib/route"; +import { PasskeyModel } from "@/api/models/passkey"; +import { CONFIG_PLUGIN } from "@/config"; + +import { passkeyFailure, requireSignedInUser } from "../failure"; +import { PASSKEY_ERROR_RESPONSES, zodPasskeySchema } from "../schema"; + +export const listPasskeysRoute = buildRoute({ + pluginId: CONFIG_PLUGIN.pluginId, + route: { + method: "get", + description: "List the signed-in user's passkeys.", + path: "/", + responses: { + 200: { + content: { + "application/json": { + schema: z.object({ + passkeys: z.array(zodPasskeySchema), + }), + }, + }, + description: "The current user's passkeys", + }, + ...PASSKEY_ERROR_RESPONSES, + }, + }, + handler: async c => { + const user = requireSignedInUser(c.get("user")); + + try { + const passkeys = await new PasskeyModel(c).listPasskeys(user.id); + + return c.json({ passkeys }, 200); + } catch (error) { + return passkeyFailure(c, error); + } + }, +}); diff --git a/packages/vitnode/src/api/modules/users/passkeys/routes/registration-options.route.ts b/packages/vitnode/src/api/modules/users/passkeys/routes/registration-options.route.ts new file mode 100644 index 000000000..7551cd82c --- /dev/null +++ b/packages/vitnode/src/api/modules/users/passkeys/routes/registration-options.route.ts @@ -0,0 +1,41 @@ +import { buildRoute } from "@/api/lib/route"; +import { PasskeyModel } from "@/api/models/passkey"; +import { CONFIG_PLUGIN } from "@/config"; + +import { passkeyFailure, requireSignedInUser } from "../failure"; +import { + PASSKEY_ERROR_RESPONSES, + zodPasskeyRegistrationOptionsSchema, +} from "../schema"; + +export const passkeyRegistrationOptionsRoute = buildRoute({ + pluginId: CONFIG_PLUGIN.pluginId, + route: { + method: "post", + description: + "Start adding a passkey to the signed-in account. Sets a short-lived challenge cookie.", + path: "/register/options", + responses: { + 200: { + content: { + "application/json": { + schema: zodPasskeyRegistrationOptionsSchema, + }, + }, + description: "WebAuthn creation options for the browser", + }, + ...PASSKEY_ERROR_RESPONSES, + }, + }, + handler: async c => { + const user = requireSignedInUser(c.get("user")); + + try { + const options = await new PasskeyModel(c).registrationOptions(user); + + return c.json(options, 200); + } catch (error) { + return passkeyFailure(c, error); + } + }, +}); diff --git a/packages/vitnode/src/api/modules/users/passkeys/routes/registration-verify.route.ts b/packages/vitnode/src/api/modules/users/passkeys/routes/registration-verify.route.ts new file mode 100644 index 000000000..c07a3beb0 --- /dev/null +++ b/packages/vitnode/src/api/modules/users/passkeys/routes/registration-verify.route.ts @@ -0,0 +1,63 @@ +import { z } from "@hono/zod-openapi"; + +import { buildRoute } from "@/api/lib/route"; +import { PasskeyModel } from "@/api/models/passkey"; +import { CONFIG_PLUGIN } from "@/config"; + +import { passkeyFailure, requireSignedInUser } from "../failure"; +import { + PASSKEY_ERROR_RESPONSES, + zodPasskeyNameSchema, + zodPasskeyRegistrationResponseSchema, + zodPasskeySchema, +} from "../schema"; + +export const passkeyRegistrationVerifyRoute = buildRoute({ + pluginId: CONFIG_PLUGIN.pluginId, + route: { + method: "post", + description: + "Finish adding a passkey. Verifies the attestation against the challenge issued to this browser and account.", + path: "/register", + request: { + body: { + required: true, + content: { + "application/json": { + schema: z.object({ + name: zodPasskeyNameSchema.optional(), + response: zodPasskeyRegistrationResponseSchema, + }), + }, + }, + }, + }, + responses: { + 201: { + content: { + "application/json": { + schema: z.object({ passkey: zodPasskeySchema }), + }, + }, + description: "Passkey saved", + }, + ...PASSKEY_ERROR_RESPONSES, + }, + }, + handler: async c => { + const user = requireSignedInUser(c.get("user")); + const { name, response } = c.req.valid("json"); + + try { + const passkey = await new PasskeyModel(c).verifyRegistration({ + name, + response, + userId: user.id, + }); + + return c.json({ passkey }, 201); + } catch (error) { + return passkeyFailure(c, error); + } + }, +}); diff --git a/packages/vitnode/src/api/modules/users/passkeys/routes/rename.route.ts b/packages/vitnode/src/api/modules/users/passkeys/routes/rename.route.ts new file mode 100644 index 000000000..07e8f416e --- /dev/null +++ b/packages/vitnode/src/api/modules/users/passkeys/routes/rename.route.ts @@ -0,0 +1,61 @@ +import { z } from "@hono/zod-openapi"; + +import { buildRoute } from "@/api/lib/route"; +import { PasskeyModel } from "@/api/models/passkey"; +import { CONFIG_PLUGIN } from "@/config"; + +import { passkeyFailure, requireSignedInUser } from "../failure"; +import { + PASSKEY_ERROR_RESPONSES, + zodPasskeyIdParam, + zodPasskeyNameSchema, + zodPasskeySchema, +} from "../schema"; + +export const renamePasskeyRoute = buildRoute({ + pluginId: CONFIG_PLUGIN.pluginId, + route: { + method: "patch", + description: "Rename one of the signed-in user's passkeys.", + path: "/{id}", + request: { + params: z.object({ id: zodPasskeyIdParam }), + body: { + required: true, + content: { + "application/json": { + schema: z.object({ name: zodPasskeyNameSchema }), + }, + }, + }, + }, + responses: { + 200: { + content: { + "application/json": { + schema: z.object({ passkey: zodPasskeySchema }), + }, + }, + description: "Passkey renamed", + }, + ...PASSKEY_ERROR_RESPONSES, + }, + }, + handler: async c => { + const user = requireSignedInUser(c.get("user")); + const { id } = c.req.valid("param"); + const { name } = c.req.valid("json"); + + try { + const passkey = await new PasskeyModel(c).renamePasskey({ + id: Number(id), + name, + userId: user.id, + }); + + return c.json({ passkey }, 200); + } catch (error) { + return passkeyFailure(c, error); + } + }, +}); diff --git a/packages/vitnode/src/api/modules/users/passkeys/schema.ts b/packages/vitnode/src/api/modules/users/passkeys/schema.ts new file mode 100644 index 000000000..096423db6 --- /dev/null +++ b/packages/vitnode/src/api/modules/users/passkeys/schema.ts @@ -0,0 +1,143 @@ +import { z } from "@hono/zod-openapi"; + +import { PASSKEY_NAME_MAX_LENGTH } from "@/lib/passkey"; + +const base64Url = (max: number) => + z + .string() + .max(max) + .regex(/^[A-Za-z0-9_-]*={0,2}$/); + +const credentialDescriptor = z.object({ + id: z.string(), + transports: z.array(z.string()).optional(), + type: z.string(), +}); + +const userVerification = z.enum(["discouraged", "preferred", "required"]); + +const authenticatorAttachment = z.enum(["cross-platform", "platform"]); + +export const zodPasskeyRegistrationOptionsSchema = z.object({ + attestation: z.enum(["direct", "enterprise", "indirect", "none"]).optional(), + authenticatorSelection: z + .object({ + authenticatorAttachment: authenticatorAttachment.optional(), + requireResidentKey: z.boolean().optional(), + residentKey: z.enum(["discouraged", "preferred", "required"]).optional(), + userVerification: userVerification.optional(), + }) + .optional(), + challenge: z.string(), + excludeCredentials: z.array(credentialDescriptor).optional(), + pubKeyCredParams: z.array( + z.object({ alg: z.number(), type: z.literal("public-key") }), + ), + rp: z.object({ id: z.string().optional(), name: z.string() }), + timeout: z.number().optional(), + user: z.object({ + displayName: z.string(), + id: z.string(), + name: z.string(), + }), +}); + +export const zodPasskeyAuthenticationOptionsSchema = z.object({ + allowCredentials: z.array(credentialDescriptor).optional(), + challenge: z.string(), + rpId: z.string().optional(), + timeout: z.number().optional(), + userVerification: userVerification.optional(), +}); + +const CREDENTIAL_ID_MAX = 1024; +const PAYLOAD_MAX = 32_768; + +const clientExtensionResults = z.object({ + credProps: z.object({ rk: z.boolean().optional() }).optional(), +}); + +export const zodPasskeyRegistrationResponseSchema = z.object({ + authenticatorAttachment: authenticatorAttachment.optional(), + clientExtensionResults, + id: base64Url(CREDENTIAL_ID_MAX), + rawId: base64Url(CREDENTIAL_ID_MAX), + response: z.object({ + attestationObject: base64Url(PAYLOAD_MAX), + authenticatorData: base64Url(PAYLOAD_MAX).optional(), + clientDataJSON: base64Url(PAYLOAD_MAX), + publicKey: base64Url(PAYLOAD_MAX).optional(), + publicKeyAlgorithm: z.number().int().optional(), + transports: z.array(z.string().max(32)).max(16).optional(), + }), + type: z.literal("public-key"), +}); + +export const zodPasskeyAuthenticationResponseSchema = z.object({ + authenticatorAttachment: authenticatorAttachment.optional(), + clientExtensionResults, + id: base64Url(CREDENTIAL_ID_MAX), + rawId: base64Url(CREDENTIAL_ID_MAX), + response: z.object({ + authenticatorData: base64Url(PAYLOAD_MAX), + clientDataJSON: base64Url(PAYLOAD_MAX), + signature: base64Url(PAYLOAD_MAX), + userHandle: base64Url(512).optional(), + }), + type: z.literal("public-key"), +}); + +export const zodPasskeyNameSchema = z + .string() + .trim() + .min(1) + .max(PASSKEY_NAME_MAX_LENGTH) + .openapi({ example: "MacBook Touch ID" }); + +export const zodPasskeySchema = z.object({ + backedUp: z.boolean(), + createdAt: z.date(), + deviceType: z.enum(["multiDevice", "singleDevice"]), + id: z.number(), + lastUsedAt: z.date().nullable(), + name: z.string(), + transports: z.array(z.string()), +}); + +export const zodPasskeyErrorSchema = z.object({ + error: z.enum([ + "admin_session_required", + "already_registered", + "invalid_challenge", + "last_recovery_method", + "not_found", + "not_staff", + "passkeys_disabled", + "verification_failed", + ]), +}); + +export type PasskeyErrorCode = z.infer["error"]; + +export const passkeyErrorResponse = (description: string) => ({ + content: { + "application/json": { + schema: zodPasskeyErrorSchema, + }, + }, + description, +}); + +export const PASSKEY_ERROR_RESPONSES = { + 400: passkeyErrorResponse("The challenge or the credential was rejected"), + 403: passkeyErrorResponse( + "The passkey could not be verified, or the account may not do this", + ), + 404: passkeyErrorResponse("Passkeys are disabled or the passkey is unknown"), + 409: passkeyErrorResponse("The request conflicts with the account's state"), +}; + +export const zodPasskeyIdParam = z + .string() + .regex(/^\d{1,9}$/, "Must be a whole number.") + .openapi({ example: "1" }); diff --git a/packages/vitnode/src/api/modules/users/routes/change-password.route.ts b/packages/vitnode/src/api/modules/users/routes/change-password.route.ts index b1795dcf7..062ebecd0 100644 --- a/packages/vitnode/src/api/modules/users/routes/change-password.route.ts +++ b/packages/vitnode/src/api/modules/users/routes/change-password.route.ts @@ -2,6 +2,7 @@ import { and, eq, gt } from "drizzle-orm"; import { HTTPException } from "hono/http-exception"; import { z } from "zod"; +import { assertPasswordSignInEnabled } from "@/api/lib/password-sign-in"; import { buildRoute } from "@/api/lib/route"; import { ForgotPasswordTokenModel, PasswordModel } from "@/api/models/password"; import { revokeAllSessionsForUser } from "@/api/models/session-revoke"; @@ -48,6 +49,7 @@ export const changePasswordRoute = buildRoute({ }, }, handler: async c => { + assertPasswordSignInEnabled(c); const { password, userId, token } = c.req.valid("json"); // The column holds a digest, never the token itself - see the reset route. diff --git a/packages/vitnode/src/api/modules/users/routes/password-sign-in.test.ts b/packages/vitnode/src/api/modules/users/routes/password-sign-in.test.ts new file mode 100644 index 000000000..5ee20cf25 --- /dev/null +++ b/packages/vitnode/src/api/modules/users/routes/password-sign-in.test.ts @@ -0,0 +1,167 @@ +// @vitest-environment node +import type { Context } from "hono"; + +import { OpenAPIHono } from "@hono/zod-openapi"; +import { afterEach, describe, expect, it, vi } from "vitest"; + +import type { EnvVariablesVitNode } from "@/api/middlewares/global.middleware"; + +import { PasswordModel } from "@/api/models/password"; +import { SessionModel } from "@/api/models/session"; +import { SessionAdminModel } from "@/api/models/session-admin"; + +import { linkRoute } from "../sso/routes/link.route"; +import { changePasswordRoute } from "./change-password.route"; +import { resetPasswordRoute } from "./reset-passowrd.route"; +import { signInRoute } from "./sign-in.route"; +import { signUpRoute } from "./sign-up.route"; + +const authorization = ( + enabled: boolean, +): EnvVariablesVitNode["core"]["authorization"] => ({ + adminCookieExpires: 1000 * 60 * 60 * 24, + adminCookieName: "vitnode_auth_admin", + cookieDomain: undefined, + cookie_expires: 1000 * 60 * 60 * 24 * 90, + cookieName: "vitnode_auth", + cookieSecure: true, + deviceCookieExpires: 1000 * 60 * 60 * 24 * 365, + deviceCookieName: "vitnode_device", + passkeys: { enabled: false, problems: [] }, + password: { enabled }, + ssoAdapters: [], +}); + +const STORED_PASSWORD = await new PasswordModel().encryptPassword("Test123!"); + +const fakeDb = () => { + const selects = vi.fn(); + const rows = [{ email: "test@test.com", id: 1, password: STORED_PASSWORD }]; + const chain = { + from: () => chain, + limit: () => chain, + then: async (onFulfilled: (value: typeof rows) => unknown) => + await Promise.resolve(onFulfilled(rows)), + where: () => chain, + }; + + return { + db: { + select: () => { + selects(); + + return chain; + }, + }, + selects, + }; +}; + +const appWithPassword = (enabled: boolean) => { + const { db, selects } = fakeDb(); + const createSession = vi + .spyOn(SessionModel.prototype, "createSessionByUserId") + .mockResolvedValue({ token: "session-token" }); + const createAdminSession = vi + .spyOn(SessionAdminModel.prototype, "createSessionByUserId") + .mockResolvedValue({ token: "admin-token" }); + + const app = new OpenAPIHono(); + app.use("*", async (c, next) => { + c.set("core", { + authorization: authorization(enabled), + } as EnvVariablesVitNode["core"]); + c.set("user", null as Context["var"]["user"]); + c.set("db", db as unknown as Context["var"]["db"]); + await next(); + }); + for (const built of [ + signInRoute, + signUpRoute, + resetPasswordRoute, + changePasswordRoute, + ]) { + app.openapi(built.route, built.handler); + } + const sso = new OpenAPIHono(); + sso.openapi(linkRoute.route, linkRoute.handler); + app.route("/sso", sso); + + return { app, createAdminSession, createSession, selects }; +}; + +const post = (body: unknown): RequestInit => ({ + body: JSON.stringify(body), + headers: { "content-type": "application/json" }, + method: "POST", +}); + +const CREDENTIALS = { email: "test@test.com", password: "Test123!" }; + +afterEach(() => { + vi.restoreAllMocks(); +}); + +describe("authorization.password switched off", () => { + it("refuses a public password sign-in without checking the password", async () => { + const { app, createSession, selects } = appWithPassword(false); + + const response = await app.request("/sign_in", post(CREDENTIALS)); + + expect(response.status).toBe(403); + expect(selects).not.toHaveBeenCalled(); + expect(createSession).not.toHaveBeenCalled(); + }); + + it("still lets staff sign in to the AdminCP", async () => { + const { app, createAdminSession } = appWithPassword(false); + + const response = await app.request( + "/sign_in", + post({ ...CREDENTIALS, isAdmin: true }), + ); + + expect(response.status).toBe(201); + expect(createAdminSession).toHaveBeenCalledWith(1); + }); + + it("refuses sign-up, password reset, password change and password-based SSO linking", async () => { + const { app } = appWithPassword(false); + + const responses = await Promise.all([ + app.request( + "/sign_up", + post({ ...CREDENTIALS, name: "tester", newsletter: false }), + ), + app.request("/reset-password", post({ email: CREDENTIALS.email })), + app.request( + "/change-password", + post({ + password: "NewPassword1!", + token: "a".repeat(32), + userId: 1, + }), + ), + app.request( + "/sso/google/link", + post({ password: CREDENTIALS.password, token: "t".repeat(32) }), + ), + ]); + + expect(responses.map(response => response.status)).toEqual([ + 403, 403, 403, 403, + ]); + }); +}); + +describe("authorization.password on (the default)", () => { + it("signs a member in with their password", async () => { + const { app, createSession, selects } = appWithPassword(true); + + const response = await app.request("/sign_in", post(CREDENTIALS)); + + expect(response.status).toBe(201); + expect(selects).toHaveBeenCalledOnce(); + expect(createSession).toHaveBeenCalledWith(1); + }); +}); diff --git a/packages/vitnode/src/api/modules/users/routes/reset-passowrd.route.ts b/packages/vitnode/src/api/modules/users/routes/reset-passowrd.route.ts index 3f53235bc..93e66a5cf 100644 --- a/packages/vitnode/src/api/modules/users/routes/reset-passowrd.route.ts +++ b/packages/vitnode/src/api/modules/users/routes/reset-passowrd.route.ts @@ -2,6 +2,7 @@ import { eq } from "drizzle-orm"; import { createTranslator } from "use-intl"; import { z } from "zod"; +import { assertPasswordSignInEnabled } from "@/api/lib/password-sign-in"; import { buildRoute } from "@/api/lib/route"; import { matchesEmail, pickAccountForEmail } from "@/api/lib/user-email-lookup"; import { ForgotPasswordTokenModel } from "@/api/models/password"; @@ -38,6 +39,7 @@ export const resetPasswordRoute = buildRoute({ }, }, handler: async c => { + assertPasswordSignInEnabled(c); const RESPONSE_TEXT = c.text("Email sent", 201); const { email } = c.req.valid("json"); const candidates = await c diff --git a/packages/vitnode/src/api/modules/users/routes/sign-in.route.ts b/packages/vitnode/src/api/modules/users/routes/sign-in.route.ts index c08080916..aeed1bee5 100644 --- a/packages/vitnode/src/api/modules/users/routes/sign-in.route.ts +++ b/packages/vitnode/src/api/modules/users/routes/sign-in.route.ts @@ -1,5 +1,6 @@ import { z } from "zod"; +import { assertPasswordSignInEnabled } from "@/api/lib/password-sign-in"; import { buildRoute } from "@/api/lib/route"; import { SessionModel } from "@/api/models/session"; import { SessionAdminModel } from "@/api/models/session-admin"; @@ -53,6 +54,7 @@ export const signInRoute = buildRoute({ }, handler: async c => { const { password, isAdmin, email } = c.req.valid("json"); + if (!isAdmin) assertPasswordSignInEnabled(c); const data = await new UserModel().signInWithPassword({ password, email, diff --git a/packages/vitnode/src/api/modules/users/routes/sign-up.route.ts b/packages/vitnode/src/api/modules/users/routes/sign-up.route.ts index 165e19d69..63363240c 100644 --- a/packages/vitnode/src/api/modules/users/routes/sign-up.route.ts +++ b/packages/vitnode/src/api/modules/users/routes/sign-up.route.ts @@ -1,5 +1,6 @@ import { z } from "zod"; +import { assertPasswordSignInEnabled } from "@/api/lib/password-sign-in"; import { buildRoute } from "@/api/lib/route"; import { PasswordModel } from "@/api/models/password"; import { UserModel } from "@/api/models/user"; @@ -65,6 +66,7 @@ export const signUpRoute = buildRoute({ }, }, handler: async c => { + assertPasswordSignInEnabled(c); const hashedPassword = await new PasswordModel().encryptPassword( c.req.valid("json").password, ); diff --git a/packages/vitnode/src/api/modules/users/sso/routes/link.route.ts b/packages/vitnode/src/api/modules/users/sso/routes/link.route.ts index 34e2a26b3..3249b3b9a 100644 --- a/packages/vitnode/src/api/modules/users/sso/routes/link.route.ts +++ b/packages/vitnode/src/api/modules/users/sso/routes/link.route.ts @@ -1,5 +1,6 @@ import { z } from "zod"; +import { assertPasswordSignInEnabled } from "@/api/lib/password-sign-in"; import { buildRoute } from "@/api/lib/route"; import { SessionModel } from "@/api/models/session"; import { SSOModel } from "@/api/models/sso"; @@ -54,6 +55,7 @@ export const linkRoute = buildRoute({ }, }, handler: async c => { + assertPasswordSignInEnabled(c); const { providerId } = c.req.valid("param"); const { password, token } = c.req.valid("json"); const { userId } = await new SSOModel(c).link({ diff --git a/packages/vitnode/src/api/modules/users/users.module.ts b/packages/vitnode/src/api/modules/users/users.module.ts index 30207d7cb..be7d1070c 100644 --- a/packages/vitnode/src/api/modules/users/users.module.ts +++ b/packages/vitnode/src/api/modules/users/users.module.ts @@ -3,6 +3,7 @@ import { CONFIG_PLUGIN } from "@/config"; import { userFilesModule } from "./files/files.module"; import { userImagesModule } from "./images/images.module"; +import { passkeysUserModule } from "./passkeys/passkeys.module"; import { changePasswordRoute } from "./routes/change-password.route"; import { listDevicesRoute } from "./routes/devices.route"; import { mePolicyRoute } from "./routes/me-policy.route"; @@ -34,5 +35,10 @@ export const usersModule = buildModule({ mePolicyRoute, updateMeRoute, ], - modules: [ssoUserModule, userFilesModule, userImagesModule], + modules: [ + ssoUserModule, + passkeysUserModule, + userFilesModule, + userImagesModule, + ], }); diff --git a/packages/vitnode/src/blocks/page.tsx b/packages/vitnode/src/blocks/page.tsx index 5497a9d51..2d4d37394 100644 --- a/packages/vitnode/src/blocks/page.tsx +++ b/packages/vitnode/src/blocks/page.tsx @@ -120,13 +120,14 @@ export const EditablePage = ({ }; }, [offer, publish, release]); - const opened = useRef(false); + const openedRef = useRef(false); useEffect(() => { - if (opened.current) return; + if (openedRef.current) return; + // eslint-disable-next-line react-you-might-not-need-an-effect/no-event-handler if (!start || !offer.canEdit || openEditing !== true) return; - opened.current = true; + openedRef.current = true; start(offer.pageId); }, [offer.canEdit, offer.pageId, openEditing, start]); diff --git a/packages/vitnode/src/blocks/zone-outlet.tsx b/packages/vitnode/src/blocks/zone-outlet.tsx index 3f5861590..c83061c28 100644 --- a/packages/vitnode/src/blocks/zone-outlet.tsx +++ b/packages/vitnode/src/blocks/zone-outlet.tsx @@ -1,9 +1,10 @@ import type { CSSProperties, ReactElement } from "react"; -import { createElement, useEffect, useState } from "react"; +import { createElement, useCallback, useState } from "react"; import type { ContentEditRuntime, ContentZoneMount } from "./edit-context"; +import { sameContentZoneMount } from "./edit-context"; import { contentZoneAttributes } from "./zone-meta"; const CONTENTS: CSSProperties = { display: "contents" }; @@ -17,20 +18,25 @@ export const ContentZoneOutlet = ({ mount, runtime, }: ContentZoneOutletProps): ReactElement => { - const [node, setNode] = useState(null); - const { id } = mount; - - useEffect(() => { - if (node) runtime.registerZone({ mount, node }); - }, [mount, node, runtime]); - - useEffect(() => { - if (!node) return; - - return () => { - runtime.releaseZone({ id, node }); - }; - }, [id, node, runtime]); + const [registeredMount, setRegisteredMount] = useState(mount); + + if ( + registeredMount !== mount && + !sameContentZoneMount(registeredMount, mount) + ) { + setRegisteredMount(mount); + } + + const register = useCallback( + (node: HTMLElement) => { + runtime.registerZone({ mount: registeredMount, node }); + + return () => { + runtime.releaseZone({ id: registeredMount.id, node }); + }; + }, + [registeredMount, runtime], + ); const transparent = runtime.preview && mount.as === undefined && mount.className === undefined; @@ -41,7 +47,7 @@ export const ContentZoneOutlet = ({ id: mount.id, }), className: mount.className, - ref: setNode, + ref: register, style: transparent ? CONTENTS : undefined, }); }; diff --git a/packages/vitnode/src/database/passkeys.ts b/packages/vitnode/src/database/passkeys.ts new file mode 100644 index 000000000..7e42cd692 --- /dev/null +++ b/packages/vitnode/src/database/passkeys.ts @@ -0,0 +1,63 @@ +import { sql } from "drizzle-orm"; +import { camelCase, index } from "drizzle-orm/pg-core"; + +import { PASSKEY_NAME_MAX_LENGTH } from "@/lib/passkey"; + +import { core_users } from "./users"; + +export const core_users_passkeys = camelCase.table.withRLS( + "core_users_passkeys", + t => ({ + id: t.serial().primaryKey(), + userId: t + .integer() + .notNull() + .references(() => core_users.id, { + onDelete: "cascade", + }), + credentialId: t.varchar({ length: 1024 }).notNull().unique(), + publicKey: t.text().notNull(), + counter: t.bigint({ mode: "number" }).notNull().default(0), + webauthnUserId: t.varchar({ length: 128 }).notNull(), + transports: t + .varchar({ length: 32 }) + .array() + .notNull() + .default(sql`'{}'::varchar[]`), + deviceType: t.varchar({ length: 32 }).notNull(), + backedUp: t.boolean().notNull().default(false), + aaguid: t.varchar({ length: 36 }), + name: t.varchar({ length: PASSKEY_NAME_MAX_LENGTH }).notNull(), + createdAt: t.timestamp().notNull().defaultNow(), + updatedAt: t + .timestamp() + .notNull() + .defaultNow() + .$onUpdate(() => new Date()), + lastUsedAt: t.timestamp(), + }), + t => [ + index("core_users_passkeys_user_id_idx").on(t.userId), + index("core_users_passkeys_webauthn_user_id_idx").on(t.webauthnUserId), + ], +); + +export const core_users_passkey_challenges = camelCase.table.withRLS( + "core_users_passkey_challenges", + t => ({ + id: t.serial().primaryKey(), + tokenHash: t.varchar({ length: 64 }).notNull().unique(), + ceremony: t.varchar({ length: 16 }).notNull(), + challenge: t.varchar({ length: 128 }).notNull(), + userId: t.integer().references(() => core_users.id, { + onDelete: "cascade", + }), + webauthnUserId: t.varchar({ length: 128 }), + createdAt: t.timestamp().notNull().defaultNow(), + expiresAt: t.timestamp().notNull(), + }), + t => [ + index("core_users_passkey_challenges_expires_at_idx").on(t.expiresAt), + index("core_users_passkey_challenges_user_id_idx").on(t.userId), + ], +); diff --git a/packages/vitnode/src/database/relations.ts b/packages/vitnode/src/database/relations.ts index a5fdc9e2e..b0397dbdc 100644 --- a/packages/vitnode/src/database/relations.ts +++ b/packages/vitnode/src/database/relations.ts @@ -9,6 +9,7 @@ import * as languages from "./languages"; import * as logs from "./logs"; import * as moderators from "./moderators"; import * as navigation from "./navigation"; +import * as passkeys from "./passkeys"; import * as queue from "./queue"; import * as roles from "./roles"; import * as search from "./search"; @@ -26,6 +27,7 @@ export const coreSchema = { ...logs, ...moderators, ...navigation, + ...passkeys, ...queue, ...roles, ...search, @@ -49,6 +51,7 @@ export const coreRelations = defineRelations(coreSchema, r => ({ }), secondary_roles: r.many.core_users_secondary_roles(), sso: r.many.core_users_sso(), + passkeys: r.many.core_users_passkeys(), confirm_email: r.one.core_users_confirm_emails(), forgot_password: r.one.core_users_forgot_password(), }, @@ -71,6 +74,13 @@ export const coreRelations = defineRelations(coreSchema, r => ({ }), }, + core_users_passkeys: { + user: r.one.core_users({ + from: r.core_users_passkeys.userId, + to: r.core_users.id, + }), + }, + core_users_confirm_emails: { user: r.one.core_users({ from: r.core_users_confirm_emails.userId, diff --git a/packages/vitnode/src/editor/zones/mount-contract.test.ts b/packages/vitnode/src/editor/zones/mount-contract.test.ts index 4038e4229..6f2816a86 100644 --- a/packages/vitnode/src/editor/zones/mount-contract.test.ts +++ b/packages/vitnode/src/editor/zones/mount-contract.test.ts @@ -190,16 +190,6 @@ describe("persisted values the editor cannot read", () => { }); describe("zones that leave the page while the editor is still open", () => { - it("unregisters the very node it registered, not whatever holds the id now", () => { - expect(outletSource).toContain("runtime.releaseZone({ id, node })"); - }); - - it("keeps that lifetime separate from syncing the mount's content", () => { - expect(outletSource).toMatch( - /return \(\) => \{\s*runtime\.releaseZone\(\{ id, node \}\);\s*\};\s*\},\s*\[id, node, runtime\]\)/, - ); - }); - it("tells the reducer the zone is gone when the portal unmounts", () => { expect(editableZoneSource).toMatch( /useEffect\(\s*\(\) => \(\) => \{\s*dispatch\(\{ type: "unmount", zoneId: id \}\);\s*\},\s*\[dispatch, id\],\s*\)/, diff --git a/packages/vitnode/src/framework/vite/optimize-deps.ts b/packages/vitnode/src/framework/vite/optimize-deps.ts index 030177ca4..f50109add 100644 --- a/packages/vitnode/src/framework/vite/optimize-deps.ts +++ b/packages/vitnode/src/framework/vite/optimize-deps.ts @@ -41,6 +41,7 @@ export const VITNODE_CLIENT_DEPENDENCIES = [ "@dnd-kit/sortable", "@dnd-kit/utilities", "@ferrucc-io/emoji-picker", + "@simplewebauthn/browser", "@tanstack/react-form", "@tanstack/react-query", "@tiptap/extension-audio", diff --git a/packages/vitnode/src/framework/vite/ssr-externals.test.ts b/packages/vitnode/src/framework/vite/ssr-externals.test.ts index f27837e9a..2af635e3c 100644 --- a/packages/vitnode/src/framework/vite/ssr-externals.test.ts +++ b/packages/vitnode/src/framework/vite/ssr-externals.test.ts @@ -9,15 +9,22 @@ const externalsFor = async ( readPluginIds = vi.fn(async () => Promise.resolve(["@acme/blog", "@acme/docs"]), ), -): Promise<{ external: string[]; readPluginIds: typeof readPluginIds }> => { +): Promise<{ + external: string[]; + nitro: undefined | { traceDeps: string[] }; + readPluginIds: typeof readPluginIds; +}> => { const plugin = vitNodeSsrExternals({ appRoot: "/app", readPluginIds }); const config = plugin.config as ( userConfig: UserConfig, env: ConfigEnv, - ) => Promise<{ ssr: { external: string[] } }>; - const { ssr } = await config({}, { command, mode: "development" }); + ) => Promise<{ + nitro: undefined | { traceDeps: string[] }; + ssr: { external: string[] }; + }>; + const { nitro, ssr } = await config({}, { command, mode: "development" }); - return { external: ssr.external, readPluginIds }; + return { external: ssr.external, nitro, readPluginIds }; }; describe("what a VitNode app externalises from its SSR pass", () => { @@ -49,4 +56,16 @@ describe("what a VitNode app externalises from its SSR pass", () => { expect(readPluginIds).toHaveBeenCalledWith("/app"); }); + + it("keeps SimpleWebAuthn's server out of the Nitro bundle", async () => { + const { nitro } = await externalsFor("build"); + + expect(nitro?.traceDeps).toContain("@simplewebauthn/server"); + }); + + it("leaves Nitro alone while the dev server runs", async () => { + const { nitro } = await externalsFor("serve"); + + expect(nitro).toBeUndefined(); + }); }); diff --git a/packages/vitnode/src/framework/vite/ssr-externals.ts b/packages/vitnode/src/framework/vite/ssr-externals.ts index 174843281..b3bf07c7a 100644 --- a/packages/vitnode/src/framework/vite/ssr-externals.ts +++ b/packages/vitnode/src/framework/vite/ssr-externals.ts @@ -6,6 +6,8 @@ const PACKAGE_NAME = "@vitnode/core"; const ALWAYS_EXTERNAL = ["tslib"] as const; +export const NITRO_TRACED_DEPENDENCIES = ["@simplewebauthn/server"] as const; + export interface VitNodeSsrExternalsOptions { appRoot: string; readPluginIds?: (appRoot: string) => Promise; @@ -16,6 +18,10 @@ export const vitNodeSsrExternals = ({ readPluginIds = configuredPluginIds, }: VitNodeSsrExternalsOptions): Plugin => ({ config: async (_userConfig, { command }) => ({ + nitro: + command === "build" + ? { traceDeps: [...NITRO_TRACED_DEPENDENCIES] } + : undefined, ssr: { external: command === "build" diff --git a/packages/vitnode/src/lib/passkey.ts b/packages/vitnode/src/lib/passkey.ts new file mode 100644 index 000000000..1d32ea466 --- /dev/null +++ b/packages/vitnode/src/lib/passkey.ts @@ -0,0 +1 @@ +export const PASSKEY_NAME_MAX_LENGTH = 64; diff --git a/packages/vitnode/src/locales/en.json b/packages/vitnode/src/locales/en.json index 95c6eb034..a291751ef 100644 --- a/packages/vitnode/src/locales/en.json +++ b/packages/vitnode/src/locales/en.json @@ -674,6 +674,10 @@ "title": "Check your email", "desc": "We've sent a confirmation link to your email address", "check_spam": "If you don't see the email in your inbox, please check your spam folder." + }, + "unavailable": { + "title": "Registration is unavailable", + "desc": "This site isn't accepting new accounts right now. Please check back later." } }, "sign_in": { @@ -694,7 +698,45 @@ "desc": "The email address or password was incorrect. Please try again (make sure your caps lock is off)." } }, - "submit": "Login" + "submit": "Login", + "passkey": { + "action": "Sign in with a passkey", + "unsupported_hint": "This browser can't use passkeys. Sign in with your email and password instead.", + "cancelled": { + "title": "Passkey sign-in cancelled", + "desc": "No worries - try again whenever you're ready." + }, + "errors": { + "expired": { + "title": "That took a little too long", + "desc": "The sign-in request expired. Please try again." + }, + "failed": { + "title": "Your passkey couldn't be used", + "desc": "Your browser or device reported a problem. Try again or use another way to sign in." + }, + "rejected": { + "title": "Passkey not recognized", + "desc": "This passkey isn't linked to an account here, or it was removed. Try another passkey or sign in with your password." + }, + "unavailable": { + "title": "Passkeys are turned off", + "desc": "This site doesn't accept passkeys right now. Please use another way to sign in." + }, + "unsupported": { + "title": "Passkeys aren't supported here", + "desc": "This browser or device can't use passkeys. Please use another way to sign in." + }, + "not_staff": { + "title": "No AdminCP access", + "desc": "Your passkey checks out, but this account can't open the AdminCP. Ask an administrator if you think that's a mistake." + } + } + }, + "unavailable": { + "title": "Signing in is unavailable", + "desc": "This site has no sign-in method switched on right now. Please check back later." + } }, "reset_password": { "title": "Reset Password", @@ -765,6 +807,76 @@ "confirm": "Sign out", "success": "Device signed out successfully." } + }, + "passkeys": { + "title": "Passkeys", + "desc": "Sign in with your fingerprint, face or screen lock instead of typing a password. Passkeys live on your devices or in your password manager, and they can't be phished.", + "add": "Add a passkey", + "empty": "You haven't added a passkey yet.", + "added": "Added", + "last_used": "Last used", + "never_used": "Not used yet", + "synced": "Synced", + "device_bound": "This device only", + "unsupported_hint": "This browser can't create passkeys. Try a recent version of Chrome, Safari, Edge or Firefox.", + "add_success": { + "title": "Passkey added", + "desc": "“{name}” is ready. Next time, choose “Sign in with a passkey” on the login page." + }, + "rename": { + "action": "Rename passkey", + "label": "Passkey name", + "save": "Save", + "success": "Passkey renamed", + "success_desc": "It now shows up as “{name}”." + }, + "delete": { + "action": "Remove passkey", + "title": "Remove this passkey?", + "desc": "You won't be able to sign in with “{name}” anymore. It may still appear in your password manager until you delete it there too.", + "confirm": "Remove", + "success": "Passkey removed", + "success_desc": "“{name}” can no longer be used to sign in." + }, + "errors": { + "cancelled": { + "title": "Passkey not added", + "desc": "The prompt was closed before a passkey was created. Try again whenever you like." + }, + "already_registered": { + "title": "Already set up", + "desc": "This device or password manager already has a passkey for your account." + }, + "expired": { + "title": "That took a little too long", + "desc": "The request expired or couldn't be verified. Please try again." + }, + "failed": { + "title": "The passkey couldn't be created", + "desc": "Your browser or device reported a problem. Please try again." + }, + "unavailable": { + "title": "Passkeys are turned off", + "desc": "This site doesn't accept new passkeys right now." + }, + "unsupported": { + "title": "Passkeys aren't supported here", + "desc": "This browser or device can't create passkeys with screen lock or biometrics." + }, + "last_recovery_method": { + "title": "Keep a way to sign in", + "desc": "This is the last way into your account. Add another passkey or set a password with “Forgot password?” on the login page first.", + "desc_passwordless": "This is the last way into your account. Add another passkey first so you can still sign in." + }, + "not_found": { + "title": "Passkey not found", + "desc": "It may have been removed from another device. The list is refreshed." + }, + "admin_session_required": { + "title": "Open the AdminCP first", + "desc": "Your account has AdminCP access, so adding a passkey needs one extra check. Sign in to the AdminCP with your password in this browser, then try again." + } + } } } }, diff --git a/packages/vitnode/src/pages/admin/sign-in.tsx b/packages/vitnode/src/pages/admin/sign-in.tsx index f5ff8746b..811467f66 100644 --- a/packages/vitnode/src/pages/admin/sign-in.tsx +++ b/packages/vitnode/src/pages/admin/sign-in.tsx @@ -2,6 +2,7 @@ import type { PluginRoutePageProps } from "@/routing"; import type { AdminSignInSearch } from "@/tanstack/admin/sign-in-search"; import { AdminSignInRouteContent } from "@/tanstack/admin/sign-in-screen"; +import { loadAuthCard } from "@/tanstack/auth/login-route"; import { useAppNavigate } from "@/tanstack/auth/navigation"; import { defineRoute } from "@/tanstack/plugin-routes"; @@ -15,6 +16,9 @@ const AdminSignInPage = ({ ); export const route = defineRoute({ + load: async ({ context }) => { + await loadAuthCard(context); + }, head: ({ t }) => ({ title: t("core.global.login") }), }); diff --git a/packages/vitnode/src/pages/settings/layout.tsx b/packages/vitnode/src/pages/settings/layout.tsx index 1bd0f7a02..7d831f603 100644 --- a/packages/vitnode/src/pages/settings/layout.tsx +++ b/packages/vitnode/src/pages/settings/layout.tsx @@ -1,3 +1,4 @@ +import { middlewareConfigQueryOptions } from "@/tanstack/auth/middleware-config"; import { defineAuthenticatedRoute } from "@/tanstack/plugin-routes"; import { settingsBreadcrumb } from "@/tanstack/settings/breadcrumb"; import { SettingsLayoutContent } from "@/tanstack/settings/layout"; @@ -9,8 +10,15 @@ const SettingsLayout = ({ children }: { children: React.ReactNode }) => ( ); export const route = defineAuthenticatedRoute({ - load: async ({ context }) => - await loadPageWidgets(context.queryClient, settingsPage), + load: async ({ context }) => { + await Promise.all([ + context.queryClient.query({ + ...middlewareConfigQueryOptions(), + staleTime: "static", + }), + loadPageWidgets(context.queryClient, settingsPage), + ]); + }, head: () => ({ robots: "noindex, nofollow" }), /** The first crumb of the trail; each panel adds its own after it. */ diff --git a/packages/vitnode/src/pages/settings/security.tsx b/packages/vitnode/src/pages/settings/security.tsx index 3f728d536..863cce726 100644 --- a/packages/vitnode/src/pages/settings/security.tsx +++ b/packages/vitnode/src/pages/settings/security.tsx @@ -1,8 +1,43 @@ +import { notFound } from "@tanstack/react-router"; + +import type { PluginRoutePageProps } from "@/routing"; + +import { middlewareConfigQueryOptions } from "@/tanstack/auth/middleware-config"; +import { PasskeysPanelContent } from "@/tanstack/passkeys/panel"; import { defineAuthenticatedRoute } from "@/tanstack/plugin-routes"; import { settingsBreadcrumb } from "@/tanstack/settings/breadcrumb"; +import { passkeysQueryOptions } from "@/views/auth/settings/passkeys/passkeys-query"; import { SecuritySettings } from "@/views/auth/settings/security/security"; -export const route = defineAuthenticatedRoute({ +interface SecurityData { + userId: number; +} + +const SecurityPage = ({ loaderData }: PluginRoutePageProps) => ( + + + +); + +export const route = defineAuthenticatedRoute({ + load: async ({ context }) => { + const config = await context.queryClient.query({ + ...middlewareConfigQueryOptions(), + staleTime: "static", + }); + + // eslint-disable-next-line @typescript-eslint/only-throw-error + if (!config.passkeys) throw notFound(); + + const userId = context.auth.user.id; + + await context.queryClient.query({ + ...passkeysQueryOptions({ userId }), + staleTime: "static", + }); + + return { userId }; + }, head: ({ t }) => ({ title: `${t("core.auth.settings.nav.security")} - ${t("core.auth.settings.title")}`, }), @@ -10,4 +45,4 @@ export const route = defineAuthenticatedRoute({ breadcrumb: settingsBreadcrumb("security"), }); -export default SecuritySettings; +export default SecurityPage; diff --git a/packages/vitnode/src/tanstack/admin/actions.ts b/packages/vitnode/src/tanstack/admin/actions.ts index ad2ea8104..2aad11131 100644 --- a/packages/vitnode/src/tanstack/admin/actions.ts +++ b/packages/vitnode/src/tanstack/admin/actions.ts @@ -1,6 +1,9 @@ import { useQueryClient } from "@tanstack/react-query"; import type { SignInSubmit } from "@/views/auth/sign-in/form/sign-in-form-content"; +import type { PasskeySignInSubmit } from "@/views/auth/sign-in/passkey/passkey-sign-in-button"; + +import { getPasskeyInBrowser } from "@/views/auth/passkeys/webauthn"; import type { AuthNavigate } from "../auth/actions"; @@ -28,3 +31,38 @@ export const useAdminSignInAction = ({ return undefined; }; }; + +export const useAdminPasskeySignInAction = ({ + destination, + navigate, +}: { + destination: () => string; + navigate: AuthNavigate; +}): PasskeySignInSubmit => { + const queryClient = useQueryClient(); + + return async () => { + const start = await authTransport().startAdminPasskeySignIn(); + if (!start.ok) return start.reason; + + const ceremony = await getPasskeyInBrowser(start.options); + if (!ceremony.ok) { + return ceremony.failure === "already_registered" + ? "failed" + : ceremony.failure; + } + + const result = await authTransport().finishAdminPasskeySignIn({ + response: ceremony.response, + }); + + if (!result.ok) { + return result.reason === "access_denied" ? "rejected" : result.reason; + } + + removeAdminIdentityQueries(queryClient); + await navigate(destination()); + + return undefined; + }; +}; diff --git a/packages/vitnode/src/tanstack/admin/queries.test.ts b/packages/vitnode/src/tanstack/admin/queries.test.ts index ba826f97a..34ed827f0 100644 --- a/packages/vitnode/src/tanstack/admin/queries.test.ts +++ b/packages/vitnode/src/tanstack/admin/queries.test.ts @@ -234,11 +234,11 @@ describe("every identity boundary drops the privileged cache", () => { it("the public auth actions clear on every identity change", () => { const code = sourceOf("../auth/actions.ts"); - // Sign-in, SSO completion, an SSO identity linked with a password, - // sign-out and a verified sign-up: five call sites, one per flow that can + // Sign-in, passkey sign-in, SSO completion, an SSO identity linked with a + // password, sign-out and a verified sign-up: six call sites, one per flow that can // change who is at the keyboard. A password reset *request* is deliberately // not one - it mints no session and the visitor stays exactly who they were. - expect(code.split(CLEANUP).length - 1).toBe(5); + expect(code.split(CLEANUP).length - 1).toBe(6); }); it("the public sign-in clears before it navigates", () => { diff --git a/packages/vitnode/src/tanstack/admin/sign-in-screen.tsx b/packages/vitnode/src/tanstack/admin/sign-in-screen.tsx index f08a29916..7f7e2640f 100644 --- a/packages/vitnode/src/tanstack/admin/sign-in-screen.tsx +++ b/packages/vitnode/src/tanstack/admin/sign-in-screen.tsx @@ -1,10 +1,12 @@ import { SignInAdminContent } from "@/views/admin/sign-in/sign-in-admin-content"; import { SignInFormContent } from "@/views/auth/sign-in/form/sign-in-form-content"; +import { PasskeySignInButton } from "@/views/auth/sign-in/passkey/passkey-sign-in-button"; import type { AuthNavigate } from "../auth/actions"; +import { useMiddlewareConfigQuery } from "../auth/middleware-config"; import { RouteMessages } from "../i18n/route-messages"; -import { useAdminSignInAction } from "./actions"; +import { useAdminPasskeySignInAction, useAdminSignInAction } from "./actions"; import { sanitizeAdminReturnTo } from "./return-to"; import { ADMIN_SIGN_IN_NAMESPACES } from "./sign-in-route"; @@ -18,15 +20,22 @@ export const AdminSignInRouteContent = ({ navigate, returnTo, }: AdminSignInRouteProps) => { - const signIn = useAdminSignInAction({ - destination: () => sanitizeAdminReturnTo(returnTo), - navigate, - }); + const { data: config } = useMiddlewareConfigQuery(); + const destination = () => sanitizeAdminReturnTo(returnTo); + const signIn = useAdminSignInAction({ destination, navigate }); + const passkeySignIn = useAdminPasskeySignInAction({ destination, navigate }); return (
- } /> + } + passkey={ + config.passkeys ? ( + + ) : undefined + } + />
); diff --git a/packages/vitnode/src/tanstack/auth/actions.ts b/packages/vitnode/src/tanstack/auth/actions.ts index 85c076339..a72f8c9ab 100644 --- a/packages/vitnode/src/tanstack/auth/actions.ts +++ b/packages/vitnode/src/tanstack/auth/actions.ts @@ -4,11 +4,14 @@ import { useRouter } from "@tanstack/react-router"; import type { ChangePasswordSubmit } from "@/views/auth/password-reset/change-password-form/change-password-form-content"; import type { PasswordResetSubmit } from "@/views/auth/password-reset/form/password-reset-form-content"; import type { SignInSubmit } from "@/views/auth/sign-in/form/sign-in-form-content"; +import type { PasskeySignInSubmit } from "@/views/auth/sign-in/passkey/passkey-sign-in-button"; import type { SignUpSubmit } from "@/views/auth/sign-up/form/sign-up-form-content"; import type { SSOSelectProvider } from "@/views/auth/sso/buttons/sso-buttons-content"; import type { SSOCallbackResult } from "@/views/auth/sso/callback/sso-callback-result"; import type { SSOLinkSubmit } from "@/views/auth/sso/link/use-sso-link-form"; +import { getPasskeyInBrowser } from "@/views/auth/passkeys/webauthn"; + import type { SsoCallbackInput } from "./contract"; import { removeAdminIdentityQueries } from "../admin/queries"; @@ -63,6 +66,44 @@ export const useSignInAction = ({ }; }; +export const usePasskeySignInAction = ({ + destination, + navigate, +}: { + destination: () => string; + navigate: AuthNavigate; +}): PasskeySignInSubmit => { + const queryClient = useQueryClient(); + + return async () => { + const start = await authTransport().startPasskeySignIn(); + if (!start.ok) return start.reason; + + const ceremony = await getPasskeyInBrowser(start.options); + if (!ceremony.ok) { + return ceremony.failure === "already_registered" + ? "failed" + : ceremony.failure; + } + + const result = await authTransport().finishPasskeySignIn({ + response: ceremony.response, + }); + + if (!result.ok) { + return result.reason === "access_denied" ? "rejected" : result.reason; + } + + removeAdminIdentityQueries(queryClient); + removeUserIdentityQueries(queryClient); + + await invalidateSession(queryClient); + await navigate(destination()); + + return undefined; + }; +}; + export const startSsoAction: SSOSelectProvider = async providerId => { const result = await authTransport().startSso({ providerId }); diff --git a/packages/vitnode/src/tanstack/auth/auth-methods.test.ts b/packages/vitnode/src/tanstack/auth/auth-methods.test.ts new file mode 100644 index 000000000..c35484e09 --- /dev/null +++ b/packages/vitnode/src/tanstack/auth/auth-methods.test.ts @@ -0,0 +1,63 @@ +import { describe, expect, it } from "vitest"; + +import type { MiddlewareConfig } from "./middleware-config"; + +import { authMethodsOf, hasSignInMethod } from "./middleware-config"; + +const config = ( + overrides: Partial = {}, +): MiddlewareConfig => ({ + ai: { models: [] }, + bottomBar: [], + isEmail: true, + navigation: [], + passkeys: true, + password: true, + sso: [], + ...overrides, +}); + +const GOOGLE = { id: "google", name: "Google" }; + +describe("authMethodsOf", () => { + it("offers everything on a default install", () => { + const methods = authMethodsOf(config()); + + expect(methods).toMatchObject({ + passkey: true, + password: true, + resetPassword: true, + signUp: true, + }); + expect(hasSignInMethod(methods)).toBe(true); + }); + + it("drops the password form, reset link and sign-up when passwords are off", () => { + const methods = authMethodsOf(config({ password: false })); + + expect(methods).toMatchObject({ + password: false, + resetPassword: false, + signUp: false, + }); + expect(hasSignInMethod(methods)).toBe(true); + }); + + it("keeps sign-up when a social login can still create accounts", () => { + expect( + authMethodsOf(config({ password: false, sso: [GOOGLE] })).signUp, + ).toBe(true); + }); + + it("hides the reset link without an email adapter", () => { + expect(authMethodsOf(config({ isEmail: false })).resetPassword).toBe(false); + }); + + it("reports when no way to sign in is left", () => { + expect( + hasSignInMethod( + authMethodsOf(config({ passkeys: false, password: false })), + ), + ).toBe(false); + }); +}); diff --git a/packages/vitnode/src/tanstack/auth/contract.test.ts b/packages/vitnode/src/tanstack/auth/contract.test.ts index 0d4065c2c..f30c05d3b 100644 --- a/packages/vitnode/src/tanstack/auth/contract.test.ts +++ b/packages/vitnode/src/tanstack/auth/contract.test.ts @@ -1,10 +1,13 @@ import { describe, expect, it } from "vitest"; import { + adminPasskeySignInResultFromStatus, completeSsoResultFromStatus, isProviderRedirectUrl, isUsableSessionStatus, parseSsoCallback, + passkeySignInResultFromStatus, + passkeySignInStartResultFromStatus, providerIdSchema, shouldSaveApiCookies, signInInputSchema, @@ -39,6 +42,49 @@ describe("sign-in results", () => { ); }); +describe("passkey sign-in results", () => { + const options = { + challenge: "Y2hhbGxlbmdl", + userVerification: "required" as const, + }; + + it("hands the browser the options the API issued", () => { + expect(passkeySignInStartResultFromStatus(200, options)).toEqual({ + ok: true, + options, + }); + }); + + it("reads a 404 as passkeys being switched off", () => { + expect(passkeySignInStartResultFromStatus(404)).toEqual({ + ok: false, + reason: "unavailable", + }); + }); + + it("refuses a 200 that carries no challenge", () => { + expect( + passkeySignInStartResultFromStatus(200, { error: "invalid_challenge" }), + ).toEqual({ ok: false, reason: "server_error" }); + }); + + it("maps the verification answer", () => { + expect(passkeySignInResultFromStatus(201)).toEqual({ ok: true }); + expect(passkeySignInResultFromStatus(400)).toEqual({ + ok: false, + reason: "expired", + }); + expect(passkeySignInResultFromStatus(403)).toEqual({ + ok: false, + reason: "access_denied", + }); + expect(passkeySignInResultFromStatus(500)).toEqual({ + ok: false, + reason: "server_error", + }); + }); +}); + describe("sign-out results", () => { it("reads 200 as signed out", () => { expect(signOutResultFromStatus(200)).toEqual({ ok: true }); @@ -375,3 +421,33 @@ describe("reading a session response status", () => { }, ); }); + +describe("AdminCP passkey sign-in results", () => { + it("tells a valid passkey without staff access apart from a rejected one", () => { + expect( + adminPasskeySignInResultFromStatus(403, { error: "not_staff" }), + ).toEqual({ + ok: false, + reason: "not_staff", + }); + expect( + adminPasskeySignInResultFromStatus(403, { error: "verification_failed" }), + ).toEqual({ ok: false, reason: "access_denied" }); + expect(adminPasskeySignInResultFromStatus(403)).toEqual({ + ok: false, + reason: "access_denied", + }); + }); + + it("maps the remaining statuses like public passkey sign-in", () => { + expect(adminPasskeySignInResultFromStatus(201)).toEqual({ ok: true }); + expect(adminPasskeySignInResultFromStatus(400)).toEqual({ + ok: false, + reason: "expired", + }); + expect(adminPasskeySignInResultFromStatus(500)).toEqual({ + ok: false, + reason: "server_error", + }); + }); +}); diff --git a/packages/vitnode/src/tanstack/auth/contract.ts b/packages/vitnode/src/tanstack/auth/contract.ts index 5d2d6e945..71df8e3d7 100644 --- a/packages/vitnode/src/tanstack/auth/contract.ts +++ b/packages/vitnode/src/tanstack/auth/contract.ts @@ -1,5 +1,11 @@ import { z } from "zod"; +import type { + PasskeyErrorCode, + zodPasskeyAuthenticationOptionsSchema, + zodPasskeyAuthenticationResponseSchema, +} from "@/api/modules/users/passkeys/schema"; + import { RATE_LIMIT_STATUS } from "@/lib/fetcher/rate-limit"; import { signUpConflictReason } from "@/views/auth/sign-up/form/schema"; @@ -42,6 +48,32 @@ export type SsoCallbackInput = z.infer; export type SignInResult = { ok: false; reason: "access_denied" | "server_error" } | { ok: true }; +export type PasskeySignInOptions = z.infer< + typeof zodPasskeyAuthenticationOptionsSchema +>; + +export type PasskeySignInOptionsBody = + PasskeySignInOptions | { error: PasskeyErrorCode }; + +export interface PasskeySignInInput { + response: z.input; +} + +export type PasskeySignInStartResult = + | { ok: false; reason: "server_error" | "unavailable" } + | { ok: true; options: PasskeySignInOptions }; + +export type PasskeySignInResult = + | { ok: false; reason: "access_denied" | "expired" | "server_error" } + | { ok: true }; + +export type AdminPasskeySignInResult = + | { + ok: false; + reason: "access_denied" | "expired" | "not_staff" | "server_error"; + } + | { ok: true }; + export type SignOutResult = { ok: false; reason: "server_error" } | { ok: true }; @@ -99,6 +131,41 @@ export const signInResultFromStatus = (status: number): SignInResult => { return { ok: false, reason: "server_error" }; }; +export const passkeySignInStartResultFromStatus = ( + status: number, + body?: PasskeySignInOptionsBody, +): PasskeySignInStartResult => { + if (status === 404) return { ok: false, reason: "unavailable" }; + if (status !== 200 || !body || !("challenge" in body)) { + return { ok: false, reason: "server_error" }; + } + + return { ok: true, options: body }; +}; + +export const passkeySignInResultFromStatus = ( + status: number, +): PasskeySignInResult => { + if (status === 201) return { ok: true }; + if (status === 400) return { ok: false, reason: "expired" }; + if (status === 403) return { ok: false, reason: "access_denied" }; + + return { ok: false, reason: "server_error" }; +}; + +const notStaffBodySchema = z.object({ error: z.literal("not_staff") }); + +export const adminPasskeySignInResultFromStatus = ( + status: number, + body?: unknown, +): AdminPasskeySignInResult => { + if (status === 403 && notStaffBodySchema.safeParse(body).success) { + return { ok: false, reason: "not_staff" }; + } + + return passkeySignInResultFromStatus(status); +}; + export const signOutResultFromStatus = (status: number): SignOutResult => status === 200 ? { ok: true } : { ok: false, reason: "server_error" }; diff --git a/packages/vitnode/src/tanstack/auth/default-transport.ts b/packages/vitnode/src/tanstack/auth/default-transport.ts index dffaf3095..9cc851ced 100644 --- a/packages/vitnode/src/tanstack/auth/default-transport.ts +++ b/packages/vitnode/src/tanstack/auth/default-transport.ts @@ -32,6 +32,24 @@ const operations = createAuthOperations({ path: "/{providerId}/callback", }), + finishAdminPasskeySignIn: async data => + await fetcher({ + plugin: CONFIG_PLUGIN.pluginId, + args: { body: data }, + method: "post", + module: "users/passkeys", + path: "/admin-sign-in", + }), + + finishPasskeySignIn: async data => + await fetcher({ + plugin: CONFIG_PLUGIN.pluginId, + args: { body: data }, + method: "post", + module: "users/passkeys", + path: "/sign-in", + }), + linkSso: async data => await fetcher({ plugin: CONFIG_PLUGIN.pluginId, @@ -90,6 +108,22 @@ const operations = createAuthOperations({ path: "/sign_up", }), + startAdminPasskeySignIn: async () => + await fetcher({ + plugin: CONFIG_PLUGIN.pluginId, + method: "post", + module: "users/passkeys", + path: "/admin-sign-in/options", + }), + + startPasskeySignIn: async () => + await fetcher({ + plugin: CONFIG_PLUGIN.pluginId, + method: "post", + module: "users/passkeys", + path: "/sign-in/options", + }), + startSso: async data => await fetcher({ plugin: CONFIG_PLUGIN.pluginId, @@ -105,11 +139,15 @@ export const readSessionFromApi = operations.readSession; export const defaultAuthTransport = { changePasswordFromReset: operations.changePasswordFromReset, completeSso: operations.completeSso, + finishAdminPasskeySignIn: operations.finishAdminPasskeySignIn, + finishPasskeySignIn: operations.finishPasskeySignIn, linkSso: operations.linkSso, readSession: operations.readSession, requestPasswordReset: operations.requestPasswordReset, signIn: operations.signIn, signOut: operations.signOut, signUp: operations.signUp, + startAdminPasskeySignIn: operations.startAdminPasskeySignIn, + startPasskeySignIn: operations.startPasskeySignIn, startSso: operations.startSso, }; diff --git a/packages/vitnode/src/tanstack/auth/login-screen.tsx b/packages/vitnode/src/tanstack/auth/login-screen.tsx index 33a722ce2..046777442 100644 --- a/packages/vitnode/src/tanstack/auth/login-screen.tsx +++ b/packages/vitnode/src/tanstack/auth/login-screen.tsx @@ -1,13 +1,22 @@ import { SignInFormContent } from "@/views/auth/sign-in/form/sign-in-form-content"; +import { PasskeySignInButton } from "@/views/auth/sign-in/passkey/passkey-sign-in-button"; import { SignInContent } from "@/views/auth/sign-in/sign-in-content"; import { SSOButtonsContent } from "@/views/auth/sso/buttons/sso-buttons-content"; import type { AuthNavigate } from "./actions"; import { RouteMessages } from "../i18n/route-messages"; -import { startSsoAction, useSignInAction } from "./actions"; +import { + startSsoAction, + usePasskeySignInAction, + useSignInAction, +} from "./actions"; import { LOGIN_NAMESPACES } from "./login-route"; -import { ssoProvidersOf, useMiddlewareConfigQuery } from "./middleware-config"; +import { + authMethodsOf, + hasSignInMethod, + useMiddlewareConfigQuery, +} from "./middleware-config"; import { postAuthDestination } from "./redirects"; export interface LoginRouteProps { @@ -18,24 +27,42 @@ export interface LoginRouteProps { export const LoginRouteContent = ({ navigate, returnTo }: LoginRouteProps) => { const { data: config } = useMiddlewareConfigQuery(); + const methods = authMethodsOf(config); const signIn = useSignInAction({ destination: () => postAuthDestination(returnTo), navigate, }); + const passkeySignIn = usePasskeySignInAction({ + destination: () => postAuthDestination(returnTo), + navigate, + }); return ( + methods.password ? ( + + ) : undefined + } + isUnavailable={!hasSignInMethod(methods)} + passkey={ + methods.passkey ? ( + 0} + /> + ) : undefined } + showSignUp={methods.signUp} sso={ } /> diff --git a/packages/vitnode/src/tanstack/auth/middleware-config.ts b/packages/vitnode/src/tanstack/auth/middleware-config.ts index ec7c55cf6..1de1babb6 100644 --- a/packages/vitnode/src/tanstack/auth/middleware-config.ts +++ b/packages/vitnode/src/tanstack/auth/middleware-config.ts @@ -21,6 +21,8 @@ export const UNKNOWN_MIDDLEWARE_CONFIG: MiddlewareConfigState = Object.freeze({ isEmail: false, isKnown: false, navigation: [], + passkeys: false, + password: true, bottomBar: [], sso: [], }); @@ -72,3 +74,26 @@ export const invalidateMiddlewareConfig = async ( export const ssoProvidersOf = (config: MiddlewareConfig): SSOProvider[] => normalizeSSOProviders(config.sso); + +export interface AuthMethods { + passkey: boolean; + password: boolean; + resetPassword: boolean; + signUp: boolean; + sso: SSOProvider[]; +} + +export const authMethodsOf = (config: MiddlewareConfig): AuthMethods => { + const sso = ssoProvidersOf(config); + + return { + passkey: config.passkeys, + password: config.password, + resetPassword: config.password && config.isEmail, + signUp: config.password || sso.length > 0, + sso, + }; +}; + +export const hasSignInMethod = (methods: AuthMethods): boolean => + methods.password || methods.passkey || methods.sso.length > 0; diff --git a/packages/vitnode/src/tanstack/auth/queries.test.ts b/packages/vitnode/src/tanstack/auth/queries.test.ts index 6f9bf2854..7b95b7bdf 100644 --- a/packages/vitnode/src/tanstack/auth/queries.test.ts +++ b/packages/vitnode/src/tanstack/auth/queries.test.ts @@ -8,6 +8,10 @@ import { DEVICES_IDENTITY_ROOT, devicesQueryKey, } from "@/views/auth/settings/devices/devices-query"; +import { + PASSKEYS_IDENTITY_ROOT, + passkeysQueryKey, +} from "@/views/auth/settings/passkeys/passkeys-query"; import { MY_FILES_IDENTITY_ROOT, myFilesQueryKey, @@ -33,6 +37,8 @@ const BOB_FILES = [ ]; const ALICE_DEVICES = [...devicesQueryKey(ALICE)]; const BOB_DEVICES = [...devicesQueryKey(BOB)]; +const ALICE_PASSKEYS = [...passkeysQueryKey(ALICE)]; +const BOB_PASSKEYS = [...passkeysQueryKey(BOB)]; const SESSION = [...SESSION_QUERY_KEY]; const INTL = ["vitnode", "intl", "en", ["core.global"]]; @@ -50,6 +56,8 @@ const seeded = (): QueryClient => { BOB_FILES, ALICE_DEVICES, BOB_DEVICES, + ALICE_PASSKEYS, + BOB_PASSKEYS, SESSION, INTL, MIDDLEWARE, @@ -90,6 +98,10 @@ describe("the identity roots prefix the keys they collect", () => { expect(ALICE_DEVICES.slice(0, 2)).toEqual([...DEVICES_IDENTITY_ROOT]); }); + it("covers every visitor's passkey list", () => { + expect(ALICE_PASSKEYS.slice(0, 2)).toEqual([...PASSKEYS_IDENTITY_ROOT]); + }); + /** * And is genuinely *above* the per-owner root rather than equal to it. A * cleanup written as `myFilesQueryRoot(currentUser)` would drop the visitor @@ -126,6 +138,15 @@ describe("removeUserIdentityQueries", () => { expect(held(queryClient, BOB_DEVICES)).toBe(false); }); + it("drops every visitor's passkey list", () => { + const queryClient = seeded(); + + removeUserIdentityQueries(queryClient); + + expect(held(queryClient, ALICE_PASSKEYS)).toBe(false); + expect(held(queryClient, BOB_PASSKEYS)).toBe(false); + }); + /** * Removal, not invalidation: nothing is left for the next render to paint. * An invalidated entry keeps its value, so the previous visitor's rows would @@ -197,8 +218,8 @@ describe("every public identity boundary drops the private cache", () => { }); /** - * Five call sites, one per flow that can change who is at the keyboard: a - * sign-in, a finished SSO exchange, an SSO identity linked with a password + * Six call sites, one per flow that can change who is at the keyboard: a + * sign-in, a passkey sign-in, a finished SSO exchange, an SSO identity linked with a password * (which mints a session too), a sign-out and a *verified* sign-up. * * An unverified sign-up is deliberately not one - no session was minted, so @@ -206,8 +227,8 @@ describe("every public identity boundary drops the private cache", () => { * password-reset request, which mints nothing and leaves the visitor exactly * who they were. */ - it("runs on all five, and only those five", () => { - expect(actionsSource().split(CLEANUP).length - 1).toBe(5); + it("runs on all six, and only those six", () => { + expect(actionsSource().split(CLEANUP).length - 1).toBe(6); }); /** @@ -219,7 +240,7 @@ describe("every public identity boundary drops the private cache", () => { expect( code.split("removeAdminIdentityQueries(queryClient)").length - 1, - ).toBe(5); + ).toBe(6); }); it("clears before it navigates", () => { diff --git a/packages/vitnode/src/tanstack/auth/queries.ts b/packages/vitnode/src/tanstack/auth/queries.ts index d698752da..d8a29bb16 100644 --- a/packages/vitnode/src/tanstack/auth/queries.ts +++ b/packages/vitnode/src/tanstack/auth/queries.ts @@ -1,9 +1,11 @@ import type { QueryClient } from "@tanstack/react-query"; import { DEVICES_IDENTITY_ROOT } from "@/views/auth/settings/devices/devices-query"; +import { PASSKEYS_IDENTITY_ROOT } from "@/views/auth/settings/passkeys/passkeys-query"; import { MY_FILES_IDENTITY_ROOT } from "@/views/files/my-files-query"; export const removeUserIdentityQueries = (queryClient: QueryClient): void => { queryClient.removeQueries({ queryKey: MY_FILES_IDENTITY_ROOT }); queryClient.removeQueries({ queryKey: DEVICES_IDENTITY_ROOT }); + queryClient.removeQueries({ queryKey: PASSKEYS_IDENTITY_ROOT }); }; diff --git a/packages/vitnode/src/tanstack/auth/recovery.test.ts b/packages/vitnode/src/tanstack/auth/recovery.test.ts index fe4515f1d..ea48f82f4 100644 --- a/packages/vitnode/src/tanstack/auth/recovery.test.ts +++ b/packages/vitnode/src/tanstack/auth/recovery.test.ts @@ -172,11 +172,29 @@ describe("the namespaces each recovery screen needs", () => { */ describe("whether this deployment has password recovery at all", () => { it("follows the email adapter when the configuration was read", () => { - expect(passwordRecoveryAvailability({ isEmail: true, isKnown: true })).toBe( - "available", - ); expect( - passwordRecoveryAvailability({ isEmail: false, isKnown: true }), + passwordRecoveryAvailability({ + isEmail: true, + isKnown: true, + password: true, + }), + ).toBe("available"); + expect( + passwordRecoveryAvailability({ + isEmail: false, + isKnown: true, + password: true, + }), + ).toBe("disabled"); + }); + + it("is off when password sign-in is switched off, email or not", () => { + expect( + passwordRecoveryAvailability({ + isEmail: true, + isKnown: true, + password: false, + }), ).toBe("disabled"); }); @@ -200,7 +218,11 @@ describe("whether this deployment has password recovery at all", () => { // `isKnown` decides on its own: even were the fallback to start guessing // `isEmail: true`, an unread configuration still may not answer "available". expect( - passwordRecoveryAvailability({ isEmail: true, isKnown: false }), + passwordRecoveryAvailability({ + isEmail: true, + isKnown: false, + password: true, + }), ).toBe("unknown"); }); @@ -211,6 +233,8 @@ describe("whether this deployment has password recovery at all", () => { knownMiddlewareConfig({ ai: { models: [] }, isEmail: false, + passkeys: false, + password: true, navigation: [], bottomBar: [], sso: [], @@ -221,6 +245,8 @@ describe("whether this deployment has password recovery at all", () => { knownMiddlewareConfig({ ai: { models: [] }, isEmail: false, + passkeys: false, + password: true, navigation: [], bottomBar: [], sso: [], diff --git a/packages/vitnode/src/tanstack/auth/recovery.ts b/packages/vitnode/src/tanstack/auth/recovery.ts index f15dbb086..2e5fd04ff 100644 --- a/packages/vitnode/src/tanstack/auth/recovery.ts +++ b/packages/vitnode/src/tanstack/auth/recovery.ts @@ -63,13 +63,15 @@ export type PasswordRecoveryAvailability = "available" | "disabled" | "unknown"; export const passwordRecoveryAvailability = ({ isEmail, isKnown, + password, }: { isEmail: boolean; isKnown: boolean; + password: boolean; }): PasswordRecoveryAvailability => { if (!isKnown) return "unknown"; - return isEmail ? "available" : "disabled"; + return isEmail && password ? "available" : "disabled"; }; export class PasswordRecoveryUnknownError extends Error { diff --git a/packages/vitnode/src/tanstack/auth/register-screen.tsx b/packages/vitnode/src/tanstack/auth/register-screen.tsx index 61c9d03f4..949df3aad 100644 --- a/packages/vitnode/src/tanstack/auth/register-screen.tsx +++ b/packages/vitnode/src/tanstack/auth/register-screen.tsx @@ -6,7 +6,7 @@ import type { AuthNavigate } from "./actions"; import { RouteMessages } from "../i18n/route-messages"; import { startSsoAction, useSignUpAction } from "./actions"; -import { ssoProvidersOf, useMiddlewareConfigQuery } from "./middleware-config"; +import { authMethodsOf, useMiddlewareConfigQuery } from "./middleware-config"; import { postAuthDestination } from "./redirects"; import { REGISTER_NAMESPACES } from "./register-route"; @@ -16,6 +16,7 @@ export interface RegisterRouteProps { export const RegisterRouteContent = ({ navigate }: RegisterRouteProps) => { const { data: config } = useMiddlewareConfigQuery(); + const methods = authMethodsOf(config); const signUp = useSignUpAction({ destination: () => postAuthDestination(undefined), navigate, @@ -25,16 +26,20 @@ export const RegisterRouteContent = ({ navigate }: RegisterRouteProps) => { + methods.password ? ( + + ) : undefined } + isUnavailable={!methods.signUp} sso={ } /> diff --git a/packages/vitnode/src/tanstack/auth/server.ts b/packages/vitnode/src/tanstack/auth/server.ts index 8cddd2971..843ab227f 100644 --- a/packages/vitnode/src/tanstack/auth/server.ts +++ b/packages/vitnode/src/tanstack/auth/server.ts @@ -9,7 +9,8 @@ import { createAuthOperations } from "./transport-operations"; * The server's own transport: the server fetcher, and the cookie relay. * * `allowSaveCookies: true` on exactly the flows whose answer carries the - * session cookie - sign-in, sign-out, the three SSO steps and sign-up. A + * session cookie - sign-in, every passkey sign-in step, sign-out, the three SSO + * steps and sign-up. A * password reset request and a token-based password change never mint a * session, so neither has ever relayed a cookie and neither does here. */ @@ -36,6 +37,26 @@ const operations = createAuthOperations({ path: "/{providerId}/callback", }), + finishAdminPasskeySignIn: async data => + await fetcher({ + plugin: CONFIG_PLUGIN.pluginId, + allowSaveCookies: true, + args: { body: data }, + method: "post", + module: "users/passkeys", + path: "/admin-sign-in", + }), + + finishPasskeySignIn: async data => + await fetcher({ + plugin: CONFIG_PLUGIN.pluginId, + allowSaveCookies: true, + args: { body: data }, + method: "post", + module: "users/passkeys", + path: "/sign-in", + }), + linkSso: async data => await fetcher({ plugin: CONFIG_PLUGIN.pluginId, @@ -98,6 +119,24 @@ const operations = createAuthOperations({ path: "/sign_up", }), + startAdminPasskeySignIn: async () => + await fetcher({ + plugin: CONFIG_PLUGIN.pluginId, + allowSaveCookies: true, + method: "post", + module: "users/passkeys", + path: "/admin-sign-in/options", + }), + + startPasskeySignIn: async () => + await fetcher({ + plugin: CONFIG_PLUGIN.pluginId, + allowSaveCookies: true, + method: "post", + module: "users/passkeys", + path: "/sign-in/options", + }), + startSso: async data => await fetcher({ plugin: CONFIG_PLUGIN.pluginId, @@ -111,10 +150,15 @@ const operations = createAuthOperations({ export const changePasswordFromResetOnApi = operations.changePasswordFromReset; export const completeSsoOnApi = operations.completeSso; +export const finishAdminPasskeySignInOnApi = + operations.finishAdminPasskeySignIn; +export const finishPasskeySignInOnApi = operations.finishPasskeySignIn; export const linkSsoOnApi = operations.linkSso; export const readSessionOnApi = operations.readSession; export const requestPasswordResetOnApi = operations.requestPasswordReset; export const signInOnApi = operations.signIn; export const signOutOnApi = operations.signOut; export const signUpOnApi = operations.signUp; +export const startAdminPasskeySignInOnApi = operations.startAdminPasskeySignIn; +export const startPasskeySignInOnApi = operations.startPasskeySignIn; export const startSsoOnApi = operations.startSso; diff --git a/packages/vitnode/src/tanstack/auth/session-query.test.ts b/packages/vitnode/src/tanstack/auth/session-query.test.ts index 6ae635896..9e8a095f3 100644 --- a/packages/vitnode/src/tanstack/auth/session-query.test.ts +++ b/packages/vitnode/src/tanstack/auth/session-query.test.ts @@ -22,6 +22,8 @@ const unreachable = () => { setAuthTransport({ changePasswordFromReset: unreachable, completeSso: unreachable, + finishAdminPasskeySignIn: unreachable, + finishPasskeySignIn: unreachable, linkSso: unreachable, readSession: async () => { reads += 1; @@ -34,6 +36,8 @@ setAuthTransport({ signIn: unreachable, signOut: unreachable, signUp: unreachable, + startAdminPasskeySignIn: unreachable, + startPasskeySignIn: unreachable, startSso: unreachable, }); diff --git a/packages/vitnode/src/tanstack/auth/transport-operations.test.ts b/packages/vitnode/src/tanstack/auth/transport-operations.test.ts new file mode 100644 index 000000000..54d4fe4f9 --- /dev/null +++ b/packages/vitnode/src/tanstack/auth/transport-operations.test.ts @@ -0,0 +1,100 @@ +import { describe, expect, it, vi } from "vitest"; + +import type { AuthApiRequester } from "./transport-operations"; + +import { createAuthOperations } from "./transport-operations"; + +const OPTIONS = { + challenge: "Y2hhbGxlbmdl", + userVerification: "required" as const, +}; + +const RESPONSE = { + clientExtensionResults: {}, + id: "Y3JlZA", + rawId: "Y3JlZA", + response: { + authenticatorData: "ZGF0YQ", + clientDataJSON: "Y2xpZW50", + signature: "c2ln", + }, + type: "public-key" as const, +}; + +const unreachable = () => { + throw new Error("not part of this suite"); +}; + +const requester = ( + overrides: Partial>, +): AuthApiRequester => ({ + changePasswordFromReset: unreachable, + completeSso: unreachable, + finishAdminPasskeySignIn: unreachable, + finishPasskeySignIn: unreachable, + linkSso: unreachable, + readSession: unreachable, + requestPasswordReset: unreachable, + signIn: unreachable, + signOut: unreachable, + signUp: unreachable, + startAdminPasskeySignIn: unreachable, + startPasskeySignIn: unreachable, + startSso: unreachable, + ...overrides, +}); + +const answer = (status: number, body: TBody) => ({ + json: async () => Promise.resolve(body), + status, +}); + +describe("AdminCP passkey sign-in operations", () => { + it("starts on the AdminCP route, not the public one", async () => { + const startAdminPasskeySignIn = vi.fn(async () => + Promise.resolve(answer(200, OPTIONS)), + ); + const operations = createAuthOperations( + requester({ startAdminPasskeySignIn }), + ); + + expect(await operations.startAdminPasskeySignIn()).toEqual({ + ok: true, + options: OPTIONS, + }); + expect(startAdminPasskeySignIn).toHaveBeenCalledOnce(); + }); + + it("reads the refusal body to tell a missing staff grant apart", async () => { + const operations = createAuthOperations( + requester({ + finishAdminPasskeySignIn: async () => + Promise.resolve(answer(403, { error: "not_staff" })), + }), + ); + + expect( + await operations.finishAdminPasskeySignIn({ response: RESPONSE }), + ).toEqual({ ok: false, reason: "not_staff" }); + }); + + it("answers server_error instead of throwing when the API is unreachable", async () => { + vi.spyOn(console, "error").mockImplementation(() => undefined); + const operations = createAuthOperations( + requester({ + finishAdminPasskeySignIn: async () => + Promise.reject(new Error("offline")), + startAdminPasskeySignIn: async () => + Promise.reject(new Error("offline")), + }), + ); + + expect(await operations.startAdminPasskeySignIn()).toEqual({ + ok: false, + reason: "server_error", + }); + expect( + await operations.finishAdminPasskeySignIn({ response: RESPONSE }), + ).toEqual({ ok: false, reason: "server_error" }); + }); +}); diff --git a/packages/vitnode/src/tanstack/auth/transport-operations.ts b/packages/vitnode/src/tanstack/auth/transport-operations.ts index e2641fe12..e180dcdb8 100644 --- a/packages/vitnode/src/tanstack/auth/transport-operations.ts +++ b/packages/vitnode/src/tanstack/auth/transport-operations.ts @@ -1,7 +1,12 @@ import type { + AdminPasskeySignInResult, ChangePasswordInput, ChangePasswordResult, CompleteSsoResult, + PasskeySignInInput, + PasskeySignInOptionsBody, + PasskeySignInResult, + PasskeySignInStartResult, PasswordResetRequestInput, PasswordResetRequestResult, SignInInput, @@ -19,9 +24,12 @@ import type { import { callUsersApi, readJson, readText } from "./api-helpers"; import { + adminPasskeySignInResultFromStatus, changePasswordResultFromStatus, completeSsoResultFromStatus, isUsableSessionStatus, + passkeySignInResultFromStatus, + passkeySignInStartResultFromStatus, passwordResetRequestResultFromStatus, SESSION_UNAVAILABLE, signInResultFromStatus, @@ -45,7 +53,7 @@ export interface AuthApiJsonOrText extends AuthApiJson { } /** - * The nine calls an auth transport makes, as requests rather than as results. + * The thirteen calls an auth transport makes, as requests rather than as results. * * A requester says *where* a call goes and *how* it travels - which fetcher, * and whether the answer's cookies may be relayed. Everything after the answer @@ -62,6 +70,10 @@ export interface AuthApiRequester { input: ChangePasswordInput, ) => Promise; completeSso: (input: SsoCallbackInput) => Promise>; + finishAdminPasskeySignIn: ( + input: PasskeySignInInput, + ) => Promise>; + finishPasskeySignIn: (input: PasskeySignInInput) => Promise; linkSso: (input: SsoLinkInput) => Promise; readSession: () => Promise>; requestPasswordReset: ( @@ -70,6 +82,8 @@ export interface AuthApiRequester { signIn: (input: SignInInput) => Promise; signOut: (input: SignOutInput) => Promise; signUp: (input: SignUpInput) => Promise>; + startAdminPasskeySignIn: () => Promise>; + startPasskeySignIn: () => Promise>; startSso: (input: SsoStartInput) => Promise>; } @@ -78,6 +92,12 @@ export interface AuthOperations { input: ChangePasswordInput, ) => Promise; completeSso: (input: SsoCallbackInput) => Promise; + finishAdminPasskeySignIn: ( + input: PasskeySignInInput, + ) => Promise; + finishPasskeySignIn: ( + input: PasskeySignInInput, + ) => Promise; linkSso: (input: SsoLinkInput) => Promise; readSession: () => Promise; requestPasswordReset: ( @@ -86,12 +106,29 @@ export interface AuthOperations { signIn: (input: SignInInput) => Promise; signOut: (input: SignOutInput) => Promise; signUp: (input: SignUpInput) => Promise; + startAdminPasskeySignIn: () => Promise; + startPasskeySignIn: () => Promise; startSso: (input: SsoStartInput) => Promise; } /** The `url` a start answer carries, with no assumption that it carries one. */ const startUrlOf = (body: { url?: unknown }): unknown => body.url; +const startPasskeyCeremony = async ( + call: () => Promise>, +): Promise => { + const response = await callUsersApi(call); + + if (!response) return { ok: false, reason: "server_error" }; + if (response.status !== 200) { + return passkeySignInStartResultFromStatus(response.status); + } + + const body = await callUsersApi(async () => response.json()); + + return passkeySignInStartResultFromStatus(response.status, body ?? undefined); +}; + export const createAuthOperations = ( request: AuthApiRequester, ): AuthOperations => ({ @@ -117,6 +154,29 @@ export const createAuthOperations = ( return completeSsoResultFromStatus(response.status); }, + finishAdminPasskeySignIn: async data => { + const response = await callUsersApi(async () => + request.finishAdminPasskeySignIn(data), + ); + + if (!response) return { ok: false, reason: "server_error" }; + if (response.status === 403) { + return adminPasskeySignInResultFromStatus(403, await readJson(response)); + } + + return adminPasskeySignInResultFromStatus(response.status); + }, + + finishPasskeySignIn: async data => { + const response = await callUsersApi(async () => + request.finishPasskeySignIn(data), + ); + + if (!response) return { ok: false, reason: "server_error" }; + + return passkeySignInResultFromStatus(response.status); + }, + linkSso: async data => { const response = await callUsersApi(async () => request.linkSso(data)); @@ -185,6 +245,12 @@ export const createAuthOperations = ( return signUpResultFromStatus(response.status); }, + startAdminPasskeySignIn: async () => + await startPasskeyCeremony(async () => request.startAdminPasskeySignIn()), + + startPasskeySignIn: async () => + await startPasskeyCeremony(async () => request.startPasskeySignIn()), + startSso: async data => { const response = await callUsersApi(async () => request.startSso(data)); diff --git a/packages/vitnode/src/tanstack/auth/transport.test.ts b/packages/vitnode/src/tanstack/auth/transport.test.ts index d1b93e15e..f5f10e40c 100644 --- a/packages/vitnode/src/tanstack/auth/transport.test.ts +++ b/packages/vitnode/src/tanstack/auth/transport.test.ts @@ -18,12 +18,16 @@ const unreachable = () => { const stub: AuthTransport = { changePasswordFromReset: unreachable, completeSso: unreachable, + finishAdminPasskeySignIn: unreachable, + finishPasskeySignIn: unreachable, linkSso: unreachable, readSession: unreachable, requestPasswordReset: unreachable, signIn: unreachable, signOut: unreachable, signUp: unreachable, + startAdminPasskeySignIn: unreachable, + startPasskeySignIn: unreachable, startSso: unreachable, }; diff --git a/packages/vitnode/src/tanstack/auth/transport.ts b/packages/vitnode/src/tanstack/auth/transport.ts index ee13590e1..09caddf04 100644 --- a/packages/vitnode/src/tanstack/auth/transport.ts +++ b/packages/vitnode/src/tanstack/auth/transport.ts @@ -1,7 +1,11 @@ import type { + AdminPasskeySignInResult, ChangePasswordInput, ChangePasswordResult, CompleteSsoResult, + PasskeySignInInput, + PasskeySignInResult, + PasskeySignInStartResult, PasswordResetRequestInput, PasswordResetRequestResult, SignInInput, @@ -25,6 +29,12 @@ export interface AuthTransport { input: ChangePasswordInput, ) => Promise; completeSso: (input: SsoCallbackInput) => Promise; + finishAdminPasskeySignIn: ( + input: PasskeySignInInput, + ) => Promise; + finishPasskeySignIn: ( + input: PasskeySignInInput, + ) => Promise; linkSso: (input: SsoLinkInput) => Promise; readSession: () => Promise; @@ -34,6 +44,8 @@ export interface AuthTransport { signIn: (input: SignInInput) => Promise; signOut: (input: SignOutInput) => Promise; signUp: (input: SignUpInput) => Promise; + startAdminPasskeySignIn: () => Promise; + startPasskeySignIn: () => Promise; startSso: (input: SsoStartInput) => Promise; } diff --git a/packages/vitnode/src/tanstack/passkeys/index.ts b/packages/vitnode/src/tanstack/passkeys/index.ts new file mode 100644 index 000000000..def6e376d --- /dev/null +++ b/packages/vitnode/src/tanstack/passkeys/index.ts @@ -0,0 +1,22 @@ +export { PasskeysPanelContent, PasskeysPanelPending } from "./panel"; + +export * from "./query"; + +export type { + AddPasskey, + AddPasskeyResult, + DeletePasskey, + DeletePasskeyResult, + RenamePasskey, + RenamePasskeyResult, +} from "@/views/auth/settings/passkeys/passkeys-mutations"; +export { + isPasskeysRequestError, + passkeysQueryKey, + passkeysQueryOptions, + PasskeysRequestError, +} from "@/views/auth/settings/passkeys/passkeys-query"; +export type { + Passkey, + PasskeysApi, +} from "@/views/auth/settings/passkeys/passkeys-query"; diff --git a/packages/vitnode/src/tanstack/passkeys/panel.tsx b/packages/vitnode/src/tanstack/passkeys/panel.tsx new file mode 100644 index 000000000..33b1e0411 --- /dev/null +++ b/packages/vitnode/src/tanstack/passkeys/panel.tsx @@ -0,0 +1,24 @@ +import { useSuspenseQuery } from "@tanstack/react-query"; + +import { PasskeysContent } from "@/views/auth/settings/passkeys/passkeys-content"; +import { PasskeysListSkeleton } from "@/views/auth/settings/passkeys/passkeys-list-skeleton"; +import { passkeysQueryOptions } from "@/views/auth/settings/passkeys/passkeys-query"; + +import { useMiddlewareConfigQuery } from "../auth/middleware-config"; +import { usePasskeyActions } from "./query"; + +export const PasskeysPanelPending = PasskeysListSkeleton; + +export const PasskeysPanelContent = ({ userId }: { userId: number }) => { + const { data } = useSuspenseQuery(passkeysQueryOptions({ userId })); + const { data: config } = useMiddlewareConfigQuery(); + const actions = usePasskeyActions(userId); + + return ( + + ); +}; diff --git a/packages/vitnode/src/tanstack/passkeys/query.ts b/packages/vitnode/src/tanstack/passkeys/query.ts new file mode 100644 index 000000000..c1eefc6e2 --- /dev/null +++ b/packages/vitnode/src/tanstack/passkeys/query.ts @@ -0,0 +1,68 @@ +import type { QueryClient } from "@tanstack/react-query"; + +import { useQueryClient } from "@tanstack/react-query"; +import React from "react"; + +import type { + AddPasskey, + DeletePasskey, + RenamePasskey, +} from "@/views/auth/settings/passkeys/passkeys-mutations"; + +import { + addPasskeyInBrowser, + deletePasskeyInBrowser, + renamePasskeyInBrowser, +} from "@/views/auth/settings/passkeys/passkeys-mutations"; +import { passkeysQueryKey } from "@/views/auth/settings/passkeys/passkeys-query"; + +export const invalidatePasskeys = async ( + queryClient: QueryClient, + userId: number, +): Promise => + await queryClient.invalidateQueries({ queryKey: passkeysQueryKey(userId) }); + +export interface PasskeyActions { + onAdd: AddPasskey; + onDelete: DeletePasskey; + onRename: RenamePasskey; +} + +export const createPasskeyActions = ( + queryClient: QueryClient, + userId: number, +): PasskeyActions => ({ + onAdd: async () => { + const result = await addPasskeyInBrowser(); + if (result.ok || result.failure === "already_registered") { + await invalidatePasskeys(queryClient, userId); + } + + return result; + }, + onDelete: async args => { + const result = await deletePasskeyInBrowser(args); + if (result.ok || result.failure === "not_found") { + await invalidatePasskeys(queryClient, userId); + } + + return result; + }, + onRename: async args => { + const result = await renamePasskeyInBrowser(args); + if (result.ok || result.failure === "not_found") { + await invalidatePasskeys(queryClient, userId); + } + + return result; + }, +}); + +export const usePasskeyActions = (userId: number): PasskeyActions => { + const queryClient = useQueryClient(); + + return React.useMemo( + () => createPasskeyActions(queryClient, userId), + [queryClient, userId], + ); +}; diff --git a/packages/vitnode/src/tanstack/plugin-routes/authoring.ts b/packages/vitnode/src/tanstack/plugin-routes/authoring.ts index 17727c13e..c6008a941 100644 --- a/packages/vitnode/src/tanstack/plugin-routes/authoring.ts +++ b/packages/vitnode/src/tanstack/plugin-routes/authoring.ts @@ -3,7 +3,6 @@ import type { QueryClient } from "@tanstack/react-query"; import type { AuthoredPluginRouteOptions, PluginRouteBreadcrumbGroup, - PluginRouteBreadcrumbProps, PluginRouteContext, PluginRouteOptions, } from "@/routing"; diff --git a/packages/vitnode/src/tanstack/plugin-routes/mount.tsx b/packages/vitnode/src/tanstack/plugin-routes/mount.tsx index 6d0993b1f..47427cbc4 100644 --- a/packages/vitnode/src/tanstack/plugin-routes/mount.tsx +++ b/packages/vitnode/src/tanstack/plugin-routes/mount.tsx @@ -14,7 +14,6 @@ import { PLUGIN_ROUTE_AREAS } from "@/routing"; import type { RouteHeadOptions, RouteHeadResult } from "../metadata"; import type { PluginRouteLoaderData } from "./loader-data"; -import type { PluginRouteModuleRef } from "./module-ref"; import type { PluginRouteSpec } from "./specs"; // Loaded for its `declare module` augmentation, which is what puts `breadcrumb` diff --git a/packages/vitnode/src/tanstack/settings/layout.tsx b/packages/vitnode/src/tanstack/settings/layout.tsx index d0ffb600e..b408ceb19 100644 --- a/packages/vitnode/src/tanstack/settings/layout.tsx +++ b/packages/vitnode/src/tanstack/settings/layout.tsx @@ -1,12 +1,14 @@ import { useRouterState } from "@tanstack/react-router"; import { SettingsNavContent } from "@/views/auth/settings/nav-content"; +import { visibleSettingsNavItems } from "@/views/auth/settings/settings-nav"; import { SettingsShellContent } from "@/views/auth/settings/shell-content"; import { SETTINGS_ZONE_IDS, settingsPage, } from "@/views/auth/settings/widgets/settings-page"; +import { useMiddlewareConfigQuery } from "../auth/middleware-config"; import { RouteMessages } from "../i18n/route-messages"; import { PageWidgets, PageWidgetsZone } from "../widgets"; import { SETTINGS_NAMESPACES } from "./route"; @@ -17,6 +19,7 @@ export const SettingsLayoutContent = ({ children: React.ReactNode; }) => { const pathname = useRouterState({ select: state => state.location.pathname }); + const { data: config } = useMiddlewareConfigQuery(); return ( @@ -24,7 +27,12 @@ export const SettingsLayoutContent = ({ } header={} - nav={} + nav={ + + } pathname={pathname} > {children} diff --git a/packages/vitnode/src/tests/passkey-store.ts b/packages/vitnode/src/tests/passkey-store.ts new file mode 100644 index 000000000..820130cfe --- /dev/null +++ b/packages/vitnode/src/tests/passkey-store.ts @@ -0,0 +1,134 @@ +import type { + PasskeyChallengeRecord, + PasskeyRecord, + PasskeyStore, +} from "@/api/models/passkey-store"; + +export interface MemoryPasskeyAccount { + hasPassword: boolean; + ssoAccounts: number; +} + +export const createMemoryPasskeyStore = ( + accounts: Record = {}, +) => { + const challenges = new Map(); + const passkeys = new Map(); + let nextId = 1; + + const store: PasskeyStore = { + consumeChallenge: async ({ ceremony, now, tokenHash, userId }) => { + const row = challenges.get(tokenHash); + const matches = + row?.ceremony === ceremony && + row.expiresAt > now && + row.userId === userId; + if (!matches) return Promise.resolve(null); + challenges.delete(tokenHash); + + return Promise.resolve(row); + }, + + createPasskey: async values => { + const duplicate = [...passkeys.values()].some( + passkey => passkey.credentialId === values.credentialId, + ); + if (duplicate) return Promise.resolve(null); + + const now = new Date(); + const row: PasskeyRecord = { + ...values, + createdAt: now, + id: nextId++, + lastUsedAt: null, + updatedAt: now, + }; + passkeys.set(row.id, row); + + return Promise.resolve(row); + }, + + deleteChallenge: async tokenHash => { + challenges.delete(tokenHash); + await Promise.resolve(); + }, + + deleteExpiredChallenges: async now => { + for (const [key, row] of challenges) { + if (row.expiresAt <= now) challenges.delete(key); + } + await Promise.resolve(); + }, + + deletePasskey: async ({ canDelete, id, userId }) => { + const passkey = passkeys.get(id); + if (passkey?.userId !== userId) return Promise.resolve("not_found"); + + const account = accounts[userId] ?? { + hasPassword: false, + ssoAccounts: 0, + }; + const otherPasskeys = [...passkeys.values()].filter( + other => other.userId === userId && other.id !== id, + ).length; + + if (!canDelete({ ...account, otherPasskeys })) { + return Promise.resolve("blocked"); + } + passkeys.delete(id); + + return Promise.resolve("deleted"); + }, + + findPasskeyByCredentialId: async credentialId => + Promise.resolve( + [...passkeys.values()].find( + passkey => passkey.credentialId === credentialId, + ) ?? null, + ), + + listPasskeys: async userId => + Promise.resolve( + [...passkeys.values()].filter(passkey => passkey.userId === userId), + ), + + recordSignIn: async ({ + backedUp, + counter, + deviceType, + id, + previousCounter, + usedAt, + }) => { + const passkey = passkeys.get(id); + if (passkey?.counter !== previousCounter) return Promise.resolve(false); + + passkeys.set(id, { + ...passkey, + backedUp, + counter, + deviceType, + lastUsedAt: usedAt, + }); + + return Promise.resolve(true); + }, + + renamePasskey: async ({ id, name, userId }) => { + const passkey = passkeys.get(id); + if (passkey?.userId !== userId) return Promise.resolve(null); + + const renamed = { ...passkey, name, updatedAt: new Date() }; + passkeys.set(id, renamed); + + return Promise.resolve(renamed); + }, + + saveChallenge: async values => { + challenges.set(values.tokenHash, values); + await Promise.resolve(); + }, + }; + + return { challenges, passkeys, store }; +}; diff --git a/packages/vitnode/src/tests/webauthn.ts b/packages/vitnode/src/tests/webauthn.ts new file mode 100644 index 000000000..920fe1b83 --- /dev/null +++ b/packages/vitnode/src/tests/webauthn.ts @@ -0,0 +1,205 @@ +import type { + AuthenticationResponseJSON, + RegistrationResponseJSON, +} from "@simplewebauthn/server"; + +import { isoBase64URL, isoCBOR } from "@simplewebauthn/server/helpers"; +import { + createHash, + generateKeyPairSync, + randomBytes, + sign, +} from "node:crypto"; + +const FLAG_USER_PRESENT = 0x01; +const FLAG_USER_VERIFIED = 0x04; +const FLAG_BACKUP_ELIGIBLE = 0x08; +const FLAG_BACKED_UP = 0x10; +const FLAG_ATTESTED_CREDENTIAL = 0x40; + +const COSE_KTY_EC2 = 2; +const COSE_ALG_ES256 = -7; +const COSE_CRV_P256 = 1; + +type Bytes = Uint8Array; + +const sha256 = (data: Uint8Array): Bytes => + new Uint8Array(createHash("sha256").update(data).digest()); + +const concat = (...parts: Uint8Array[]): Bytes => { + const out = new Uint8Array( + parts.reduce((size, part) => size + part.length, 0), + ); + let offset = 0; + for (const part of parts) { + out.set(part, offset); + offset += part.length; + } + + return out; +}; + +const uint32 = (value: number): Bytes => { + const out = new Uint8Array(4); + new DataView(out.buffer).setUint32(0, value); + + return out; +}; + +const uint16 = (value: number): Bytes => { + const out = new Uint8Array(2); + new DataView(out.buffer).setUint16(0, value); + + return out; +}; + +const encodeJson = (value: unknown): Bytes => + new TextEncoder().encode(JSON.stringify(value)); + +export interface CeremonyOverrides { + challenge?: string; + counter?: number; + origin?: string; + rpId?: string; + userHandle?: null | string; + userVerified?: boolean; +} + +export const createSoftwareAuthenticator = ({ + aaguid = "00000000-0000-0000-0000-000000000000", + origin, + rpId, +}: { + aaguid?: string; + origin: string; + rpId: string; +}) => { + const { privateKey, publicKey } = generateKeyPairSync("ec", { + namedCurve: "P-256", + }); + const jwk = publicKey.export({ format: "jwk" }); + const credentialId = new Uint8Array(randomBytes(32)); + const aaguidBytes = new Uint8Array( + Buffer.from(aaguid.replaceAll("-", ""), "hex"), + ); + let counter = 0; + let userHandle: string | undefined; + + const cosePublicKey = isoCBOR.encode( + new Map([ + [-3, isoBase64URL.toBuffer(jwk.y ?? "")], + [-2, isoBase64URL.toBuffer(jwk.x ?? "")], + [-1, COSE_CRV_P256], + [1, COSE_KTY_EC2], + [3, COSE_ALG_ES256], + ]), + ); + + const flags = (userVerified: boolean, extra = 0) => + FLAG_USER_PRESENT | + (userVerified ? FLAG_USER_VERIFIED : 0) | + FLAG_BACKUP_ELIGIBLE | + FLAG_BACKED_UP | + extra; + + const id = isoBase64URL.fromBuffer(credentialId); + + return { + credentialId: id, + + setCounter: (value: number) => { + counter = value; + }, + + createCredential: ({ + challenge, + overrides = {}, + userId, + }: { + challenge: string; + overrides?: CeremonyOverrides; + userId: string; + }): RegistrationResponseJSON => { + userHandle = userId; + const clientDataJSON = encodeJson({ + challenge: overrides.challenge ?? challenge, + crossOrigin: false, + origin: overrides.origin ?? origin, + type: "webauthn.create", + }); + const authData = concat( + sha256(new TextEncoder().encode(overrides.rpId ?? rpId)), + new Uint8Array([ + flags(overrides.userVerified ?? true, FLAG_ATTESTED_CREDENTIAL), + ]), + uint32(overrides.counter ?? counter), + aaguidBytes, + uint16(credentialId.length), + credentialId, + cosePublicKey, + ); + const attestationObject = isoCBOR.encode( + new Map | string | Uint8Array>([ + ["attStmt", new Map()], + ["authData", authData], + ["fmt", "none"], + ]), + ); + + return { + clientExtensionResults: {}, + id, + rawId: id, + response: { + attestationObject: isoBase64URL.fromBuffer(attestationObject), + clientDataJSON: isoBase64URL.fromBuffer(clientDataJSON), + transports: ["internal", "hybrid"], + }, + type: "public-key", + }; + }, + + getAssertion: ({ + challenge, + overrides = {}, + }: { + challenge: string; + overrides?: CeremonyOverrides; + }): AuthenticationResponseJSON => { + if (overrides.counter === undefined && counter > 0) counter += 1; + const clientDataJSON = encodeJson({ + challenge: overrides.challenge ?? challenge, + crossOrigin: false, + origin: overrides.origin ?? origin, + type: "webauthn.get", + }); + const authenticatorData = concat( + sha256(new TextEncoder().encode(overrides.rpId ?? rpId)), + new Uint8Array([flags(overrides.userVerified ?? true)]), + uint32(overrides.counter ?? counter), + ); + const signature = sign( + "sha256", + concat(authenticatorData, sha256(clientDataJSON)), + privateKey, + ); + const handle = + overrides.userHandle === null + ? undefined + : (overrides.userHandle ?? userHandle); + + return { + clientExtensionResults: {}, + id, + rawId: id, + response: { + authenticatorData: isoBase64URL.fromBuffer(authenticatorData), + clientDataJSON: isoBase64URL.fromBuffer(clientDataJSON), + signature: isoBase64URL.fromBuffer(new Uint8Array(signature)), + ...(handle === undefined ? {} : { userHandle: handle }), + }, + type: "public-key", + }; + }, + }; +}; diff --git a/packages/vitnode/src/views/admin/sign-in/sign-in-admin-content.tsx b/packages/vitnode/src/views/admin/sign-in/sign-in-admin-content.tsx index 27aac4844..f1f95135e 100644 --- a/packages/vitnode/src/views/admin/sign-in/sign-in-admin-content.tsx +++ b/packages/vitnode/src/views/admin/sign-in/sign-in-admin-content.tsx @@ -1,9 +1,18 @@ import { LogoVitNode } from "@/components/logo-vitnode"; import { Card } from "@/components/ui/card"; -export const SignInAdminContent = ({ form }: { form: React.ReactNode }) => ( +export const SignInAdminContent = ({ + form, + passkey, +}: { + form: React.ReactNode; + passkey?: React.ReactNode; +}) => (
- {form} + + {form} + {passkey} +
); diff --git a/packages/vitnode/src/views/admin/views/content/table/list-query.ts b/packages/vitnode/src/views/admin/views/content/table/list-query.ts index 5340a6d56..b19ae0fcf 100644 --- a/packages/vitnode/src/views/admin/views/content/table/list-query.ts +++ b/packages/vitnode/src/views/admin/views/content/table/list-query.ts @@ -1,10 +1,7 @@ import { queryOptions } from "@tanstack/react-query"; import { z } from "zod"; -import type { - AdminTablePage, - AdminTablePageInfo, -} from "@/views/admin/table/params"; +import type { AdminTablePage } from "@/views/admin/table/params"; import { RECORD_STALE_TIME } from "@/lib/query-freshness"; diff --git a/packages/vitnode/src/views/auth/passkeys/webauthn.test.ts b/packages/vitnode/src/views/auth/passkeys/webauthn.test.ts new file mode 100644 index 000000000..c5e1408f5 --- /dev/null +++ b/packages/vitnode/src/views/auth/passkeys/webauthn.test.ts @@ -0,0 +1,46 @@ +import { WebAuthnError } from "@simplewebauthn/browser"; +import { describe, expect, it } from "vitest"; + +import { passkeyCeremonyFailure } from "./webauthn"; + +const webAuthnError = (code: WebAuthnError["code"], name = "Error") => + new WebAuthnError({ cause: new Error(code), code, message: code, name }); + +describe("passkeyCeremonyFailure", () => { + it("treats a closed prompt as a cancellation", () => { + expect( + passkeyCeremonyFailure( + webAuthnError( + "ERROR_PASSTHROUGH_SEE_CAUSE_PROPERTY", + "NotAllowedError", + ), + ), + ).toBe("cancelled"); + expect( + passkeyCeremonyFailure(webAuthnError("ERROR_CEREMONY_ABORTED")), + ).toBe("cancelled"); + }); + + it("recognises an authenticator that already holds this account", () => { + expect( + passkeyCeremonyFailure( + webAuthnError("ERROR_AUTHENTICATOR_PREVIOUSLY_REGISTERED"), + ), + ).toBe("already_registered"); + }); + + it("reports an authenticator that cannot do discoverable, verified passkeys", () => { + expect( + passkeyCeremonyFailure( + webAuthnError( + "ERROR_AUTHENTICATOR_MISSING_DISCOVERABLE_CREDENTIAL_SUPPORT", + ), + ), + ).toBe("unsupported"); + }); + + it("falls back to a generic failure", () => { + expect(passkeyCeremonyFailure(new Error("boom"))).toBe("failed"); + expect(passkeyCeremonyFailure("not an error")).toBe("failed"); + }); +}); diff --git a/packages/vitnode/src/views/auth/passkeys/webauthn.ts b/packages/vitnode/src/views/auth/passkeys/webauthn.ts new file mode 100644 index 000000000..8117cec9a --- /dev/null +++ b/packages/vitnode/src/views/auth/passkeys/webauthn.ts @@ -0,0 +1,82 @@ +import type { + AuthenticationResponseJSON, + PublicKeyCredentialCreationOptionsJSON, + PublicKeyCredentialRequestOptionsJSON, + RegistrationResponseJSON, +} from "@simplewebauthn/browser"; + +import { + browserSupportsWebAuthn, + startAuthentication, + startRegistration, + WebAuthnError, +} from "@simplewebauthn/browser"; +import React from "react"; + +export type PasskeyCeremonyFailure = + "already_registered" | "cancelled" | "failed" | "unsupported"; + +export type PasskeyCeremonyResult = + | { failure: PasskeyCeremonyFailure; ok: false } + | { ok: true; response: Response }; + +const CANCELLED_ERROR_NAMES = new Set(["AbortError", "NotAllowedError"]); + +export const passkeyCeremonyFailure = ( + error: unknown, +): PasskeyCeremonyFailure => { + if (error instanceof WebAuthnError) { + if (error.code === "ERROR_AUTHENTICATOR_PREVIOUSLY_REGISTERED") { + return "already_registered"; + } + if (error.code === "ERROR_CEREMONY_ABORTED") return "cancelled"; + if ( + error.code === + "ERROR_AUTHENTICATOR_MISSING_DISCOVERABLE_CREDENTIAL_SUPPORT" || + error.code === "ERROR_AUTHENTICATOR_MISSING_USER_VERIFICATION_SUPPORT" + ) { + return "unsupported"; + } + } + + if (error instanceof Error && CANCELLED_ERROR_NAMES.has(error.name)) { + return "cancelled"; + } + + return "failed"; +}; + +export const isPasskeySupported = (): boolean => browserSupportsWebAuthn(); + +const subscribeToNothing = () => () => undefined; + +export const usePasskeySupport = (): boolean => + React.useSyncExternalStore( + subscribeToNothing, + isPasskeySupported, + () => true, + ); + +export const createPasskeyInBrowser = async ( + optionsJSON: PublicKeyCredentialCreationOptionsJSON, +): Promise> => { + if (!isPasskeySupported()) return { failure: "unsupported", ok: false }; + + try { + return { ok: true, response: await startRegistration({ optionsJSON }) }; + } catch (error) { + return { failure: passkeyCeremonyFailure(error), ok: false }; + } +}; + +export const getPasskeyInBrowser = async ( + optionsJSON: PublicKeyCredentialRequestOptionsJSON, +): Promise> => { + if (!isPasskeySupported()) return { failure: "unsupported", ok: false }; + + try { + return { ok: true, response: await startAuthentication({ optionsJSON }) }; + } catch (error) { + return { failure: passkeyCeremonyFailure(error), ok: false }; + } +}; diff --git a/packages/vitnode/src/views/auth/settings/nav-content.tsx b/packages/vitnode/src/views/auth/settings/nav-content.tsx index 007d71952..bab294433 100644 --- a/packages/vitnode/src/views/auth/settings/nav-content.tsx +++ b/packages/vitnode/src/views/auth/settings/nav-content.tsx @@ -8,7 +8,7 @@ import { } from "lucide-react"; import { useTranslations } from "use-intl"; -import type { SettingsNavKey } from "./settings-nav"; +import type { SettingsNavItem, SettingsNavKey } from "./settings-nav"; import { SETTINGS_INTERACTIVE_ROW } from "./settings-group"; import { @@ -26,14 +26,20 @@ const ICONS: Record< security: KeyRoundIcon, }; -export const SettingsNavContent = ({ pathname }: { pathname: string }) => { +export const SettingsNavContent = ({ + items = SETTINGS_NAV_ITEMS, + pathname, +}: { + items?: readonly SettingsNavItem[]; + pathname: string; +}) => { const t = useTranslations("core.auth.settings"); const tNav = useTranslations("core.auth.settings.nav"); return (