From 613bb6216dc1636ae0007020cff0da32ecc83810 Mon Sep 17 00:00:00 2001 From: Hong Minhee Date: Fri, 15 May 2026 03:14:04 +0900 Subject: [PATCH 01/25] Add @simplewebauthn dependencies Add @simplewebauthn/server and @simplewebauthn/browser at ^13.3.0 as the foundation for upcoming WebAuthn/passkey authentication support. This commit only updates package.json and the pnpm lockfile; no source code changes yet. Assisted-by: Claude Code:claude-opus-4-7 --- package.json | 2 + pnpm-lock.yaml | 194 +++++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 196 insertions(+) diff --git a/package.json b/package.json index 067f068f..9ac2ec09 100644 --- a/package.json +++ b/package.json @@ -55,6 +55,8 @@ "@sentry/core": "^10.51.0", "@sentry/node": "^10.51.0", "@shikijs/markdown-it": "^4.0.2", + "@simplewebauthn/browser": "^13.3.0", + "@simplewebauthn/server": "^13.3.0", "@supercharge/promise-pool": "^3.2.0", "argon2": "^0.44.0", "cheerio": "^1.2.0", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index f914a923..9df55821 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -80,6 +80,12 @@ importers: '@shikijs/markdown-it': specifier: ^4.0.2 version: 4.0.2 + '@simplewebauthn/browser': + specifier: ^13.3.0 + version: 13.3.0 + '@simplewebauthn/server': + specifier: ^13.3.0 + version: 13.3.0 '@supercharge/promise-pool': specifier: ^3.2.0 version: 3.2.0 @@ -1306,6 +1312,9 @@ packages: '@fxts/core@1.26.0': resolution: {integrity: sha512-ONaza1CGr8dLKmJ0HQgi0h4XuyDJMr0P70M7o/My/YeeRxuYoSANHjE2nSY7xO4WHgxUD5ojd5dpxlFOzAEJsA==} + '@hexagon/base64@1.1.28': + resolution: {integrity: sha512-lhqDEAvWixy3bZ+UOYbPwUbBkwBq5C1LAJ/xPC8Oi+lL54oyakv/npbA0aU2hgCsx/1NUd4IBvV03+aUBWxerw==} + '@hexagon/base64@2.0.4': resolution: {integrity: sha512-H/ZY6rGyaEuk0mwQgZ3BVi9hMjFTYpBNFbmtOuec/pPibuGhCMXd8fGtwBaO0h44FkWMurysMsDrpkJsBRmoWQ==} @@ -1529,6 +1538,9 @@ packages: resolution: {integrity: sha512-hloP58zRVCRSpgDxmqCWJNlizAlUgJFqG2ypq79DCvyv9tHjRYMDOcPFjzfl/A1/YxDvRCZz8wvZvmapQnKwFQ==} engines: {node: '>=12'} + '@levischuck/tiny-cbor@0.2.11': + resolution: {integrity: sha512-llBRm4dT4Z89aRsm6u2oEZ8tfwL/2l6BwpZ7JcyieouniDECM5AqNgr/y08zalEIvW3RSK4upYyybDcmjXqAow==} + '@logtape/drizzle-orm@2.0.7': resolution: {integrity: sha512-JndELP5Q1impvcezL6GrlX0AJX+6unskv8lgCL/9Rgcx/9ppsi9OjfyA27E38HO2r803Tm4c2Kj+KL0NcXzBqA==} peerDependencies: @@ -2170,6 +2182,46 @@ packages: cpu: [x64] os: [win32] + '@peculiar/asn1-android@2.7.0': + resolution: {integrity: sha512-iD3VskhVQnM4nE3PN9cBdPTR7JrqZy3FYk+uD2CeG6DUqKoANqaEfx0f7izPmW+Qm5JBM35ek+viLCmjy18ByQ==} + + '@peculiar/asn1-cms@2.7.0': + resolution: {integrity: sha512-hew63shtzzvBcSHbhm+cyAmKe6AIfinT9hzEqSPjDC6opTTMKmTkQ0gHuN2KsWlvqiKw1S/fS94fhag/FJkioQ==} + + '@peculiar/asn1-csr@2.7.0': + resolution: {integrity: sha512-VVsAyGqErT9D1SY4aEqozThXMVI+ssVRiv2DDeYuvpBKLIgZ3hYs3Ay3u/VSoKq6ESFi9cf6rf3IOOzfwh7oMA==} + + '@peculiar/asn1-ecc@2.7.0': + resolution: {integrity: sha512-n7KEs/Q/wrB415cxy4fHOBhegp4NdJ15fkJPwcB/3/8iNBQC2L/N7SChJPKDJPZGYH0jD4Tg4/0vnHmwghnbKw==} + + '@peculiar/asn1-pfx@2.7.0': + resolution: {integrity: sha512-V/nrlQVmhg7lYAsM7E13UDL5erAwFv6kCIVFqNaMIHSVi7dngcT839JkRTkQBqznMG98l2XjxYk74ZztAohZzA==} + + '@peculiar/asn1-pkcs8@2.7.0': + resolution: {integrity: sha512-9GTl1nE8Mx1kTZ+7QyYatDyKsm34QcWRBFkY1iPvWC3X4Dona5s/tlLiQsx5WzVdZqiMBZNYT0buyw4/vbhnjw==} + + '@peculiar/asn1-pkcs9@2.7.0': + resolution: {integrity: sha512-Bh7m+OuIaSEllPQcSd9OSp93F4ROWH7sbITWV8MI+8dwsjE5111/87VxiWVvYFKyww3vp39geLv9ENqhwWHcew==} + + '@peculiar/asn1-rsa@2.7.0': + resolution: {integrity: sha512-/qvENQrXyTZURjMqSeofHul0JJt2sNSzSwk36pl2olkHbaioMQgrASDZAlHXl0xUlnVbHj0uGgOrBMTb5x2aJQ==} + + '@peculiar/asn1-schema@2.7.0': + resolution: {integrity: sha512-W8ZfWzLmQnrcky+eh3tni4IozMdqBDiHWU0N+vve/UGjMaUs8c0L7A2oEdkBXS8rTpWDpK/aoI3DG/L/hxmxPg==} + + '@peculiar/asn1-x509-attr@2.7.0': + resolution: {integrity: sha512-NS8e7SOgXipkzUPLF/sce7ukpMpWjhxYsH0n6Y+bHYo4TTxOb95Zv7hqwSuL212mj5YxovjdOKQOgH1As3E94w==} + + '@peculiar/asn1-x509@2.7.0': + resolution: {integrity: sha512-mUn9RRrkGDnG4ALfunDmzyRW5dg+sWCj/pfnCCqEHYbkGxEpvUt6iVJv8Yw1cyp6SWZ26ZE5oSmI5SqEaen15g==} + + '@peculiar/utils@2.0.3': + resolution: {integrity: sha512-+oL3HPFRIZ1St2K50lWCXiioIgSoxzz7R1J3uF6neO2yl1sgmpgY6XXJH4BdpoDkMWznQTeYF6oWNDZLCdQ4eQ==} + + '@peculiar/x509@1.14.3': + resolution: {integrity: sha512-C2Xj8FZ0uHWeCXXqX5B4/gVFQmtSkiuOolzAgutjTfseNOHT3pUjljDZsTSxXFGgio54bCzVFqmEOUrIVk8RDA==} + engines: {node: '>=20.0.0'} + '@phc/format@1.0.0': resolution: {integrity: sha512-m7X9U6BG2+J+R1lSOdCiITLLrxm+cWlNI3HUFA92oLO77ObGNzaKdh8pMLqdZcshtkKuV84olNNXDfMc4FezBQ==} engines: {node: '>=10'} @@ -2533,6 +2585,13 @@ packages: '@shikijs/vscode-textmate@10.0.2': resolution: {integrity: sha512-83yeghZ2xxin3Nj8z1NMd/NCuca+gsYXswywDy5bHvwlWL8tpTQmzGeUuHd9FC3E/SBEMvzJRwWEOz5gGes9Qg==} + '@simplewebauthn/browser@13.3.0': + resolution: {integrity: sha512-BE/UWv6FOToAdVk0EokzkqQQDOWtNydYlY6+OrmiZ5SCNmb41VehttboTetUM3T/fr6EAFYVXjz4My2wg230rQ==} + + '@simplewebauthn/server@13.3.0': + resolution: {integrity: sha512-MLHYFrYG8/wK2i+86XMhiecK72nMaHKKt4bo+7Q1TbuG9iGjlSdfkPWKO5ZFE/BX+ygCJ7pr8H/AJeyAj1EaTQ==} + engines: {node: '>=20.0.0'} + '@smithy/abort-controller@3.1.9': resolution: {integrity: sha512-yiW0WI30zj8ZKoSYNx90no7ugVn3khlyH/z5W8qtKBtVE6awRALbhSG+2SAHA1r6bO/6M9utxYKVZ3PCJ1rWxw==} engines: {node: '>=16.0.0'} @@ -4995,6 +5054,9 @@ packages: recma-stringify@1.0.0: resolution: {integrity: sha512-cjwII1MdIIVloKvC9ErQ+OgAtwHBmcZ0Bg4ciz78FtbT8In39aAYbaA7zvxQ61xVMSPE8WxhLwLbhif4Js2C+g==} + reflect-metadata@0.2.2: + resolution: {integrity: sha512-urBwgfrvVP/eAyXx4hluJivBKzuEbSQs9rKWCrCkbSxNv8mxPcUZKeuoF3Uy4mJl3Lwprp6yy5/39VWigZ4K6Q==} + regex-recursion@6.0.2: resolution: {integrity: sha512-0YCaSCq2VRIebiaUviZNs0cBz1kg5kVS2UKUfNIx8YVs1cN3AV7NTctO5FOKBA+UT2BPJIWZauYHPqJODG50cg==} @@ -5406,6 +5468,9 @@ packages: unplugin-unused: optional: true + tslib@1.14.1: + resolution: {integrity: sha512-Xni35NKzjgMrwevysHTCArtLDpPvye8zV/0E4EyYn43P7/7qvQwPh9BGkHewbMulVntbigmcT7rdX3BNo9wRJg==} + tslib@2.8.1: resolution: {integrity: sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==} @@ -5414,6 +5479,10 @@ packages: engines: {node: '>=18.0.0'} hasBin: true + tsyringe@4.10.0: + resolution: {integrity: sha512-axr3IdNuVIxnaK5XGEUFTu3YmAQ6lllgrvqfEoR16g/HGnYY/6We4oWENtAnzK6/LpJ2ur9PAb80RBt7/U4ugw==} + engines: {node: '>= 6.0.0'} + tunnel@0.0.6: resolution: {integrity: sha512-1h/Lnq9yajKY2PEbBadPXj3VxsDDu844OnaAo52UVmIzIvwwtBPIuNvkjuzBlTWpfJyUbG3ez0KSBibQkj4ojg==} engines: {node: '>=0.6.11 <=0.7.0 || >=0.7.3'} @@ -7641,6 +7710,8 @@ snapshots: dependencies: tslib: 2.8.1 + '@hexagon/base64@1.1.28': {} + '@hexagon/base64@2.0.4': {} '@hono/node-server@1.19.13(hono@4.12.18)': @@ -7825,6 +7896,8 @@ snapshots: dependencies: jsbi: 4.3.0 + '@levischuck/tiny-cbor@0.2.11': {} + '@logtape/drizzle-orm@2.0.7(@logtape/logtape@2.0.7)': dependencies: '@logtape/logtape': 2.0.7 @@ -8348,6 +8421,106 @@ snapshots: '@pagefind/windows-x64@1.4.0': optional: true + '@peculiar/asn1-android@2.7.0': + dependencies: + '@peculiar/asn1-schema': 2.7.0 + asn1js: 3.0.10 + tslib: 2.8.1 + + '@peculiar/asn1-cms@2.7.0': + dependencies: + '@peculiar/asn1-schema': 2.7.0 + '@peculiar/asn1-x509': 2.7.0 + '@peculiar/asn1-x509-attr': 2.7.0 + asn1js: 3.0.10 + tslib: 2.8.1 + + '@peculiar/asn1-csr@2.7.0': + dependencies: + '@peculiar/asn1-schema': 2.7.0 + '@peculiar/asn1-x509': 2.7.0 + asn1js: 3.0.10 + tslib: 2.8.1 + + '@peculiar/asn1-ecc@2.7.0': + dependencies: + '@peculiar/asn1-schema': 2.7.0 + '@peculiar/asn1-x509': 2.7.0 + asn1js: 3.0.10 + tslib: 2.8.1 + + '@peculiar/asn1-pfx@2.7.0': + dependencies: + '@peculiar/asn1-cms': 2.7.0 + '@peculiar/asn1-pkcs8': 2.7.0 + '@peculiar/asn1-rsa': 2.7.0 + '@peculiar/asn1-schema': 2.7.0 + asn1js: 3.0.10 + tslib: 2.8.1 + + '@peculiar/asn1-pkcs8@2.7.0': + dependencies: + '@peculiar/asn1-schema': 2.7.0 + '@peculiar/asn1-x509': 2.7.0 + asn1js: 3.0.10 + tslib: 2.8.1 + + '@peculiar/asn1-pkcs9@2.7.0': + dependencies: + '@peculiar/asn1-cms': 2.7.0 + '@peculiar/asn1-pfx': 2.7.0 + '@peculiar/asn1-pkcs8': 2.7.0 + '@peculiar/asn1-schema': 2.7.0 + '@peculiar/asn1-x509': 2.7.0 + '@peculiar/asn1-x509-attr': 2.7.0 + asn1js: 3.0.10 + tslib: 2.8.1 + + '@peculiar/asn1-rsa@2.7.0': + dependencies: + '@peculiar/asn1-schema': 2.7.0 + '@peculiar/asn1-x509': 2.7.0 + asn1js: 3.0.10 + tslib: 2.8.1 + + '@peculiar/asn1-schema@2.7.0': + dependencies: + '@peculiar/utils': 2.0.3 + asn1js: 3.0.10 + tslib: 2.8.1 + + '@peculiar/asn1-x509-attr@2.7.0': + dependencies: + '@peculiar/asn1-schema': 2.7.0 + '@peculiar/asn1-x509': 2.7.0 + asn1js: 3.0.10 + tslib: 2.8.1 + + '@peculiar/asn1-x509@2.7.0': + dependencies: + '@peculiar/asn1-schema': 2.7.0 + '@peculiar/utils': 2.0.3 + asn1js: 3.0.10 + tslib: 2.8.1 + + '@peculiar/utils@2.0.3': + dependencies: + tslib: 2.8.1 + + '@peculiar/x509@1.14.3': + dependencies: + '@peculiar/asn1-cms': 2.7.0 + '@peculiar/asn1-csr': 2.7.0 + '@peculiar/asn1-ecc': 2.7.0 + '@peculiar/asn1-pkcs9': 2.7.0 + '@peculiar/asn1-rsa': 2.7.0 + '@peculiar/asn1-schema': 2.7.0 + '@peculiar/asn1-x509': 2.7.0 + pvtsutils: 1.3.6 + reflect-metadata: 0.2.2 + tslib: 2.8.1 + tsyringe: 4.10.0 + '@phc/format@1.0.0': {} '@pkgjs/parseargs@0.11.0': @@ -8661,6 +8834,19 @@ snapshots: '@shikijs/vscode-textmate@10.0.2': {} + '@simplewebauthn/browser@13.3.0': {} + + '@simplewebauthn/server@13.3.0': + dependencies: + '@hexagon/base64': 1.1.28 + '@levischuck/tiny-cbor': 0.2.11 + '@peculiar/asn1-android': 2.7.0 + '@peculiar/asn1-ecc': 2.7.0 + '@peculiar/asn1-rsa': 2.7.0 + '@peculiar/asn1-schema': 2.7.0 + '@peculiar/asn1-x509': 2.7.0 + '@peculiar/x509': 1.14.3 + '@smithy/abort-controller@3.1.9': dependencies: '@smithy/types': 3.7.2 @@ -12019,6 +12205,8 @@ snapshots: unified: 11.0.5 vfile: 6.0.3 + reflect-metadata@0.2.2: {} + regex-recursion@6.0.2: dependencies: regex-utilities: 2.3.0 @@ -12533,6 +12721,8 @@ snapshots: - synckit - vue-tsc + tslib@1.14.1: {} + tslib@2.8.1: {} tsx@4.21.0: @@ -12542,6 +12732,10 @@ snapshots: optionalDependencies: fsevents: 2.3.3 + tsyringe@4.10.0: + dependencies: + tslib: 1.14.1 + tunnel@0.0.6: {} type-check@0.4.0: From 2bf93ed6150750174b11a11813a2314b664ab70d Mon Sep 17 00:00:00 2001 From: Hong Minhee Date: Fri, 15 May 2026 03:20:24 +0900 Subject: [PATCH 02/25] Add passkeys table MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Introduce a `passkeys` table to back the upcoming WebAuthn / passkey authentication flow. Each row stores one credential returned by the browser at registration: the base64url credential id, the COSE public key, the signature counter, transport hints, device-type / backup-state metadata reported by SimpleWebAuthn, a user-supplied nickname, and the timestamps Hollo needs to render the management UI. Rows cascade-delete with the parent `credentials.email` so the credential record stays authoritative — when a credential is replaced (re-setup), its passkeys go with it instead of becoming orphans bound to an obsolete WebAuthn user handle. Assisted-by: Claude Code:claude-opus-4-7 --- drizzle/0088_passkeys.sql | 14 + drizzle/meta/0088_snapshot.json | 4529 +++++++++++++++++++++++++++++++ drizzle/meta/_journal.json | 9 +- src/schema.ts | 30 + 4 files changed, 4581 insertions(+), 1 deletion(-) create mode 100644 drizzle/0088_passkeys.sql create mode 100644 drizzle/meta/0088_snapshot.json diff --git a/drizzle/0088_passkeys.sql b/drizzle/0088_passkeys.sql new file mode 100644 index 00000000..fffd37c6 --- /dev/null +++ b/drizzle/0088_passkeys.sql @@ -0,0 +1,14 @@ +CREATE TABLE "passkeys" ( + "id" text PRIMARY KEY NOT NULL, + "credential_email" varchar(254) NOT NULL, + "public_key" text NOT NULL, + "counter" bigint NOT NULL, + "transports" text[] DEFAULT (ARRAY[]::text[]) NOT NULL, + "device_type" text NOT NULL, + "backed_up" boolean NOT NULL, + "nickname" text NOT NULL, + "last_used" timestamp with time zone, + "created" timestamp with time zone DEFAULT CURRENT_TIMESTAMP NOT NULL +); +--> statement-breakpoint +ALTER TABLE "passkeys" ADD CONSTRAINT "passkeys_credential_email_credentials_email_fk" FOREIGN KEY ("credential_email") REFERENCES "public"."credentials"("email") ON DELETE cascade ON UPDATE no action; \ No newline at end of file diff --git a/drizzle/meta/0088_snapshot.json b/drizzle/meta/0088_snapshot.json new file mode 100644 index 00000000..25ac04cb --- /dev/null +++ b/drizzle/meta/0088_snapshot.json @@ -0,0 +1,4529 @@ +{ + "id": "6304a3ed-3b7c-4681-bfdc-c5c162553d52", + "prevId": "111f8bf6-721d-40fc-a563-fb07818be081", + "version": "7", + "dialect": "postgresql", + "tables": { + "public.access_grants": { + "name": "access_grants", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true + }, + "code": { + "name": "code", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "expires_in": { + "name": "expires_in", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "redirect_uri": { + "name": "redirect_uri", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "scopes": { + "name": "scopes", + "type": "scope[]", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "code_challenge": { + "name": "code_challenge", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "code_challenge_method": { + "name": "code_challenge_method", + "type": "varchar(256)", + "primaryKey": false, + "notNull": false + }, + "application_id": { + "name": "application_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "resource_owner_id": { + "name": "resource_owner_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "created": { + "name": "created", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "CURRENT_TIMESTAMP" + }, + "revoked": { + "name": "revoked", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "access_grants_resource_owner_id_index": { + "name": "access_grants_resource_owner_id_index", + "columns": [ + { + "expression": "resource_owner_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "access_grants_application_id_applications_id_fk": { + "name": "access_grants_application_id_applications_id_fk", + "tableFrom": "access_grants", + "tableTo": "applications", + "columnsFrom": [ + "application_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "access_grants_resource_owner_id_account_owners_id_fk": { + "name": "access_grants_resource_owner_id_account_owners_id_fk", + "tableFrom": "access_grants", + "tableTo": "account_owners", + "columnsFrom": [ + "resource_owner_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "access_grants_code_unique": { + "name": "access_grants_code_unique", + "nullsNotDistinct": false, + "columns": [ + "code" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.access_tokens": { + "name": "access_tokens", + "schema": "", + "columns": { + "code": { + "name": "code", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "application_id": { + "name": "application_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "account_owner_id": { + "name": "account_owner_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "grant_type": { + "name": "grant_type", + "type": "grant_type", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'authorization_code'" + }, + "scopes": { + "name": "scopes", + "type": "scope[]", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "created": { + "name": "created", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "CURRENT_TIMESTAMP" + } + }, + "indexes": {}, + "foreignKeys": { + "access_tokens_application_id_applications_id_fk": { + "name": "access_tokens_application_id_applications_id_fk", + "tableFrom": "access_tokens", + "tableTo": "applications", + "columnsFrom": [ + "application_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "access_tokens_account_owner_id_account_owners_id_fk": { + "name": "access_tokens_account_owner_id_account_owners_id_fk", + "tableFrom": "access_tokens", + "tableTo": "account_owners", + "columnsFrom": [ + "account_owner_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.account_owners": { + "name": "account_owners", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true + }, + "handle": { + "name": "handle", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "rsa_private_key_jwk": { + "name": "rsa_private_key_jwk", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "rsa_public_key_jwk": { + "name": "rsa_public_key_jwk", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "ed25519_private_key_jwk": { + "name": "ed25519_private_key_jwk", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "ed25519_public_key_jwk": { + "name": "ed25519_public_key_jwk", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "fields": { + "name": "fields", + "type": "json", + "primaryKey": false, + "notNull": true, + "default": "'{}'::json" + }, + "bio": { + "name": "bio", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "followed_tags": { + "name": "followed_tags", + "type": "text[]", + "primaryKey": false, + "notNull": true, + "default": "'{}'" + }, + "visibility": { + "name": "visibility", + "type": "post_visibility", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'public'" + }, + "language": { + "name": "language", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'en'" + }, + "discoverable": { + "name": "discoverable", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "expand_spoilers": { + "name": "expand_spoilers", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "theme_color": { + "name": "theme_color", + "type": "theme_color", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": { + "account_owners_id_accounts_id_fk": { + "name": "account_owners_id_accounts_id_fk", + "tableFrom": "account_owners", + "tableTo": "accounts", + "columnsFrom": [ + "id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "account_owners_handle_unique": { + "name": "account_owners_handle_unique", + "nullsNotDistinct": false, + "columns": [ + "handle" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.accounts": { + "name": "accounts", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true + }, + "iri": { + "name": "iri", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "type": { + "name": "type", + "type": "account_type", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "varchar(100)", + "primaryKey": false, + "notNull": true + }, + "handle": { + "name": "handle", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "bio_html": { + "name": "bio_html", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "url": { + "name": "url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "protected": { + "name": "protected", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "avatar_url": { + "name": "avatar_url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "cover_url": { + "name": "cover_url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "inbox_url": { + "name": "inbox_url", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "followers_url": { + "name": "followers_url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "shared_inbox_url": { + "name": "shared_inbox_url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "featured_url": { + "name": "featured_url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "following_count": { + "name": "following_count", + "type": "bigint", + "primaryKey": false, + "notNull": false, + "default": 0 + }, + "followers_count": { + "name": "followers_count", + "type": "bigint", + "primaryKey": false, + "notNull": false, + "default": 0 + }, + "posts_count": { + "name": "posts_count", + "type": "bigint", + "primaryKey": false, + "notNull": false, + "default": 0 + }, + "field_htmls": { + "name": "field_htmls", + "type": "json", + "primaryKey": false, + "notNull": true, + "default": "'{}'::json" + }, + "emojis": { + "name": "emojis", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'::jsonb" + }, + "sensitive": { + "name": "sensitive", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "successor_id": { + "name": "successor_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "aliases": { + "name": "aliases", + "type": "text[]", + "primaryKey": false, + "notNull": true, + "default": "(ARRAY[]::text[])" + }, + "instance_host": { + "name": "instance_host", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "published": { + "name": "published", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "updated": { + "name": "updated", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "CURRENT_TIMESTAMP" + }, + "fetched": { + "name": "fetched", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": { + "accounts_successor_id_accounts_id_fk": { + "name": "accounts_successor_id_accounts_id_fk", + "tableFrom": "accounts", + "tableTo": "accounts", + "columnsFrom": [ + "successor_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "accounts_instance_host_instances_host_fk": { + "name": "accounts_instance_host_instances_host_fk", + "tableFrom": "accounts", + "tableTo": "instances", + "columnsFrom": [ + "instance_host" + ], + "columnsTo": [ + "host" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "accounts_iri_unique": { + "name": "accounts_iri_unique", + "nullsNotDistinct": false, + "columns": [ + "iri" + ] + }, + "accounts_handle_unique": { + "name": "accounts_handle_unique", + "nullsNotDistinct": false, + "columns": [ + "handle" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.applications": { + "name": "applications", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true + }, + "name": { + "name": "name", + "type": "varchar(256)", + "primaryKey": false, + "notNull": true + }, + "redirect_uris": { + "name": "redirect_uris", + "type": "text[]", + "primaryKey": false, + "notNull": true + }, + "scopes": { + "name": "scopes", + "type": "scope[]", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "website": { + "name": "website", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "client_id": { + "name": "client_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "client_secret": { + "name": "client_secret", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "confidential": { + "name": "confidential", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "created": { + "name": "created", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "CURRENT_TIMESTAMP" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "applications_client_id_unique": { + "name": "applications_client_id_unique", + "nullsNotDistinct": false, + "columns": [ + "client_id" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.blocks": { + "name": "blocks", + "schema": "", + "columns": { + "account_id": { + "name": "account_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "blocked_account_id": { + "name": "blocked_account_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "created": { + "name": "created", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "CURRENT_TIMESTAMP" + } + }, + "indexes": { + "blocks_account_id_index": { + "name": "blocks_account_id_index", + "columns": [ + { + "expression": "account_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "blocks_blocked_account_id_index": { + "name": "blocks_blocked_account_id_index", + "columns": [ + { + "expression": "blocked_account_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "blocks_account_id_accounts_id_fk": { + "name": "blocks_account_id_accounts_id_fk", + "tableFrom": "blocks", + "tableTo": "accounts", + "columnsFrom": [ + "account_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "blocks_blocked_account_id_accounts_id_fk": { + "name": "blocks_blocked_account_id_accounts_id_fk", + "tableFrom": "blocks", + "tableTo": "accounts", + "columnsFrom": [ + "blocked_account_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "blocks_account_id_blocked_account_id_pk": { + "name": "blocks_account_id_blocked_account_id_pk", + "columns": [ + "account_id", + "blocked_account_id" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.bookmarks": { + "name": "bookmarks", + "schema": "", + "columns": { + "post_id": { + "name": "post_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "account_owner_id": { + "name": "account_owner_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "created": { + "name": "created", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "CURRENT_TIMESTAMP" + } + }, + "indexes": { + "bookmarks_post_id_account_owner_id_index": { + "name": "bookmarks_post_id_account_owner_id_index", + "columns": [ + { + "expression": "post_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "account_owner_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "bookmarks_post_id_posts_id_fk": { + "name": "bookmarks_post_id_posts_id_fk", + "tableFrom": "bookmarks", + "tableTo": "posts", + "columnsFrom": [ + "post_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "bookmarks_account_owner_id_account_owners_id_fk": { + "name": "bookmarks_account_owner_id_account_owners_id_fk", + "tableFrom": "bookmarks", + "tableTo": "account_owners", + "columnsFrom": [ + "account_owner_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "bookmarks_post_id_account_owner_id_pk": { + "name": "bookmarks_post_id_account_owner_id_pk", + "columns": [ + "post_id", + "account_owner_id" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.cleanup_job_items": { + "name": "cleanup_job_items", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true + }, + "job_id": { + "name": "job_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "cleanup_job_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "data": { + "name": "data", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "error_message": { + "name": "error_message", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "processed_at": { + "name": "processed_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created": { + "name": "created", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "CURRENT_TIMESTAMP" + } + }, + "indexes": { + "cleanup_job_items_job_id_status_index": { + "name": "cleanup_job_items_job_id_status_index", + "columns": [ + { + "expression": "job_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "cleanup_job_items_job_id_cleanup_jobs_id_fk": { + "name": "cleanup_job_items_job_id_cleanup_jobs_id_fk", + "tableFrom": "cleanup_job_items", + "tableTo": "cleanup_jobs", + "columnsFrom": [ + "job_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.cleanup_jobs": { + "name": "cleanup_jobs", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true + }, + "category": { + "name": "category", + "type": "cleanup_job_category", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "cleanup_job_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "total_items": { + "name": "total_items", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "processed_items": { + "name": "processed_items", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "successful_items": { + "name": "successful_items", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "failed_items": { + "name": "failed_items", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "error_message": { + "name": "error_message", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created": { + "name": "created", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "CURRENT_TIMESTAMP" + }, + "started_at": { + "name": "started_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "completed_at": { + "name": "completed_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "cleanup_jobs_status_created_index": { + "name": "cleanup_jobs_status_created_index", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.credentials": { + "name": "credentials", + "schema": "", + "columns": { + "email": { + "name": "email", + "type": "varchar(254)", + "primaryKey": true, + "notNull": true + }, + "password_hash": { + "name": "password_hash", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created": { + "name": "created", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "CURRENT_TIMESTAMP" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.custom_emojis": { + "name": "custom_emojis", + "schema": "", + "columns": { + "shortcode": { + "name": "shortcode", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "url": { + "name": "url", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "category": { + "name": "category", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created": { + "name": "created", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "CURRENT_TIMESTAMP" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.featured_tags": { + "name": "featured_tags", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true + }, + "account_owner_id": { + "name": "account_owner_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created": { + "name": "created", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": { + "featured_tags_account_owner_id_account_owners_id_fk": { + "name": "featured_tags_account_owner_id_account_owners_id_fk", + "tableFrom": "featured_tags", + "tableTo": "account_owners", + "columnsFrom": [ + "account_owner_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "featured_tags_account_owner_id_name_unique": { + "name": "featured_tags_account_owner_id_name_unique", + "nullsNotDistinct": false, + "columns": [ + "account_owner_id", + "name" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.follows": { + "name": "follows", + "schema": "", + "columns": { + "iri": { + "name": "iri", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "following_id": { + "name": "following_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "follower_id": { + "name": "follower_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "shares": { + "name": "shares", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "notify": { + "name": "notify", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "languages": { + "name": "languages", + "type": "text[]", + "primaryKey": false, + "notNull": false + }, + "created": { + "name": "created", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "CURRENT_TIMESTAMP" + }, + "approved": { + "name": "approved", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "follows_following_id_approved_index": { + "name": "follows_following_id_approved_index", + "columns": [ + { + "expression": "following_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "approved", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"follows\".\"approved\" is not null", + "concurrently": false, + "method": "btree", + "with": {} + }, + "follows_follower_id_following_id_approved_index": { + "name": "follows_follower_id_following_id_approved_index", + "columns": [ + { + "expression": "follower_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "following_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"follows\".\"approved\" is not null", + "concurrently": false, + "method": "btree", + "with": {} + }, + "follows_following_id_created_index": { + "name": "follows_following_id_created_index", + "columns": [ + { + "expression": "following_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "follows_following_id_accounts_id_fk": { + "name": "follows_following_id_accounts_id_fk", + "tableFrom": "follows", + "tableTo": "accounts", + "columnsFrom": [ + "following_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "follows_follower_id_accounts_id_fk": { + "name": "follows_follower_id_accounts_id_fk", + "tableFrom": "follows", + "tableTo": "accounts", + "columnsFrom": [ + "follower_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "follows_following_id_follower_id_pk": { + "name": "follows_following_id_follower_id_pk", + "columns": [ + "following_id", + "follower_id" + ] + } + }, + "uniqueConstraints": { + "follows_iri_unique": { + "name": "follows_iri_unique", + "nullsNotDistinct": false, + "columns": [ + "iri" + ] + } + }, + "policies": {}, + "checkConstraints": { + "ck_follows_self": { + "name": "ck_follows_self", + "value": "\"follows\".\"following_id\" != \"follows\".\"follower_id\"" + } + }, + "isRLSEnabled": false + }, + "public.import_job_items": { + "name": "import_job_items", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true + }, + "job_id": { + "name": "job_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "import_job_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "data": { + "name": "data", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "error_message": { + "name": "error_message", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "processed_at": { + "name": "processed_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created": { + "name": "created", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "CURRENT_TIMESTAMP" + } + }, + "indexes": { + "import_job_items_job_id_status_index": { + "name": "import_job_items_job_id_status_index", + "columns": [ + { + "expression": "job_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "import_job_items_job_id_import_jobs_id_fk": { + "name": "import_job_items_job_id_import_jobs_id_fk", + "tableFrom": "import_job_items", + "tableTo": "import_jobs", + "columnsFrom": [ + "job_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.import_jobs": { + "name": "import_jobs", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true + }, + "account_owner_id": { + "name": "account_owner_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "category": { + "name": "category", + "type": "import_job_category", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "import_job_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "total_items": { + "name": "total_items", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "processed_items": { + "name": "processed_items", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "successful_items": { + "name": "successful_items", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "failed_items": { + "name": "failed_items", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "error_message": { + "name": "error_message", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created": { + "name": "created", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "CURRENT_TIMESTAMP" + }, + "started_at": { + "name": "started_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "completed_at": { + "name": "completed_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "import_jobs_account_owner_id_status_index": { + "name": "import_jobs_account_owner_id_status_index", + "columns": [ + { + "expression": "account_owner_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "import_jobs_status_created_index": { + "name": "import_jobs_status_created_index", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "import_jobs_account_owner_id_account_owners_id_fk": { + "name": "import_jobs_account_owner_id_account_owners_id_fk", + "tableFrom": "import_jobs", + "tableTo": "account_owners", + "columnsFrom": [ + "account_owner_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.instances": { + "name": "instances", + "schema": "", + "columns": { + "host": { + "name": "host", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "software": { + "name": "software", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "software_version": { + "name": "software_version", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created": { + "name": "created", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "CURRENT_TIMESTAMP" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.likes": { + "name": "likes", + "schema": "", + "columns": { + "post_id": { + "name": "post_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "account_id": { + "name": "account_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "created": { + "name": "created", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "CURRENT_TIMESTAMP" + } + }, + "indexes": { + "likes_account_id_post_id_index": { + "name": "likes_account_id_post_id_index", + "columns": [ + { + "expression": "account_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "post_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "likes_created_index": { + "name": "likes_created_index", + "columns": [ + { + "expression": "created", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "likes_post_id_posts_id_fk": { + "name": "likes_post_id_posts_id_fk", + "tableFrom": "likes", + "tableTo": "posts", + "columnsFrom": [ + "post_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "likes_account_id_accounts_id_fk": { + "name": "likes_account_id_accounts_id_fk", + "tableFrom": "likes", + "tableTo": "accounts", + "columnsFrom": [ + "account_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "likes_post_id_account_id_pk": { + "name": "likes_post_id_account_id_pk", + "columns": [ + "post_id", + "account_id" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.list_members": { + "name": "list_members", + "schema": "", + "columns": { + "list_id": { + "name": "list_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "account_id": { + "name": "account_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "created": { + "name": "created", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "CURRENT_TIMESTAMP" + } + }, + "indexes": {}, + "foreignKeys": { + "list_members_list_id_lists_id_fk": { + "name": "list_members_list_id_lists_id_fk", + "tableFrom": "list_members", + "tableTo": "lists", + "columnsFrom": [ + "list_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "list_members_account_id_accounts_id_fk": { + "name": "list_members_account_id_accounts_id_fk", + "tableFrom": "list_members", + "tableTo": "accounts", + "columnsFrom": [ + "account_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "list_members_list_id_account_id_pk": { + "name": "list_members_list_id_account_id_pk", + "columns": [ + "list_id", + "account_id" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.list_posts": { + "name": "list_posts", + "schema": "", + "columns": { + "list_id": { + "name": "list_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "post_id": { + "name": "post_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "list_posts_list_id_post_id_index": { + "name": "list_posts_list_id_post_id_index", + "columns": [ + { + "expression": "list_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "post_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "list_posts_list_id_lists_id_fk": { + "name": "list_posts_list_id_lists_id_fk", + "tableFrom": "list_posts", + "tableTo": "lists", + "columnsFrom": [ + "list_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "list_posts_post_id_posts_id_fk": { + "name": "list_posts_post_id_posts_id_fk", + "tableFrom": "list_posts", + "tableTo": "posts", + "columnsFrom": [ + "post_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "list_posts_list_id_post_id_pk": { + "name": "list_posts_list_id_post_id_pk", + "columns": [ + "list_id", + "post_id" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.lists": { + "name": "lists", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true + }, + "account_owner_id": { + "name": "account_owner_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "replies_policy": { + "name": "replies_policy", + "type": "list_replies_policy", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'list'" + }, + "exclusive": { + "name": "exclusive", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "created": { + "name": "created", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "CURRENT_TIMESTAMP" + } + }, + "indexes": {}, + "foreignKeys": { + "lists_account_owner_id_account_owners_id_fk": { + "name": "lists_account_owner_id_account_owners_id_fk", + "tableFrom": "lists", + "tableTo": "account_owners", + "columnsFrom": [ + "account_owner_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.markers": { + "name": "markers", + "schema": "", + "columns": { + "account_owner_id": { + "name": "account_owner_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "type": { + "name": "type", + "type": "marker_type", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "last_read_id": { + "name": "last_read_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "version": { + "name": "version", + "type": "bigint", + "primaryKey": false, + "notNull": true, + "default": 1 + }, + "updated": { + "name": "updated", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "CURRENT_TIMESTAMP" + } + }, + "indexes": {}, + "foreignKeys": { + "markers_account_owner_id_account_owners_id_fk": { + "name": "markers_account_owner_id_account_owners_id_fk", + "tableFrom": "markers", + "tableTo": "account_owners", + "columnsFrom": [ + "account_owner_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "markers_account_owner_id_type_pk": { + "name": "markers_account_owner_id_type_pk", + "columns": [ + "account_owner_id", + "type" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.media": { + "name": "media", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true + }, + "post_id": { + "name": "post_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "url": { + "name": "url", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "width": { + "name": "width", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "height": { + "name": "height", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "thumbnail_type": { + "name": "thumbnail_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "thumbnail_url": { + "name": "thumbnail_url", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "thumbnail_width": { + "name": "thumbnail_width", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "thumbnail_height": { + "name": "thumbnail_height", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "created": { + "name": "created", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "CURRENT_TIMESTAMP" + }, + "thumbnail_cleaned": { + "name": "thumbnail_cleaned", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + } + }, + "indexes": { + "media_post_id_index": { + "name": "media_post_id_index", + "columns": [ + { + "expression": "post_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "media_post_id_posts_id_fk": { + "name": "media_post_id_posts_id_fk", + "tableFrom": "media", + "tableTo": "posts", + "columnsFrom": [ + "post_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.mentions": { + "name": "mentions", + "schema": "", + "columns": { + "post_id": { + "name": "post_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "account_id": { + "name": "account_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "mentions_post_id_account_id_index": { + "name": "mentions_post_id_account_id_index", + "columns": [ + { + "expression": "post_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "account_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "mentions_post_id_posts_id_fk": { + "name": "mentions_post_id_posts_id_fk", + "tableFrom": "mentions", + "tableTo": "posts", + "columnsFrom": [ + "post_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "mentions_account_id_accounts_id_fk": { + "name": "mentions_account_id_accounts_id_fk", + "tableFrom": "mentions", + "tableTo": "accounts", + "columnsFrom": [ + "account_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "mentions_post_id_account_id_pk": { + "name": "mentions_post_id_account_id_pk", + "columns": [ + "post_id", + "account_id" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.mutes": { + "name": "mutes", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true + }, + "account_id": { + "name": "account_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "muted_account_id": { + "name": "muted_account_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "notifications": { + "name": "notifications", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "duration": { + "name": "duration", + "type": "interval", + "primaryKey": false, + "notNull": false + }, + "created": { + "name": "created", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "CURRENT_TIMESTAMP" + } + }, + "indexes": { + "mutes_account_id_index": { + "name": "mutes_account_id_index", + "columns": [ + { + "expression": "account_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "mutes_account_id_accounts_id_fk": { + "name": "mutes_account_id_accounts_id_fk", + "tableFrom": "mutes", + "tableTo": "accounts", + "columnsFrom": [ + "account_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "mutes_muted_account_id_accounts_id_fk": { + "name": "mutes_muted_account_id_accounts_id_fk", + "tableFrom": "mutes", + "tableTo": "accounts", + "columnsFrom": [ + "muted_account_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "mutes_account_id_muted_account_id_unique": { + "name": "mutes_account_id_muted_account_id_unique", + "nullsNotDistinct": false, + "columns": [ + "account_id", + "muted_account_id" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.notification_groups": { + "name": "notification_groups", + "schema": "", + "columns": { + "group_key": { + "name": "group_key", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "account_owner_id": { + "name": "account_owner_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "type": { + "name": "type", + "type": "notification_type", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "target_post_id": { + "name": "target_post_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "notifications_count": { + "name": "notifications_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "most_recent_notification_id": { + "name": "most_recent_notification_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "sample_account_ids": { + "name": "sample_account_ids", + "type": "uuid[]", + "primaryKey": false, + "notNull": true, + "default": "'{}'::uuid[]" + }, + "latest_page_notification_at": { + "name": "latest_page_notification_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "page_min_id": { + "name": "page_min_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "page_max_id": { + "name": "page_max_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "created": { + "name": "created", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "CURRENT_TIMESTAMP" + }, + "updated": { + "name": "updated", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "CURRENT_TIMESTAMP" + } + }, + "indexes": { + "notification_groups_account_owner_id_updated_index": { + "name": "notification_groups_account_owner_id_updated_index", + "columns": [ + { + "expression": "account_owner_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "updated", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "notification_groups_account_owner_id_type_index": { + "name": "notification_groups_account_owner_id_type_index", + "columns": [ + { + "expression": "account_owner_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "type", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "notification_groups_account_owner_id_account_owners_id_fk": { + "name": "notification_groups_account_owner_id_account_owners_id_fk", + "tableFrom": "notification_groups", + "tableTo": "account_owners", + "columnsFrom": [ + "account_owner_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "notification_groups_target_post_id_posts_id_fk": { + "name": "notification_groups_target_post_id_posts_id_fk", + "tableFrom": "notification_groups", + "tableTo": "posts", + "columnsFrom": [ + "target_post_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "notification_groups_most_recent_notification_id_notifications_id_fk": { + "name": "notification_groups_most_recent_notification_id_notifications_id_fk", + "tableFrom": "notification_groups", + "tableTo": "notifications", + "columnsFrom": [ + "most_recent_notification_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.notifications": { + "name": "notifications", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true + }, + "account_owner_id": { + "name": "account_owner_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "type": { + "name": "type", + "type": "notification_type", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "actor_account_id": { + "name": "actor_account_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "target_post_id": { + "name": "target_post_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "target_account_id": { + "name": "target_account_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "target_poll_id": { + "name": "target_poll_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "group_key": { + "name": "group_key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created": { + "name": "created", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "CURRENT_TIMESTAMP" + }, + "read_at": { + "name": "read_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "notifications_account_owner_id_created_index": { + "name": "notifications_account_owner_id_created_index", + "columns": [ + { + "expression": "account_owner_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "notifications_account_owner_id_read_at_index": { + "name": "notifications_account_owner_id_read_at_index", + "columns": [ + { + "expression": "account_owner_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "read_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "notifications_group_key_index": { + "name": "notifications_group_key_index", + "columns": [ + { + "expression": "group_key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "notifications_created_index": { + "name": "notifications_created_index", + "columns": [ + { + "expression": "created", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "notifications_account_owner_id_account_owners_id_fk": { + "name": "notifications_account_owner_id_account_owners_id_fk", + "tableFrom": "notifications", + "tableTo": "account_owners", + "columnsFrom": [ + "account_owner_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "notifications_actor_account_id_accounts_id_fk": { + "name": "notifications_actor_account_id_accounts_id_fk", + "tableFrom": "notifications", + "tableTo": "accounts", + "columnsFrom": [ + "actor_account_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "notifications_target_post_id_posts_id_fk": { + "name": "notifications_target_post_id_posts_id_fk", + "tableFrom": "notifications", + "tableTo": "posts", + "columnsFrom": [ + "target_post_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "notifications_target_account_id_accounts_id_fk": { + "name": "notifications_target_account_id_accounts_id_fk", + "tableFrom": "notifications", + "tableTo": "accounts", + "columnsFrom": [ + "target_account_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "notifications_target_poll_id_polls_id_fk": { + "name": "notifications_target_poll_id_polls_id_fk", + "tableFrom": "notifications", + "tableTo": "polls", + "columnsFrom": [ + "target_poll_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.passkeys": { + "name": "passkeys", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "credential_email": { + "name": "credential_email", + "type": "varchar(254)", + "primaryKey": false, + "notNull": true + }, + "public_key": { + "name": "public_key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "counter": { + "name": "counter", + "type": "bigint", + "primaryKey": false, + "notNull": true + }, + "transports": { + "name": "transports", + "type": "text[]", + "primaryKey": false, + "notNull": true, + "default": "(ARRAY[]::text[])" + }, + "device_type": { + "name": "device_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "backed_up": { + "name": "backed_up", + "type": "boolean", + "primaryKey": false, + "notNull": true + }, + "nickname": { + "name": "nickname", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "last_used": { + "name": "last_used", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created": { + "name": "created", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "CURRENT_TIMESTAMP" + } + }, + "indexes": {}, + "foreignKeys": { + "passkeys_credential_email_credentials_email_fk": { + "name": "passkeys_credential_email_credentials_email_fk", + "tableFrom": "passkeys", + "tableTo": "credentials", + "columnsFrom": [ + "credential_email" + ], + "columnsTo": [ + "email" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.pinned_posts": { + "name": "pinned_posts", + "schema": "", + "columns": { + "index": { + "name": "index", + "type": "bigserial", + "primaryKey": true, + "notNull": true + }, + "post_id": { + "name": "post_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "account_id": { + "name": "account_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "created": { + "name": "created", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "CURRENT_TIMESTAMP" + } + }, + "indexes": { + "pinned_posts_account_id_post_id_index": { + "name": "pinned_posts_account_id_post_id_index", + "columns": [ + { + "expression": "account_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "post_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "pinned_posts_account_id_accounts_id_fk": { + "name": "pinned_posts_account_id_accounts_id_fk", + "tableFrom": "pinned_posts", + "tableTo": "accounts", + "columnsFrom": [ + "account_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "pinned_posts_post_id_account_id_posts_id_actor_id_fk": { + "name": "pinned_posts_post_id_account_id_posts_id_actor_id_fk", + "tableFrom": "pinned_posts", + "tableTo": "posts", + "columnsFrom": [ + "post_id", + "account_id" + ], + "columnsTo": [ + "id", + "actor_id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "pinned_posts_post_id_account_id_unique": { + "name": "pinned_posts_post_id_account_id_unique", + "nullsNotDistinct": false, + "columns": [ + "post_id", + "account_id" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.poll_options": { + "name": "poll_options", + "schema": "", + "columns": { + "poll_id": { + "name": "poll_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "index": { + "name": "index", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "votes_count": { + "name": "votes_count", + "type": "bigint", + "primaryKey": false, + "notNull": true, + "default": 0 + } + }, + "indexes": { + "poll_options_poll_id_index_index": { + "name": "poll_options_poll_id_index_index", + "columns": [ + { + "expression": "poll_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "index", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "poll_options_poll_id_polls_id_fk": { + "name": "poll_options_poll_id_polls_id_fk", + "tableFrom": "poll_options", + "tableTo": "polls", + "columnsFrom": [ + "poll_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "poll_options_poll_id_index_pk": { + "name": "poll_options_poll_id_index_pk", + "columns": [ + "poll_id", + "index" + ] + } + }, + "uniqueConstraints": { + "poll_options_poll_id_title_unique": { + "name": "poll_options_poll_id_title_unique", + "nullsNotDistinct": false, + "columns": [ + "poll_id", + "title" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.poll_votes": { + "name": "poll_votes", + "schema": "", + "columns": { + "poll_id": { + "name": "poll_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "option_index": { + "name": "option_index", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "account_id": { + "name": "account_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "created": { + "name": "created", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "CURRENT_TIMESTAMP" + } + }, + "indexes": { + "poll_votes_poll_id_account_id_index": { + "name": "poll_votes_poll_id_account_id_index", + "columns": [ + { + "expression": "poll_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "account_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "poll_votes_poll_id_polls_id_fk": { + "name": "poll_votes_poll_id_polls_id_fk", + "tableFrom": "poll_votes", + "tableTo": "polls", + "columnsFrom": [ + "poll_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "poll_votes_account_id_accounts_id_fk": { + "name": "poll_votes_account_id_accounts_id_fk", + "tableFrom": "poll_votes", + "tableTo": "accounts", + "columnsFrom": [ + "account_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "poll_votes_poll_id_option_index_poll_options_poll_id_index_fk": { + "name": "poll_votes_poll_id_option_index_poll_options_poll_id_index_fk", + "tableFrom": "poll_votes", + "tableTo": "poll_options", + "columnsFrom": [ + "poll_id", + "option_index" + ], + "columnsTo": [ + "poll_id", + "index" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "poll_votes_poll_id_option_index_account_id_pk": { + "name": "poll_votes_poll_id_option_index_account_id_pk", + "columns": [ + "poll_id", + "option_index", + "account_id" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.polls": { + "name": "polls", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true + }, + "multiple": { + "name": "multiple", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "voters_count": { + "name": "voters_count", + "type": "bigint", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "expires": { + "name": "expires", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "created": { + "name": "created", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "CURRENT_TIMESTAMP" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.posts": { + "name": "posts", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true + }, + "iri": { + "name": "iri", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "type": { + "name": "type", + "type": "post_type", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "actor_id": { + "name": "actor_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "application_id": { + "name": "application_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "reply_target_id": { + "name": "reply_target_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "sharing_id": { + "name": "sharing_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "quote_target_id": { + "name": "quote_target_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "quote_target_iri": { + "name": "quote_target_iri", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "quote_state": { + "name": "quote_state", + "type": "quote_state", + "typeSchema": "public", + "primaryKey": false, + "notNull": false + }, + "quote_authorization_iri": { + "name": "quote_authorization_iri", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "quote_approval_policy": { + "name": "quote_approval_policy", + "type": "quote_approval_policy", + "typeSchema": "public", + "primaryKey": false, + "notNull": false, + "default": "'public'" + }, + "visibility": { + "name": "visibility", + "type": "post_visibility", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "summary": { + "name": "summary", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "content_html": { + "name": "content_html", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "content": { + "name": "content", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "poll_id": { + "name": "poll_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "language": { + "name": "language", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "tags": { + "name": "tags", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'::jsonb" + }, + "emojis": { + "name": "emojis", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'::jsonb" + }, + "sensitive": { + "name": "sensitive", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "url": { + "name": "url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "preview_card": { + "name": "preview_card", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "replies_count": { + "name": "replies_count", + "type": "bigint", + "primaryKey": false, + "notNull": false, + "default": 0 + }, + "shares_count": { + "name": "shares_count", + "type": "bigint", + "primaryKey": false, + "notNull": false, + "default": 0 + }, + "likes_count": { + "name": "likes_count", + "type": "bigint", + "primaryKey": false, + "notNull": false, + "default": 0 + }, + "quotes_count": { + "name": "quotes_count", + "type": "bigint", + "primaryKey": false, + "notNull": false, + "default": 0 + }, + "idempotence_key": { + "name": "idempotence_key", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "published": { + "name": "published", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "updated": { + "name": "updated", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "CURRENT_TIMESTAMP" + } + }, + "indexes": { + "posts_sharing_id_index": { + "name": "posts_sharing_id_index", + "columns": [ + { + "expression": "sharing_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "posts_actor_id_index": { + "name": "posts_actor_id_index", + "columns": [ + { + "expression": "actor_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "posts_actor_id_sharing_id_index": { + "name": "posts_actor_id_sharing_id_index", + "columns": [ + { + "expression": "actor_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "sharing_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "posts_reply_target_id_index": { + "name": "posts_reply_target_id_index", + "columns": [ + { + "expression": "reply_target_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "posts_actor_id_reply_target_id_index": { + "name": "posts_actor_id_reply_target_id_index", + "columns": [ + { + "expression": "actor_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "reply_target_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "posts_quote_target_id_index": { + "name": "posts_quote_target_id_index", + "columns": [ + { + "expression": "quote_target_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"posts\".\"quote_target_id\" is not null", + "concurrently": false, + "method": "btree", + "with": {} + }, + "posts_visibility_actor_id_index": { + "name": "posts_visibility_actor_id_index", + "columns": [ + { + "expression": "visibility", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "actor_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "posts_visibility_actor_id_sharing_id_index": { + "name": "posts_visibility_actor_id_sharing_id_index", + "columns": [ + { + "expression": "visibility", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "actor_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "sharing_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"posts\".\"sharing_id\" is not null", + "concurrently": false, + "method": "btree", + "with": {} + }, + "posts_visibility_actor_id_reply_target_id_index": { + "name": "posts_visibility_actor_id_reply_target_id_index", + "columns": [ + { + "expression": "visibility", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "actor_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "reply_target_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"posts\".\"reply_target_id\" is not null", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "posts_actor_id_accounts_id_fk": { + "name": "posts_actor_id_accounts_id_fk", + "tableFrom": "posts", + "tableTo": "accounts", + "columnsFrom": [ + "actor_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "posts_application_id_applications_id_fk": { + "name": "posts_application_id_applications_id_fk", + "tableFrom": "posts", + "tableTo": "applications", + "columnsFrom": [ + "application_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "posts_reply_target_id_posts_id_fk": { + "name": "posts_reply_target_id_posts_id_fk", + "tableFrom": "posts", + "tableTo": "posts", + "columnsFrom": [ + "reply_target_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "posts_sharing_id_posts_id_fk": { + "name": "posts_sharing_id_posts_id_fk", + "tableFrom": "posts", + "tableTo": "posts", + "columnsFrom": [ + "sharing_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "posts_quote_target_id_posts_id_fk": { + "name": "posts_quote_target_id_posts_id_fk", + "tableFrom": "posts", + "tableTo": "posts", + "columnsFrom": [ + "quote_target_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "posts_poll_id_polls_id_fk": { + "name": "posts_poll_id_polls_id_fk", + "tableFrom": "posts", + "tableTo": "polls", + "columnsFrom": [ + "poll_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "posts_iri_unique": { + "name": "posts_iri_unique", + "nullsNotDistinct": false, + "columns": [ + "iri" + ] + }, + "posts_id_actor_id_unique": { + "name": "posts_id_actor_id_unique", + "nullsNotDistinct": false, + "columns": [ + "id", + "actor_id" + ] + }, + "posts_poll_id_unique": { + "name": "posts_poll_id_unique", + "nullsNotDistinct": false, + "columns": [ + "poll_id" + ] + }, + "posts_actor_id_sharing_id_unique": { + "name": "posts_actor_id_sharing_id_unique", + "nullsNotDistinct": false, + "columns": [ + "actor_id", + "sharing_id" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.reactions": { + "name": "reactions", + "schema": "", + "columns": { + "post_id": { + "name": "post_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "account_id": { + "name": "account_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "emoji": { + "name": "emoji", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "custom_emoji": { + "name": "custom_emoji", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "emoji_iri": { + "name": "emoji_iri", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created": { + "name": "created", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "CURRENT_TIMESTAMP" + } + }, + "indexes": { + "reactions_post_id_index": { + "name": "reactions_post_id_index", + "columns": [ + { + "expression": "post_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "reactions_post_id_account_id_index": { + "name": "reactions_post_id_account_id_index", + "columns": [ + { + "expression": "post_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "account_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "reactions_created_index": { + "name": "reactions_created_index", + "columns": [ + { + "expression": "created", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "reactions_post_id_posts_id_fk": { + "name": "reactions_post_id_posts_id_fk", + "tableFrom": "reactions", + "tableTo": "posts", + "columnsFrom": [ + "post_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "reactions_account_id_accounts_id_fk": { + "name": "reactions_account_id_accounts_id_fk", + "tableFrom": "reactions", + "tableTo": "accounts", + "columnsFrom": [ + "account_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "reactions_post_id_account_id_emoji_pk": { + "name": "reactions_post_id_account_id_emoji_pk", + "columns": [ + "post_id", + "account_id", + "emoji" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.remote_reply_scrape_jobs": { + "name": "remote_reply_scrape_jobs", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true + }, + "post_id": { + "name": "post_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "post_iri": { + "name": "post_iri", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "replies_iri": { + "name": "replies_iri", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "base_url": { + "name": "base_url", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "origin_host": { + "name": "origin_host", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "depth": { + "name": "depth", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "status": { + "name": "status", + "type": "remote_reply_scrape_job_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "attempts": { + "name": "attempts", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "fetched_items": { + "name": "fetched_items", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "next_attempt_at": { + "name": "next_attempt_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "CURRENT_TIMESTAMP" + }, + "error_message": { + "name": "error_message", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created": { + "name": "created", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "CURRENT_TIMESTAMP" + }, + "started_at": { + "name": "started_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "completed_at": { + "name": "completed_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "updated": { + "name": "updated", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "CURRENT_TIMESTAMP" + } + }, + "indexes": { + "remote_reply_scrape_jobs_claim_index": { + "name": "remote_reply_scrape_jobs_claim_index", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "next_attempt_at", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "remote_reply_scrape_jobs_origin_claim_index": { + "name": "remote_reply_scrape_jobs_origin_claim_index", + "columns": [ + { + "expression": "origin_host", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "next_attempt_at", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "remote_reply_scrape_jobs_stale_processing_index": { + "name": "remote_reply_scrape_jobs_stale_processing_index", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "updated", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "remote_reply_scrape_jobs_post_id_posts_id_fk": { + "name": "remote_reply_scrape_jobs_post_id_posts_id_fk", + "tableFrom": "remote_reply_scrape_jobs", + "tableTo": "posts", + "columnsFrom": [ + "post_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "remote_reply_scrape_jobs_replies_iri_unique": { + "name": "remote_reply_scrape_jobs_replies_iri_unique", + "nullsNotDistinct": false, + "columns": [ + "replies_iri" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.remote_reply_scrape_origins": { + "name": "remote_reply_scrape_origins", + "schema": "", + "columns": { + "origin_host": { + "name": "origin_host", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "next_request_at": { + "name": "next_request_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "CURRENT_TIMESTAMP" + }, + "last_request_at": { + "name": "last_request_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "processing_job_id": { + "name": "processing_job_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "processing_started_at": { + "name": "processing_started_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "updated": { + "name": "updated", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "CURRENT_TIMESTAMP" + } + }, + "indexes": { + "remote_reply_scrape_origins_next_request_at_index": { + "name": "remote_reply_scrape_origins_next_request_at_index", + "columns": [ + { + "expression": "next_request_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "remote_reply_scrape_origins_processing_job_id_index": { + "name": "remote_reply_scrape_origins_processing_job_id_index", + "columns": [ + { + "expression": "processing_job_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.reports": { + "name": "reports", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true + }, + "iri": { + "name": "iri", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "account_id": { + "name": "account_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "target_account_id": { + "name": "target_account_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "created": { + "name": "created", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "CURRENT_TIMESTAMP" + }, + "comment": { + "name": "comment", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "posts": { + "name": "posts", + "type": "uuid[]", + "primaryKey": false, + "notNull": true, + "default": "'{}'::uuid[]" + } + }, + "indexes": {}, + "foreignKeys": { + "reports_account_id_accounts_id_fk": { + "name": "reports_account_id_accounts_id_fk", + "tableFrom": "reports", + "tableTo": "accounts", + "columnsFrom": [ + "account_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "reports_target_account_id_accounts_id_fk": { + "name": "reports_target_account_id_accounts_id_fk", + "tableFrom": "reports", + "tableTo": "accounts", + "columnsFrom": [ + "target_account_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "reports_iri_unique": { + "name": "reports_iri_unique", + "nullsNotDistinct": false, + "columns": [ + "iri" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.timeline_posts": { + "name": "timeline_posts", + "schema": "", + "columns": { + "account_id": { + "name": "account_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "post_id": { + "name": "post_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "timeline_posts_account_id_post_id_index": { + "name": "timeline_posts_account_id_post_id_index", + "columns": [ + { + "expression": "account_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "post_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "timeline_posts_account_id_account_owners_id_fk": { + "name": "timeline_posts_account_id_account_owners_id_fk", + "tableFrom": "timeline_posts", + "tableTo": "account_owners", + "columnsFrom": [ + "account_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "timeline_posts_post_id_posts_id_fk": { + "name": "timeline_posts_post_id_posts_id_fk", + "tableFrom": "timeline_posts", + "tableTo": "posts", + "columnsFrom": [ + "post_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "timeline_posts_account_id_post_id_pk": { + "name": "timeline_posts_account_id_post_id_pk", + "columns": [ + "account_id", + "post_id" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.totps": { + "name": "totps", + "schema": "", + "columns": { + "issuer": { + "name": "issuer", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "label": { + "name": "label", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "algorithm": { + "name": "algorithm", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "digits": { + "name": "digits", + "type": "smallint", + "primaryKey": false, + "notNull": true + }, + "period": { + "name": "period", + "type": "smallint", + "primaryKey": false, + "notNull": true + }, + "secret": { + "name": "secret", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created": { + "name": "created", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "CURRENT_TIMESTAMP" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + } + }, + "enums": { + "public.account_type": { + "name": "account_type", + "schema": "public", + "values": [ + "Application", + "Group", + "Organization", + "Person", + "Service" + ] + }, + "public.cleanup_job_category": { + "name": "cleanup_job_category", + "schema": "public", + "values": [ + "cleanup_thumbnails" + ] + }, + "public.cleanup_job_status": { + "name": "cleanup_job_status", + "schema": "public", + "values": [ + "pending", + "processing", + "completed", + "failed", + "cancelled" + ] + }, + "public.grant_type": { + "name": "grant_type", + "schema": "public", + "values": [ + "authorization_code", + "client_credentials" + ] + }, + "public.import_job_category": { + "name": "import_job_category", + "schema": "public", + "values": [ + "following_accounts", + "lists", + "muted_accounts", + "blocked_accounts", + "bookmarks" + ] + }, + "public.import_job_status": { + "name": "import_job_status", + "schema": "public", + "values": [ + "pending", + "processing", + "completed", + "failed", + "cancelled" + ] + }, + "public.list_replies_policy": { + "name": "list_replies_policy", + "schema": "public", + "values": [ + "followed", + "list", + "none" + ] + }, + "public.marker_type": { + "name": "marker_type", + "schema": "public", + "values": [ + "notifications", + "home" + ] + }, + "public.notification_type": { + "name": "notification_type", + "schema": "public", + "values": [ + "mention", + "status", + "reblog", + "follow", + "follow_request", + "favourite", + "emoji_reaction", + "poll", + "update", + "admin.sign_up", + "admin.report", + "quote", + "quoted_update" + ] + }, + "public.post_type": { + "name": "post_type", + "schema": "public", + "values": [ + "Article", + "Note", + "Question" + ] + }, + "public.post_visibility": { + "name": "post_visibility", + "schema": "public", + "values": [ + "public", + "unlisted", + "private", + "direct" + ] + }, + "public.quote_approval_policy": { + "name": "quote_approval_policy", + "schema": "public", + "values": [ + "public", + "followers", + "nobody" + ] + }, + "public.quote_state": { + "name": "quote_state", + "schema": "public", + "values": [ + "pending", + "accepted", + "rejected", + "revoked", + "unauthorized" + ] + }, + "public.remote_reply_scrape_job_status": { + "name": "remote_reply_scrape_job_status", + "schema": "public", + "values": [ + "pending", + "processing", + "completed", + "failed" + ] + }, + "public.scope": { + "name": "scope", + "schema": "public", + "values": [ + "read", + "read:accounts", + "read:blocks", + "read:bookmarks", + "read:favourites", + "read:filters", + "read:follows", + "read:lists", + "read:mutes", + "read:notifications", + "read:search", + "read:statuses", + "write", + "write:accounts", + "write:blocks", + "write:bookmarks", + "write:conversations", + "write:favourites", + "write:filters", + "write:follows", + "write:lists", + "write:media", + "write:mutes", + "write:notifications", + "write:reports", + "write:statuses", + "follow", + "push", + "profile" + ] + }, + "public.theme_color": { + "name": "theme_color", + "schema": "public", + "values": [ + "amber", + "azure", + "blue", + "cyan", + "fuchsia", + "green", + "grey", + "indigo", + "jade", + "lime", + "orange", + "pink", + "pumpkin", + "purple", + "red", + "sand", + "slate", + "violet", + "yellow", + "zinc" + ] + } + }, + "schemas": {}, + "sequences": {}, + "roles": {}, + "policies": {}, + "views": {}, + "_meta": { + "columns": {}, + "schemas": {}, + "tables": {} + } +} \ No newline at end of file diff --git a/drizzle/meta/_journal.json b/drizzle/meta/_journal.json index 220cee16..9b3d2c27 100644 --- a/drizzle/meta/_journal.json +++ b/drizzle/meta/_journal.json @@ -617,6 +617,13 @@ "when": 1777428000000, "tag": "0087_quote_authorization_requirement", "breakpoints": true + }, + { + "idx": 88, + "version": "7", + "when": 1778782509038, + "tag": "0088_passkeys", + "breakpoints": true } ] -} +} \ No newline at end of file diff --git a/src/schema.ts b/src/schema.ts index 8810f493..a4f3f9a1 100644 --- a/src/schema.ts +++ b/src/schema.ts @@ -53,6 +53,36 @@ export const totps = pgTable("totps", { export type Totp = typeof totps.$inferSelect; export type NewTotp = typeof totps.$inferInsert; +export const passkeys = pgTable("passkeys", { + id: text("id").primaryKey(), + credentialEmail: varchar("credential_email", { length: 254 }) + .notNull() + .references(() => credentials.email, { onDelete: "cascade" }), + publicKey: text("public_key").notNull(), + counter: bigint("counter", { mode: "number" }).notNull(), + transports: text("transports") + .array() + .notNull() + .default(sql`(ARRAY[]::text[])`), + deviceType: text("device_type").notNull(), + backedUp: boolean("backed_up").notNull(), + nickname: text("nickname").notNull(), + lastUsed: timestamp("last_used", { withTimezone: true }), + created: timestamp("created", { withTimezone: true }) + .notNull() + .default(currentTimestamp), +}); + +export const passkeyRelations = relations(passkeys, ({ one }) => ({ + credential: one(credentials, { + fields: [passkeys.credentialEmail], + references: [credentials.email], + }), +})); + +export type Passkey = typeof passkeys.$inferSelect; +export type NewPasskey = typeof passkeys.$inferInsert; + export const accountTypeEnum = pgEnum("account_type", [ "Application", "Group", From 29205d923072dedb2c3acdedb1bf1887cf5153ed Mon Sep 17 00:00:00 2001 From: Hong Minhee Date: Fri, 15 May 2026 03:38:01 +0900 Subject: [PATCH 03/25] Add passkey model helpers MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Introduce `src/passkey.ts` as the seam between Hollo and @simplewebauthn/server. It exposes a small, intent-named API the upcoming HTTP routes can consume: - `getRpInfo(requestUrl)` derives the relying-party ID and origin from the incoming request, which is what makes the split-domain setup (HANDLE_HOST + WEB_ORIGIN) Just Work: the browser is always on the web origin during a ceremony, so its hostname is the rpID. - `buildRegistrationOptions` / `verifyRegistration` and the matching authentication pair wrap the library, enforce `residentKey: required` (so logins can be username-less) and `userVerification: required` (so a biometric / PIN gesture is always part of the ceremony), and swallow the library's "invalid response" throws into a plain `null` return so route handlers can respond with a 400 without a stack trace in the logs. - `encodePublicKey` / `decodePublicKey` keep the COSE blob portable through a `text` column without reaching for a custom Drizzle type. - `nicknameFromUserAgent` lights up sensible defaults ("macOS device", "iOS device", …) so the management UI doesn't open with an empty label. The companion test file was written first; it stubs the library's verify-* functions with `vi.mock`, so the round-trip of a real authenticator response isn't required to lock in the wrapper contract. Assisted-by: Claude Code:claude-opus-4-7 Assisted-by: Codex:gpt-5.5 --- src/passkey.test.ts | 387 ++++++++++++++++++++++++++++++++++++++++++++ src/passkey.ts | 228 ++++++++++++++++++++++++++ 2 files changed, 615 insertions(+) create mode 100644 src/passkey.test.ts create mode 100644 src/passkey.ts diff --git a/src/passkey.test.ts b/src/passkey.test.ts new file mode 100644 index 00000000..4dd061da --- /dev/null +++ b/src/passkey.test.ts @@ -0,0 +1,387 @@ +import { describe, expect, it, vi } from "vitest"; + +import { + buildAuthenticationOptions, + buildRegistrationOptions, + decodePublicKey, + encodePublicKey, + getRpInfo, + nicknameFromUserAgent, + userIdFromEmail, + verifyAuthentication, + verifyRegistration, +} from "./passkey"; + +vi.mock("@simplewebauthn/server", async () => { + const actual = await vi.importActual( + "@simplewebauthn/server", + ); + return { + ...actual, + verifyRegistrationResponse: vi.fn(), + verifyAuthenticationResponse: vi.fn(), + }; +}); + +const { + verifyRegistrationResponse: mockVerifyRegistration, + verifyAuthenticationResponse: mockVerifyAuthentication, +} = await import("@simplewebauthn/server"); + +describe("getRpInfo", () => { + it("returns hostname and origin from a same-origin URL", () => { + const info = getRpInfo("https://hollo.example/auth/passkeys/begin"); + expect(info).toEqual({ + rpID: "hollo.example", + origin: "https://hollo.example", + }); + }); + + it("strips port and path from the origin string but keeps an explicit port", () => { + const info = getRpInfo("http://localhost:3000/login/passkey/begin?x=1"); + expect(info).toEqual({ + rpID: "localhost", + origin: "http://localhost:3000", + }); + }); + + it("uses the request URL's hostname when split-domain (the web origin)", () => { + // In split-domain mode the login + admin pages are served from the web + // origin (e.g. dorikom.hollo.social) while ActivityPub lives on a + // separate host (ap.hollo.social). Passkey ceremonies always happen + // on the web origin, so the rpID comes from the request hostname. + const info = getRpInfo("https://dorikom.hollo.social/auth"); + expect(info.rpID).toBe("dorikom.hollo.social"); + expect(info.origin).toBe("https://dorikom.hollo.social"); + }); + + it("accepts a URL instance as well as a string", () => { + const info = getRpInfo(new URL("https://hollo.example/foo")); + expect(info.rpID).toBe("hollo.example"); + }); +}); + +describe("userIdFromEmail", () => { + it("returns a 32-byte SHA-256 digest", async () => { + const id = await userIdFromEmail("alice@example.com"); + expect(id).toBeInstanceOf(Uint8Array); + expect(id.length).toBe(32); + }); + + it("is deterministic and case-insensitive", async () => { + const a = await userIdFromEmail("Alice@Example.com"); + const b = await userIdFromEmail("alice@example.com"); + expect(a).toEqual(b); + }); + + it("differs for different emails", async () => { + const a = await userIdFromEmail("alice@example.com"); + const b = await userIdFromEmail("bob@example.com"); + expect(a).not.toEqual(b); + }); +}); + +describe("encodePublicKey / decodePublicKey", () => { + it("round-trips arbitrary byte sequences", () => { + const bytes = new Uint8Array([0, 1, 2, 250, 251, 252, 253, 254, 255]); + const encoded = encodePublicKey(bytes); + expect(typeof encoded).toBe("string"); + expect(decodePublicKey(encoded)).toEqual(bytes); + }); + + it("encodes to base64url (no +, /, or = padding)", () => { + const bytes = new Uint8Array([255, 255, 255, 255, 255]); + const encoded = encodePublicKey(bytes); + expect(encoded).not.toMatch(/[+/=]/); + }); + + it("decodes the same value when given a string with padding stripped", () => { + const bytes = new Uint8Array([1, 2, 3]); + const encoded = encodePublicKey(bytes); + expect(decodePublicKey(encoded)).toEqual(bytes); + }); +}); + +describe("nicknameFromUserAgent", () => { + it("falls back to 'Passkey' for null/empty input", () => { + expect(nicknameFromUserAgent(null)).toBe("Passkey"); + expect(nicknameFromUserAgent(undefined)).toBe("Passkey"); + expect(nicknameFromUserAgent("")).toBe("Passkey"); + expect(nicknameFromUserAgent(" ")).toBe("Passkey"); + }); + + it("labels common platforms", () => { + expect( + nicknameFromUserAgent( + "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15", + ), + ).toContain("macOS"); + expect( + nicknameFromUserAgent( + "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36", + ), + ).toContain("Windows"); + expect( + nicknameFromUserAgent( + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_0 like Mac OS X)", + ), + ).toContain("iOS"); + expect( + nicknameFromUserAgent( + "Mozilla/5.0 (Linux; Android 14; Pixel 8) AppleWebKit/537.36", + ), + ).toContain("Android"); + expect( + nicknameFromUserAgent( + "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36", + ), + ).toContain("Linux"); + }); + + it("returns 'Passkey' for an unrecognized UA", () => { + expect(nicknameFromUserAgent("some-random-thing/1.0")).toBe("Passkey"); + }); +}); + +describe("buildRegistrationOptions", () => { + it("populates rp, user, and authenticator selection from inputs", async () => { + const { options, challenge } = await buildRegistrationOptions({ + rpInfo: { rpID: "hollo.example", origin: "https://hollo.example" }, + email: "alice@example.com", + existingCredentials: [], + }); + expect(options.rp.id).toBe("hollo.example"); + expect(options.rp.name).toBe("Hollo"); + expect(options.user.name).toBe("alice@example.com"); + expect(options.user.displayName).toBe("alice@example.com"); + expect(typeof options.user.id).toBe("string"); + expect(options.challenge).toBe(challenge); + expect(options.authenticatorSelection?.residentKey).toBe("required"); + expect(options.authenticatorSelection?.userVerification).toBe("required"); + expect(options.attestation).toBe("none"); + }); + + it("threads excludeCredentials through", async () => { + const { options } = await buildRegistrationOptions({ + rpInfo: { rpID: "hollo.example", origin: "https://hollo.example" }, + email: "alice@example.com", + existingCredentials: [ + { id: "abc-123", transports: ["internal", "hybrid"] }, + { id: "def-456" }, + ], + }); + expect(options.excludeCredentials).toEqual([ + { id: "abc-123", transports: ["internal", "hybrid"], type: "public-key" }, + { id: "def-456", type: "public-key" }, + ]); + }); +}); + +describe("buildAuthenticationOptions", () => { + it("emits options with the configured rpID and no allowCredentials by default", async () => { + const { options, challenge } = await buildAuthenticationOptions({ + rpInfo: { rpID: "hollo.example", origin: "https://hollo.example" }, + }); + expect(options.rpId).toBe("hollo.example"); + expect(options.allowCredentials).toBeUndefined(); + expect(options.challenge).toBe(challenge); + expect(options.userVerification).toBe("required"); + }); + + it("threads allowedCredentials through", async () => { + const { options } = await buildAuthenticationOptions({ + rpInfo: { rpID: "hollo.example", origin: "https://hollo.example" }, + allowedCredentials: [{ id: "abc-123", transports: ["internal"] }], + }); + expect(options.allowCredentials).toEqual([ + { id: "abc-123", transports: ["internal"], type: "public-key" }, + ]); + }); +}); + +describe("verifyRegistration", () => { + it("returns the credential ready for insert when verification succeeds", async () => { + vi.mocked(mockVerifyRegistration).mockResolvedValueOnce({ + verified: true, + registrationInfo: { + fmt: "none", + aaguid: "00000000-0000-0000-0000-000000000000", + credential: { + id: "cred-id-1", + publicKey: new Uint8Array([1, 2, 3, 4]), + counter: 0, + }, + credentialType: "public-key", + attestationObject: new Uint8Array(0), + userVerified: true, + credentialDeviceType: "multiDevice", + credentialBackedUp: true, + origin: "https://hollo.example", + rpID: "hollo.example", + }, + }); + const result = await verifyRegistration({ + rpInfo: { rpID: "hollo.example", origin: "https://hollo.example" }, + response: { + id: "cred-id-1", + rawId: "cred-id-1", + type: "public-key", + clientExtensionResults: {}, + response: { + clientDataJSON: "", + attestationObject: "", + transports: ["internal"], + }, + }, + expectedChallenge: "challenge-abc", + }); + expect(result).toEqual({ + credentialId: "cred-id-1", + publicKey: new Uint8Array([1, 2, 3, 4]), + counter: 0, + transports: ["internal"], + deviceType: "multiDevice", + backedUp: true, + }); + }); + + it("returns null when SimpleWebAuthn rejects the response", async () => { + vi.mocked(mockVerifyRegistration).mockResolvedValueOnce({ + verified: false, + }); + const result = await verifyRegistration({ + rpInfo: { rpID: "hollo.example", origin: "https://hollo.example" }, + response: { + id: "x", + rawId: "x", + type: "public-key", + clientExtensionResults: {}, + response: { clientDataJSON: "", attestationObject: "" }, + }, + expectedChallenge: "challenge", + }); + expect(result).toBeNull(); + }); + + it("returns null when SimpleWebAuthn throws (malformed response, etc.)", async () => { + vi.mocked(mockVerifyRegistration).mockRejectedValueOnce( + new Error("Unexpected RP ID hash"), + ); + const result = await verifyRegistration({ + rpInfo: { rpID: "hollo.example", origin: "https://hollo.example" }, + response: { + id: "x", + rawId: "x", + type: "public-key", + clientExtensionResults: {}, + response: { clientDataJSON: "", attestationObject: "" }, + }, + expectedChallenge: "challenge", + }); + expect(result).toBeNull(); + }); +}); + +describe("verifyAuthentication", () => { + it("returns the new counter when verification succeeds", async () => { + vi.mocked(mockVerifyAuthentication).mockResolvedValueOnce({ + verified: true, + authenticationInfo: { + credentialID: "cred-id-1", + newCounter: 42, + userVerified: true, + credentialDeviceType: "multiDevice", + credentialBackedUp: true, + origin: "https://hollo.example", + rpID: "hollo.example", + }, + }); + const result = await verifyAuthentication({ + rpInfo: { rpID: "hollo.example", origin: "https://hollo.example" }, + response: { + id: "cred-id-1", + rawId: "cred-id-1", + type: "public-key", + clientExtensionResults: {}, + response: { + clientDataJSON: "", + authenticatorData: "", + signature: "", + }, + }, + expectedChallenge: "challenge-abc", + storedPasskey: { + id: "cred-id-1", + publicKey: encodePublicKey(new Uint8Array([1, 2, 3, 4])), + counter: 10, + transports: ["internal"], + }, + }); + expect(result).toEqual({ newCounter: 42 }); + }); + + it("returns null when SimpleWebAuthn rejects the assertion", async () => { + vi.mocked(mockVerifyAuthentication).mockResolvedValueOnce({ + verified: false, + authenticationInfo: { + credentialID: "cred-id-1", + newCounter: 0, + userVerified: false, + credentialDeviceType: "singleDevice", + credentialBackedUp: false, + origin: "https://hollo.example", + rpID: "hollo.example", + }, + }); + const result = await verifyAuthentication({ + rpInfo: { rpID: "hollo.example", origin: "https://hollo.example" }, + response: { + id: "cred-id-1", + rawId: "cred-id-1", + type: "public-key", + clientExtensionResults: {}, + response: { + clientDataJSON: "", + authenticatorData: "", + signature: "", + }, + }, + expectedChallenge: "challenge-abc", + storedPasskey: { + id: "cred-id-1", + publicKey: encodePublicKey(new Uint8Array([1, 2, 3, 4])), + counter: 10, + transports: ["internal"], + }, + }); + expect(result).toBeNull(); + }); + + it("returns null when SimpleWebAuthn throws (counter rollback, etc.)", async () => { + vi.mocked(mockVerifyAuthentication).mockRejectedValueOnce( + new Error("Response signature invalid"), + ); + const result = await verifyAuthentication({ + rpInfo: { rpID: "hollo.example", origin: "https://hollo.example" }, + response: { + id: "cred-id-1", + rawId: "cred-id-1", + type: "public-key", + clientExtensionResults: {}, + response: { + clientDataJSON: "", + authenticatorData: "", + signature: "", + }, + }, + expectedChallenge: "challenge-abc", + storedPasskey: { + id: "cred-id-1", + publicKey: encodePublicKey(new Uint8Array([1, 2, 3, 4])), + counter: 10, + transports: ["internal"], + }, + }); + expect(result).toBeNull(); + }); +}); diff --git a/src/passkey.ts b/src/passkey.ts new file mode 100644 index 00000000..f17668b4 --- /dev/null +++ b/src/passkey.ts @@ -0,0 +1,228 @@ +import { Buffer } from "node:buffer"; + +import { + generateAuthenticationOptions, + generateRegistrationOptions, + verifyAuthenticationResponse, + verifyRegistrationResponse, +} from "@simplewebauthn/server"; +import type { + AuthenticationResponseJSON, + AuthenticatorTransportFuture, + Base64URLString, + CredentialDeviceType, + PublicKeyCredentialCreationOptionsJSON, + PublicKeyCredentialRequestOptionsJSON, + RegistrationResponseJSON, +} from "@simplewebauthn/server"; + +import type { Passkey } from "./schema"; + +export const RP_NAME = "Hollo"; + +export interface RpInfo { + rpID: string; + origin: string; +} + +/** Derive WebAuthn relying-party info from an incoming request URL. */ +export function getRpInfo(requestUrl: string | URL): RpInfo { + const url = requestUrl instanceof URL ? requestUrl : new URL(requestUrl); + return { rpID: url.hostname, origin: url.origin }; +} + +/** Derive a stable, opaque WebAuthn user handle from the credential email. */ +export async function userIdFromEmail( + email: string, +): Promise> { + const data = new TextEncoder().encode(email.toLowerCase()); + const digest = await crypto.subtle.digest("SHA-256", data); + return new Uint8Array(digest); +} + +export interface BuildRegistrationOptionsInput { + rpInfo: RpInfo; + email: string; + existingCredentials: ReadonlyArray<{ + id: Base64URLString; + transports?: ReadonlyArray; + }>; +} + +export interface BuildRegistrationOptionsResult { + options: PublicKeyCredentialCreationOptionsJSON; + challenge: Base64URLString; +} + +export async function buildRegistrationOptions( + input: BuildRegistrationOptionsInput, +): Promise { + const userID = await userIdFromEmail(input.email); + const options = await generateRegistrationOptions({ + rpName: RP_NAME, + rpID: input.rpInfo.rpID, + userName: input.email, + userDisplayName: input.email, + userID, + attestationType: "none", + excludeCredentials: input.existingCredentials.map((c) => ({ + id: c.id, + transports: c.transports == null ? undefined : [...c.transports], + })), + authenticatorSelection: { + residentKey: "required", + userVerification: "required", + }, + }); + return { options, challenge: options.challenge }; +} + +export interface VerifyRegistrationInput { + rpInfo: RpInfo; + response: RegistrationResponseJSON; + expectedChallenge: Base64URLString; +} + +export interface VerifiedRegistration { + credentialId: Base64URLString; + publicKey: Uint8Array; + counter: number; + transports: AuthenticatorTransportFuture[]; + deviceType: CredentialDeviceType; + backedUp: boolean; +} + +export async function verifyRegistration( + input: VerifyRegistrationInput, +): Promise { + let verification: Awaited>; + try { + verification = await verifyRegistrationResponse({ + response: input.response, + expectedChallenge: input.expectedChallenge, + expectedOrigin: input.rpInfo.origin, + expectedRPID: input.rpInfo.rpID, + requireUserVerification: true, + }); + } catch { + // SimpleWebAuthn throws on malformed responses, origin / rpID + // mismatches, unsupported attestation, etc. Surface every failure + // mode uniformly so callers can return a plain 400. + return null; + } + if (!verification.verified || verification.registrationInfo == null) { + return null; + } + const { credential, credentialDeviceType, credentialBackedUp } = + verification.registrationInfo; + return { + credentialId: credential.id, + publicKey: credential.publicKey, + counter: credential.counter, + transports: input.response.response.transports ?? [], + deviceType: credentialDeviceType, + backedUp: credentialBackedUp, + }; +} + +export interface BuildAuthenticationOptionsInput { + rpInfo: RpInfo; + allowedCredentials?: ReadonlyArray<{ + id: Base64URLString; + transports?: ReadonlyArray; + }>; +} + +export interface BuildAuthenticationOptionsResult { + options: PublicKeyCredentialRequestOptionsJSON; + challenge: Base64URLString; +} + +export async function buildAuthenticationOptions( + input: BuildAuthenticationOptionsInput, +): Promise { + const options = await generateAuthenticationOptions({ + rpID: input.rpInfo.rpID, + allowCredentials: + input.allowedCredentials == null + ? undefined + : input.allowedCredentials.map((c) => ({ + id: c.id, + transports: c.transports == null ? undefined : [...c.transports], + })), + userVerification: "required", + }); + return { options, challenge: options.challenge }; +} + +export interface VerifyAuthenticationInput { + rpInfo: RpInfo; + response: AuthenticationResponseJSON; + expectedChallenge: Base64URLString; + storedPasskey: Pick; +} + +export interface VerifiedAuthentication { + newCounter: number; +} + +export async function verifyAuthentication( + input: VerifyAuthenticationInput, +): Promise { + let verification: Awaited>; + try { + verification = await verifyAuthenticationResponse({ + response: input.response, + expectedChallenge: input.expectedChallenge, + expectedOrigin: input.rpInfo.origin, + expectedRPID: input.rpInfo.rpID, + requireUserVerification: true, + credential: { + id: input.storedPasskey.id, + publicKey: decodePublicKey(input.storedPasskey.publicKey), + counter: input.storedPasskey.counter, + transports: input.storedPasskey + .transports as AuthenticatorTransportFuture[], + }, + }); + } catch { + return null; + } + if (!verification.verified) return null; + return { newCounter: verification.authenticationInfo.newCounter }; +} + +/** Encode a binary public-key blob for storage in a text column. */ +export function encodePublicKey(bytes: Uint8Array): string { + return Buffer.from(bytes).toString("base64url"); +} + +/** Decode a base64url-encoded public-key blob back into bytes. */ +export function decodePublicKey(encoded: string): Uint8Array { + const buf = Buffer.from(encoded, "base64url"); + const out = new Uint8Array(new ArrayBuffer(buf.byteLength)); + out.set(buf); + return out; +} + +const PLATFORM_LABELS: ReadonlyArray<{ pattern: RegExp; label: string }> = [ + // Order matters: iOS / iPadOS strings include "Mac OS X", so they go first. + { pattern: /\biPhone\b|\biPad\b|\biPod\b/, label: "iOS device" }, + { pattern: /\bAndroid\b/, label: "Android device" }, + { pattern: /\bMac OS X\b|\bMacintosh\b/, label: "macOS device" }, + { pattern: /\bWindows\b/, label: "Windows device" }, + { pattern: /\bLinux\b|\bX11\b/, label: "Linux device" }, +]; + +/** Best-effort friendly device label derived from a User-Agent string. */ +export function nicknameFromUserAgent( + userAgent: string | null | undefined, +): string { + if (userAgent == null) return "Passkey"; + const trimmed = userAgent.trim(); + if (trimmed === "") return "Passkey"; + for (const { pattern, label } of PLATFORM_LABELS) { + if (pattern.test(trimmed)) return label; + } + return "Passkey"; +} From 77a82d41f306e75fa03e2d7ba0c240186ba5523e Mon Sep 17 00:00:00 2001 From: Hong Minhee Date: Fri, 15 May 2026 06:31:14 +0900 Subject: [PATCH 04/25] Accept a passkey assertion as the second factor MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When a TOTP is enrolled, `loginRequired` previously required a matching `otp` cookie before letting any request through. This commit teaches it to also honour a matching `passkey` cookie written by the upcoming passkey login flow. A passkey is itself multi-factor — the user has the authenticator and confirms with a biometric or PIN gesture during the WebAuthn ceremony — so stacking TOTP on top would add no real security and just hurt the experience of signing in. Either cookie, bound to the current `login` value to prevent session-mixing, now satisfies the gate. The new `src/login.test.ts` mounts a throwaway Hono app behind the middleware and walks the four-way matrix: no TOTP, TOTP + otp, TOTP + passkey, TOTP + nothing, plus mismatched-cookie cases. Assisted-by: Claude Code:claude-opus-4-7 Assisted-by: Codex:gpt-5.5 --- src/login.test.ts | 103 ++++++++++++++++++++++++++++++++++++++++++++++ src/login.ts | 11 ++++- 2 files changed, 113 insertions(+), 1 deletion(-) create mode 100644 src/login.test.ts diff --git a/src/login.test.ts b/src/login.test.ts new file mode 100644 index 00000000..9b5e9f30 --- /dev/null +++ b/src/login.test.ts @@ -0,0 +1,103 @@ +import { Hono } from "hono"; +import { serializeSigned } from "hono/utils/cookie"; +import { beforeEach, describe, expect, it } from "vitest"; + +import { cleanDatabase } from "../tests/helpers"; +import db from "./db"; +import { SECRET_KEY } from "./env"; +import { loginRequired } from "./login"; +import { totps } from "./schema"; + +const app = new Hono(); +app.use(loginRequired); +app.get("/protected", (c) => c.text("ok")); + +async function signed(name: string, value: string): Promise { + return serializeSigned(name, value, SECRET_KEY!, { path: "/" }); +} + +describe("loginRequired", () => { + beforeEach(async () => { + await cleanDatabase(); + }); + + it("redirects to /login when no login cookie is present", async () => { + const response = await app.request("/protected"); + expect(response.status).toBe(302); + expect(response.headers.get("Location")).toMatch(/^\/login\?next=/); + }); + + it("lets the request through when login is set and no TOTP is enrolled", async () => { + const cookie = await signed("login", new Date().toISOString()); + const response = await app.request("/protected", { + headers: { Cookie: cookie }, + }); + expect(response.status).toBe(200); + expect(await response.text()).toBe("ok"); + }); + + describe("when a TOTP is enrolled", () => { + beforeEach(async () => { + await db.insert(totps).values({ + issuer: "Hollo", + label: "test@example.com", + algorithm: "SHA1", + digits: 6, + period: 30, + secret: "JBSWY3DPEHPK3PXP", + }); + }); + + it("redirects to /login/otp when login is set but no second factor", async () => { + const cookie = await signed("login", new Date().toISOString()); + const response = await app.request("/protected", { + headers: { Cookie: cookie }, + }); + expect(response.status).toBe(302); + expect(response.headers.get("Location")).toMatch(/^\/login\/otp\?next=/); + }); + + it("lets the request through when the otp cookie matches the login cookie", async () => { + const login = new Date().toISOString(); + const loginCookie = await signed("login", login); + const otpCookie = await signed("otp", `${login} totp`); + const response = await app.request("/protected", { + headers: { Cookie: `${loginCookie}; ${otpCookie}` }, + }); + expect(response.status).toBe(200); + }); + + it("lets the request through when the passkey cookie matches the login cookie", async () => { + const login = new Date().toISOString(); + const loginCookie = await signed("login", login); + const passkeyCookie = await signed("passkey", `${login} passkey`); + const response = await app.request("/protected", { + headers: { Cookie: `${loginCookie}; ${passkeyCookie}` }, + }); + expect(response.status).toBe(200); + }); + + it("redirects to /login/otp when the otp cookie does not match the login cookie", async () => { + const loginCookie = await signed("login", new Date().toISOString()); + const otpCookie = await signed("otp", "1999-01-01T00:00:00.000Z totp"); + const response = await app.request("/protected", { + headers: { Cookie: `${loginCookie}; ${otpCookie}` }, + }); + expect(response.status).toBe(302); + expect(response.headers.get("Location")).toMatch(/^\/login\/otp\?next=/); + }); + + it("redirects to /login/otp when the passkey cookie does not match the login cookie", async () => { + const loginCookie = await signed("login", new Date().toISOString()); + const passkeyCookie = await signed( + "passkey", + "1999-01-01T00:00:00.000Z passkey", + ); + const response = await app.request("/protected", { + headers: { Cookie: `${loginCookie}; ${passkeyCookie}` }, + }); + expect(response.status).toBe(302); + expect(response.headers.get("Location")).toMatch(/^\/login\/otp\?next=/); + }); + }); +}); diff --git a/src/login.ts b/src/login.ts index c9a7dc78..3e79840c 100644 --- a/src/login.ts +++ b/src/login.ts @@ -11,8 +11,17 @@ export const loginRequired = createMiddleware(async (c, next) => { } const totp = await db.query.totps.findFirst(); if (totp != null) { + // Either a TOTP code challenge or a passkey assertion bound to the + // same login session satisfies the second factor. A passkey is + // already multi-factor on its own (something the user has plus a + // user-verification gesture), so it stands in for TOTP rather than + // stacking on top of it. const otp = await getSignedCookie(c, SECRET_KEY, "otp"); - if (otp == null || otp === false || otp !== `${login} totp`) { + const passkey = await getSignedCookie(c, SECRET_KEY, "passkey"); + const otpOk = otp != null && otp !== false && otp === `${login} totp`; + const passkeyOk = + passkey != null && passkey !== false && passkey === `${login} passkey`; + if (!otpOk && !passkeyOk) { return c.redirect(`/login/otp?next=${encodeURIComponent(c.req.url)}`); } } From 83de0a88e9b942c2d974e17f8a6b3875716c09db Mon Sep 17 00:00:00 2001 From: Hong Minhee Date: Fri, 15 May 2026 06:46:48 +0900 Subject: [PATCH 05/25] Enroll and manage passkeys from the Auth page Wire the admin Auth page into the WebAuthn helpers added earlier. Three new routes sit alongside the existing TOTP forms: - POST /auth/passkeys/registration/begin returns the WebAuthn creation options and stashes the challenge in a signed cookie. The cookie body is `${challenge}|${expiresAt}|${login}`, which binds the ceremony to the current login session and lets the server enforce a 5-minute TTL even though Max-Age is only a browser hint. - POST /auth/passkeys/registration/finish reads the cookie back, verifies expiry / session binding / WebAuthn assertion, and inserts a row. The cookie is deleted unconditionally so a captured value can't be replayed within its TTL; duplicate credential ids return 409 instead of a false-positive 204; and the row's nickname falls back to a UA-derived label ("macOS device", etc.) when the user leaves the field blank. - POST /auth/passkeys/:id/delete drops the row and redirects back to /auth. The rendered AuthPage gains a Passkeys section below the TOTP section that lists enrolled passkeys with their nickname, the date they were added, and the date they were last used, plus a small form the upcoming client script will hook into for enrollment. CSRF is intentionally left as a project-wide concern: the existing /auth/2fa/disable, /logout, and the new passkey delete all share the same posture, so widening the scope here would just be inconsistent. A follow-up can apply a single token mechanism across all auth-management POSTs in one pass. Assisted-by: Claude Code:claude-opus-4-7 Assisted-by: Codex:gpt-5.5 --- src/pages/auth.test.ts | 423 +++++++++++++++++++++++++++++++++++++++++ src/pages/auth.tsx | 299 ++++++++++++++++++++++++++++- 2 files changed, 716 insertions(+), 6 deletions(-) create mode 100644 src/pages/auth.test.ts diff --git a/src/pages/auth.test.ts b/src/pages/auth.test.ts new file mode 100644 index 00000000..cab35e07 --- /dev/null +++ b/src/pages/auth.test.ts @@ -0,0 +1,423 @@ +import { hash } from "argon2"; +import { eq } from "drizzle-orm"; +import { beforeEach, describe, expect, it, vi } from "vitest"; + +import { cleanDatabase } from "../../tests/helpers"; +import { getLoginCookie } from "../../tests/helpers/web"; +import db from "../db"; +import { credentials, passkeys } from "../schema"; +import app from "./index"; + +vi.mock("../passkey", async () => { + const actual = + await vi.importActual("../passkey"); + return { + ...actual, + verifyRegistration: vi.fn(), + }; +}); + +const { verifyRegistration: mockVerifyRegistration } = + await import("../passkey"); + +const TEST_EMAIL = "owner@example.com"; + +async function seedCredential(): Promise { + await db.insert(credentials).values({ + email: TEST_EMAIL, + passwordHash: await hash("hunter2hunter2"), + }); +} + +describe("auth passkeys", () => { + beforeEach(async () => { + await cleanDatabase(); + vi.mocked(mockVerifyRegistration).mockClear(); + }); + + describe("POST /auth/passkeys/registration/begin", () => { + it("redirects to /setup if no credential is configured", async () => { + const cookie = await getLoginCookie(); + const response = await app.request("/auth/passkeys/registration/begin", { + method: "POST", + headers: { Cookie: cookie }, + }); + expect(response.status).toBe(302); + expect(response.headers.get("Location")).toBe("/setup"); + }); + + it("returns options JSON and sets a challenge cookie", async () => { + await seedCredential(); + const cookie = await getLoginCookie(); + const response = await app.request( + "http://hollo.test/auth/passkeys/registration/begin", + { + method: "POST", + headers: { Cookie: cookie }, + }, + ); + expect(response.status).toBe(200); + const body = (await response.json()) as Record & { + rp: { id: string; name: string }; + user: { name: string }; + challenge: string; + authenticatorSelection?: { + residentKey?: string; + userVerification?: string; + }; + }; + expect(body.rp.id).toBe("hollo.test"); + expect(body.rp.name).toBe("Hollo"); + expect(body.user.name).toBe(TEST_EMAIL); + expect(typeof body.challenge).toBe("string"); + expect(body.authenticatorSelection?.residentKey).toBe("required"); + expect(body.authenticatorSelection?.userVerification).toBe("required"); + const setCookie = response.headers.get("Set-Cookie") ?? ""; + expect(setCookie).toMatch(/passkey_reg=/); + expect(setCookie).toMatch(/HttpOnly/); + }); + + it("requires a valid login cookie", async () => { + await seedCredential(); + const response = await app.request( + "http://hollo.test/auth/passkeys/registration/begin", + { method: "POST" }, + ); + expect(response.status).toBe(302); + expect(response.headers.get("Location")).toMatch(/^\/login\?next=/); + }); + }); + + describe("POST /auth/passkeys/registration/finish", () => { + it("rejects requests without a challenge cookie", async () => { + await seedCredential(); + const cookie = await getLoginCookie(); + const response = await app.request( + "http://hollo.test/auth/passkeys/registration/finish", + { + method: "POST", + headers: { + Cookie: cookie, + "Content-Type": "application/json", + }, + body: JSON.stringify({ + registrationResponse: { + id: "fake", + rawId: "fake", + type: "public-key", + clientExtensionResults: {}, + response: { clientDataJSON: "", attestationObject: "" }, + }, + }), + }, + ); + expect(response.status).toBe(400); + const rows = await db.query.passkeys.findMany(); + expect(rows).toEqual([]); + }); + + it("inserts a passkey when verification succeeds", async () => { + await seedCredential(); + const cookie = await getLoginCookie(); + + // First: hit /begin to receive a challenge cookie. + const beginResponse = await app.request( + "http://hollo.test/auth/passkeys/registration/begin", + { + method: "POST", + headers: { Cookie: cookie }, + }, + ); + const challengeCookie = beginResponse.headers.get("Set-Cookie") ?? ""; + const passkeyRegCookie = challengeCookie.split(";")[0]; + + // Pretend the browser produced a valid response. + vi.mocked(mockVerifyRegistration).mockResolvedValueOnce({ + credentialId: "cred-id-abc", + publicKey: new Uint8Array([1, 2, 3, 4]), + counter: 0, + transports: ["internal", "hybrid"], + deviceType: "multiDevice", + backedUp: true, + }); + + const finishResponse = await app.request( + "http://hollo.test/auth/passkeys/registration/finish", + { + method: "POST", + headers: { + Cookie: `${cookie}; ${passkeyRegCookie}`, + "Content-Type": "application/json", + }, + body: JSON.stringify({ + nickname: "My Yubikey", + registrationResponse: { + id: "cred-id-abc", + rawId: "cred-id-abc", + type: "public-key", + clientExtensionResults: {}, + response: { clientDataJSON: "", attestationObject: "" }, + }, + }), + }, + ); + expect(finishResponse.status).toBe(204); + const setCookie = finishResponse.headers.get("Set-Cookie") ?? ""; + expect(setCookie).toMatch(/passkey_reg=;/); + + const rows = await db.query.passkeys.findMany(); + expect(rows).toHaveLength(1); + expect(rows[0]).toMatchObject({ + id: "cred-id-abc", + credentialEmail: TEST_EMAIL, + counter: 0, + transports: ["internal", "hybrid"], + deviceType: "multiDevice", + backedUp: true, + nickname: "My Yubikey", + }); + }); + + it("returns 409 when the same credential id is enrolled twice", async () => { + await seedCredential(); + const cookie = await getLoginCookie(); + const beginResponse = await app.request( + "http://hollo.test/auth/passkeys/registration/begin", + { method: "POST", headers: { Cookie: cookie } }, + ); + const challengeCookie = beginResponse.headers.get("Set-Cookie") ?? ""; + const passkeyRegCookie = challengeCookie.split(";")[0]; + + // Pre-seed the duplicate to simulate "this passkey is already on file." + await db.insert(passkeys).values({ + id: "duplicate-cred-id", + credentialEmail: TEST_EMAIL, + publicKey: "preexisting-key", + counter: 0, + transports: ["internal"], + deviceType: "multiDevice", + backedUp: true, + nickname: "Old entry", + }); + + vi.mocked(mockVerifyRegistration).mockResolvedValueOnce({ + credentialId: "duplicate-cred-id", + publicKey: new Uint8Array([9, 9, 9]), + counter: 0, + transports: ["internal"], + deviceType: "multiDevice", + backedUp: true, + }); + + const finishResponse = await app.request( + "http://hollo.test/auth/passkeys/registration/finish", + { + method: "POST", + headers: { + Cookie: `${cookie}; ${passkeyRegCookie}`, + "Content-Type": "application/json", + }, + body: JSON.stringify({ + registrationResponse: { + id: "duplicate-cred-id", + rawId: "duplicate-cred-id", + type: "public-key", + clientExtensionResults: {}, + response: { clientDataJSON: "", attestationObject: "" }, + }, + }), + }, + ); + expect(finishResponse.status).toBe(409); + const rows = await db.query.passkeys.findMany(); + expect(rows).toHaveLength(1); + expect(rows[0]?.nickname).toBe("Old entry"); + }); + + it("rejects challenge cookies bound to a different login session", async () => { + await seedCredential(); + const beginCookie = await getLoginCookie(); + const beginResponse = await app.request( + "http://hollo.test/auth/passkeys/registration/begin", + { method: "POST", headers: { Cookie: beginCookie } }, + ); + const challengeCookie = beginResponse.headers.get("Set-Cookie") ?? ""; + const passkeyRegCookie = challengeCookie.split(";")[0]; + + // Different "login" timestamp -> different signed cookie value. + await new Promise((r) => setTimeout(r, 5)); + const otherCookie = await getLoginCookie(); + + const finishResponse = await app.request( + "http://hollo.test/auth/passkeys/registration/finish", + { + method: "POST", + headers: { + Cookie: `${otherCookie}; ${passkeyRegCookie}`, + "Content-Type": "application/json", + }, + body: JSON.stringify({ + registrationResponse: { + id: "x", + rawId: "x", + type: "public-key", + clientExtensionResults: {}, + response: { clientDataJSON: "", attestationObject: "" }, + }, + }), + }, + ); + expect(finishResponse.status).toBe(400); + expect(mockVerifyRegistration).not.toHaveBeenCalled(); + }); + + it("returns 400 when verification fails", async () => { + await seedCredential(); + const cookie = await getLoginCookie(); + const beginResponse = await app.request( + "http://hollo.test/auth/passkeys/registration/begin", + { method: "POST", headers: { Cookie: cookie } }, + ); + const challengeCookie = beginResponse.headers.get("Set-Cookie") ?? ""; + const passkeyRegCookie = challengeCookie.split(";")[0]; + + vi.mocked(mockVerifyRegistration).mockResolvedValueOnce(null); + + const finishResponse = await app.request( + "http://hollo.test/auth/passkeys/registration/finish", + { + method: "POST", + headers: { + Cookie: `${cookie}; ${passkeyRegCookie}`, + "Content-Type": "application/json", + }, + body: JSON.stringify({ + registrationResponse: { + id: "x", + rawId: "x", + type: "public-key", + clientExtensionResults: {}, + response: { clientDataJSON: "", attestationObject: "" }, + }, + }), + }, + ); + expect(finishResponse.status).toBe(400); + const rows = await db.query.passkeys.findMany(); + expect(rows).toEqual([]); + }); + + it("derives a friendly default nickname from the User-Agent", async () => { + await seedCredential(); + const cookie = await getLoginCookie(); + const beginResponse = await app.request( + "http://hollo.test/auth/passkeys/registration/begin", + { method: "POST", headers: { Cookie: cookie } }, + ); + const challengeCookie = beginResponse.headers.get("Set-Cookie") ?? ""; + const passkeyRegCookie = challengeCookie.split(";")[0]; + + vi.mocked(mockVerifyRegistration).mockResolvedValueOnce({ + credentialId: "cred-id-def", + publicKey: new Uint8Array([5, 6, 7, 8]), + counter: 0, + transports: [], + deviceType: "singleDevice", + backedUp: false, + }); + + const finishResponse = await app.request( + "http://hollo.test/auth/passkeys/registration/finish", + { + method: "POST", + headers: { + Cookie: `${cookie}; ${passkeyRegCookie}`, + "Content-Type": "application/json", + "User-Agent": + "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15", + }, + body: JSON.stringify({ + registrationResponse: { + id: "cred-id-def", + rawId: "cred-id-def", + type: "public-key", + clientExtensionResults: {}, + response: { clientDataJSON: "", attestationObject: "" }, + }, + }), + }, + ); + expect(finishResponse.status).toBe(204); + const row = await db.query.passkeys.findFirst({ + where: eq(passkeys.id, "cred-id-def"), + }); + expect(row?.nickname).toBe("macOS device"); + }); + }); + + describe("POST /auth/passkeys/:id/delete", () => { + it("deletes the named passkey and redirects to /auth", async () => { + await seedCredential(); + await db.insert(passkeys).values({ + id: "cred-to-remove", + credentialEmail: TEST_EMAIL, + publicKey: "public-key-base64url", + counter: 0, + transports: ["internal"], + deviceType: "multiDevice", + backedUp: true, + nickname: "Old phone", + }); + const cookie = await getLoginCookie(); + const response = await app.request( + "/auth/passkeys/cred-to-remove/delete", + { + method: "POST", + headers: { Cookie: cookie }, + }, + ); + expect(response.status).toBe(302); + expect(response.headers.get("Location")).toBe("/auth"); + const rows = await db.query.passkeys.findMany(); + expect(rows).toEqual([]); + }); + + it("redirects to /auth even when the id does not exist", async () => { + await seedCredential(); + const cookie = await getLoginCookie(); + const response = await app.request( + "/auth/passkeys/does-not-exist/delete", + { + method: "POST", + headers: { Cookie: cookie }, + }, + ); + expect(response.status).toBe(302); + expect(response.headers.get("Location")).toBe("/auth"); + }); + }); + + describe("GET /auth", () => { + it("renders a Passkeys section with the enrolled passkeys", async () => { + await seedCredential(); + await db.insert(passkeys).values({ + id: "cred-listing", + credentialEmail: TEST_EMAIL, + publicKey: "public-key-base64url", + counter: 0, + transports: ["internal"], + deviceType: "multiDevice", + backedUp: true, + nickname: "My laptop", + }); + const cookie = await getLoginCookie(); + const response = await app.request("/auth", { + headers: { Cookie: cookie }, + }); + expect(response.status).toBe(200); + const body = await response.text(); + expect(body).toContain("Passkeys"); + expect(body).toContain("My laptop"); + }); + }); +}); diff --git a/src/pages/auth.tsx b/src/pages/auth.tsx index 5ae5784f..9136df90 100644 --- a/src/pages/auth.tsx +++ b/src/pages/auth.tsx @@ -1,22 +1,38 @@ import { zValidator } from "@hono/zod-validator"; import { getLogger } from "@logtape/logtape"; +import { eq } from "drizzle-orm"; import { Hono } from "hono"; +import { deleteCookie, getSignedCookie, setSignedCookie } from "hono/cookie"; import type { HOTP, TOTP } from "otpauth"; import { z } from "zod"; import { DashboardLayout } from "../components/DashboardLayout"; import db from "../db"; +import { SECRET_KEY } from "../env"; import { loginRequired } from "../login"; -import { type Totp, totps } from "../schema"; +import { + buildRegistrationOptions, + encodePublicKey, + getRpInfo, + nicknameFromUserAgent, + verifyRegistration, +} from "../passkey"; +import { type Passkey, passkeys, type Totp, totps } from "../schema"; const logger = getLogger(["hollo", "pages", "auth"]); +const PASSKEY_REG_COOKIE = "passkey_reg"; +const PASSKEY_REG_MAX_AGE_SECONDS = 5 * 60; + const auth = new Hono(); auth.use(loginRequired); auth.get("/", async (c) => { const totp = await db.query.totps.findFirst(); + const passkeysList = await db.query.passkeys.findMany({ + orderBy: (p, { desc }) => [desc(p.created)], + }); const open = c.req.query("open"); if (totp == null && open === "2fa") { const credential = await db.query.credentials.findFirst(); @@ -31,9 +47,9 @@ auth.get("/", async (c) => { secret: new Secret({ size: 20 }), }); logger.debug("The TOTP token: {token}", { token: totp.generate() }); - return c.html(); + return c.html(); } - return c.html(); + return c.html(); }); auth.post( @@ -46,9 +62,15 @@ auth.post( const form = c.req.valid("form"); const { HOTP, URI } = await import("otpauth"); const totp = URI.parse(form.totp); + const passkeysList = await db.query.passkeys.findMany({ + orderBy: (p, { desc }) => [desc(p.created)], + }); if (totp instanceof HOTP) { return c.html( - , + , ); } const validated = totp.validate({ @@ -57,7 +79,10 @@ auth.post( }); if (validated == null) { return c.html( - , + , ); } await db.insert(totps).values({ @@ -73,15 +98,165 @@ auth.post("/2fa/disable", async (c) => { return c.redirect("/auth"); }); +auth.post("/passkeys/registration/begin", async (c) => { + const login = await getSignedCookie(c, SECRET_KEY, "login"); + // loginRequired ran already, but TypeScript can't narrow that, and the + // double check costs nothing. + if (login == null || login === false) { + return c.redirect(`/login?next=${encodeURIComponent(c.req.url)}`); + } + const credential = await db.query.credentials.findFirst(); + if (credential == null) return c.redirect("/setup"); + const enrolled = await db.query.passkeys.findMany({ + columns: { id: true, transports: true }, + }); + const rpInfo = getRpInfo(c.req.url); + const { options, challenge } = await buildRegistrationOptions({ + rpInfo, + email: credential.email, + existingCredentials: enrolled.map((p) => ({ + id: p.id, + transports: p.transports as ReadonlyArray< + Parameters< + typeof buildRegistrationOptions + >[0]["existingCredentials"][number]["transports"] + > extends ReadonlyArray + ? T + : never, + })), + }); + const expiresAt = Date.now() + PASSKEY_REG_MAX_AGE_SECONDS * 1000; + // The signed cookie binds the challenge to (a) the current login + // session and (b) a server-enforced expiry, so a captured cookie + // can't be replayed after logout or after the TTL even though + // Max-Age is only a browser hint. The pipe character is not part + // of base64url (the challenge encoding), so it's safe as a + // separator. + const value = `${challenge}|${expiresAt.toString()}|${login}`; + await setSignedCookie(c, PASSKEY_REG_COOKIE, value, SECRET_KEY, { + httpOnly: true, + secure: rpInfo.origin.startsWith("https://"), + sameSite: "Strict", + path: "/auth/passkeys", + maxAge: PASSKEY_REG_MAX_AGE_SECONDS, + }); + return c.json(options); +}); + +const finishBodySchema = z.object({ + nickname: z.string().trim().max(80).optional(), + registrationResponse: z.object({ + id: z.string(), + rawId: z.string(), + type: z.literal("public-key"), + clientExtensionResults: z.record(z.string(), z.unknown()), + authenticatorAttachment: z.string().optional(), + response: z.object({ + clientDataJSON: z.string(), + attestationObject: z.string(), + authenticatorData: z.string().optional(), + publicKey: z.string().optional(), + publicKeyAlgorithm: z.number().optional(), + transports: z.array(z.string()).optional(), + }), + }), +}); + +auth.post( + "/passkeys/registration/finish", + zValidator("json", finishBodySchema), + async (c) => { + const login = await getSignedCookie(c, SECRET_KEY, "login"); + if (login == null || login === false) { + return c.redirect(`/login?next=${encodeURIComponent(c.req.url)}`); + } + const cookieValue = await getSignedCookie( + c, + SECRET_KEY, + PASSKEY_REG_COOKIE, + ); + // The cookie is always consumed, even on failure paths, to prevent + // replays of a captured value. + deleteCookie(c, PASSKEY_REG_COOKIE, { path: "/auth/passkeys" }); + if (cookieValue == null || cookieValue === false) { + return c.json({ error: "Missing or invalid challenge cookie." }, 400); + } + const parts = cookieValue.split("|"); + if (parts.length !== 3) { + return c.json({ error: "Malformed challenge cookie." }, 400); + } + const [challenge, expiresAtStr, boundLogin] = parts; + const expiresAt = Number.parseInt(expiresAtStr, 10); + if (!Number.isFinite(expiresAt) || Date.now() > expiresAt) { + return c.json({ error: "Challenge has expired." }, 400); + } + if (boundLogin !== login) { + return c.json( + { error: "Challenge is bound to a different login session." }, + 400, + ); + } + const credential = await db.query.credentials.findFirst(); + if (credential == null) return c.redirect("/setup"); + + const body = c.req.valid("json"); + const rpInfo = getRpInfo(c.req.url); + const verified = await verifyRegistration({ + rpInfo, + // SimpleWebAuthn validates the inner shape; the Zod schema above + // just rejects obviously wrong payloads. + // oxlint-disable-next-line typescript/no-explicit-any + response: body.registrationResponse as any, + expectedChallenge: challenge, + }); + if (verified == null) { + return c.json({ error: "Registration could not be verified." }, 400); + } + const trimmedNickname = body.nickname?.trim(); + const nickname = + trimmedNickname != null && trimmedNickname !== "" + ? trimmedNickname + : nicknameFromUserAgent(c.req.header("user-agent")); + const inserted = await db + .insert(passkeys) + .values({ + id: verified.credentialId, + credentialEmail: credential.email, + publicKey: encodePublicKey(verified.publicKey), + counter: verified.counter, + transports: verified.transports, + deviceType: verified.deviceType, + backedUp: verified.backedUp, + nickname, + }) + .onConflictDoNothing() + .returning({ id: passkeys.id }); + if (inserted.length === 0) { + return c.json( + { error: "This passkey is already enrolled on this account." }, + 409, + ); + } + return c.body(null, 204); + }, +); + +auth.post("/passkeys/:id/delete", async (c) => { + const id = c.req.param("id"); + await db.delete(passkeys).where(eq(passkeys.id, id)); + return c.redirect("/auth"); +}); + interface AuthPageProps { totp?: Totp; tfa?: { totp: TOTP | HOTP; error?: string; }; + passkeys: Passkey[]; } -async function AuthPage({ totp, tfa }: AuthPageProps) { +async function AuthPage({ totp, tfa, passkeys }: AuthPageProps) { return (
@@ -209,10 +384,122 @@ async function AuthPage({ totp, tfa }: AuthPageProps) { )} + +
+
+
+

+ Passkeys +

+

+ Sign in without a password using a device-bound key plus a + biometric or PIN. A passkey on its own counts as multi-factor + authentication, so the TOTP step is skipped. +

+
+ + +
+ + {passkeys.length === 0 ? ( +

+ No passkeys are enrolled yet. Enrolling one lets you sign in from + this browser without typing your password. +

+ ) : ( +
    + {passkeys.map((p) => ( +
  • +
    +

    + {p.nickname} +

    +

    + Added {formatDate(p.created)} + {p.lastUsed != null + ? ` · last used ${formatDate(p.lastUsed)}` + : " · never used"} +

    +
    +
    + +
    +
  • + ))} +
+ )} + +
+ + +
+ +

+

+
+
); } +function formatDate(value: Date): string { + // toISOString() produces a stable, locale-independent string; the browser + // can fancy this up later if needed. Using just the date portion keeps + // the list scannable. + return value.toISOString().slice(0, 10); +} + function qrCode(data: string): Promise { return new Promise((resolve, reject) => { const run = async () => { From 2b2ddfb8cddc5d4f884e07c7fb273c9c198d3f8f Mon Sep 17 00:00:00 2001 From: Hong Minhee Date: Fri, 15 May 2026 07:01:42 +0900 Subject: [PATCH 06/25] Sign in with a passkey Add a public-side passkey login flow alongside the existing email/password path: - POST /login/passkey/begin returns the WebAuthn request options (empty allowCredentials, so the browser surfaces any resident credential it knows about) and stashes the challenge in a signed cookie. The cookie body is `${challenge}|${expiresAt}` so the server enforces the 5-minute TTL even when Max-Age is just a browser hint. - POST /login/passkey/finish consumes the cookie unconditionally (so a captured value is good for at most one request), parses the JSON body manually after the cookie is already burnt so schema failures can't bypass the consumption, verifies the assertion against the stored credential, compare-and-sets the counter to defeat concurrent ceremonies racing over an old value, and on success writes both the `login` cookie and the `passkey` cookie that the middleware accepts as a TOTP stand-in. Response is `{ redirect }` JSON for the client script to follow. The redirect target is clamped to the request's origin via `safeNext`, which parses the candidate as a URL against the request URL and demands a matching origin. That neutralises open-redirect tricks like `/\evil.example/path` that browsers normalise into `//evil.example/path`. - LoginPage is now passkey-first: when at least one passkey is enrolled, a "Sign in with passkey" button is the primary action with the email/password form tucked behind a
toggle. The fallback is opened automatically when a previous password attempt failed so the error stays visible. A follow-up commit will replace the stateless challenge cookie with a `passkey_login_challenges` table so a captured assertion + cookie pair can't be replayed within the TTL even if a sync passkey reports `signCount == 0`. Tracked as the next task on the branch. Assisted-by: Claude Code:claude-opus-4-7 Assisted-by: Codex:gpt-5.5 --- src/pages/login.test.ts | 452 ++++++++++++++++++++++++++++++++++++++++ src/pages/login.tsx | 203 +++++++++++++++++- 2 files changed, 644 insertions(+), 11 deletions(-) create mode 100644 src/pages/login.test.ts diff --git a/src/pages/login.test.ts b/src/pages/login.test.ts new file mode 100644 index 00000000..c26dae73 --- /dev/null +++ b/src/pages/login.test.ts @@ -0,0 +1,452 @@ +import { eq } from "drizzle-orm"; +import { beforeEach, describe, expect, it, vi } from "vitest"; + +import { cleanDatabase } from "../../tests/helpers"; +import db from "../db"; +import { credentials, passkeys } from "../schema"; +import app from "./index"; + +vi.mock("../passkey", async () => { + const actual = + await vi.importActual("../passkey"); + return { + ...actual, + verifyAuthentication: vi.fn(), + }; +}); + +const { verifyAuthentication: mockVerifyAuthentication } = + await import("../passkey"); + +const TEST_EMAIL = "owner@example.com"; + +async function seedCredential(): Promise { + await db.insert(credentials).values({ + email: TEST_EMAIL, + passwordHash: "$argon2id$stub", + }); +} + +async function seedPasskey(id = "cred-id-login"): Promise { + await db.insert(passkeys).values({ + id, + credentialEmail: TEST_EMAIL, + publicKey: "base64url-public-key", + counter: 5, + transports: ["internal", "hybrid"], + deviceType: "multiDevice", + backedUp: true, + nickname: "My laptop", + }); +} + +describe("login passkeys", () => { + beforeEach(async () => { + await cleanDatabase(); + vi.mocked(mockVerifyAuthentication).mockClear(); + }); + + describe("GET /login", () => { + it("does not show the passkey button when none are enrolled", async () => { + const response = await app.request("/login"); + const body = await response.text(); + expect(response.status).toBe(200); + expect(body).not.toContain("Sign in with passkey"); + }); + + it("shows the passkey button when at least one is enrolled", async () => { + await seedCredential(); + await seedPasskey(); + const response = await app.request("/login"); + const body = await response.text(); + expect(response.status).toBe(200); + expect(body).toContain("Sign in with passkey"); + // The password form is still available behind a toggle. + expect(body).toContain("Sign in with password"); + }); + + it("opens the password form when the previous password attempt failed", async () => { + await seedCredential(); + await seedPasskey(); + const response = await app.request("/login", { + method: "POST", + body: new URLSearchParams({ + email: "wrong@example.com", + password: "wrong-password", + }), + headers: { "Content-Type": "application/x-www-form-urlencoded" }, + }); + const body = await response.text(); + // The
element rendering its `open` attribute means the + // password form is visible by default after a failed attempt. + expect(body).toMatch(/]*\bopen\b/); + expect(body).toContain("Invalid email or password."); + }); + }); + + describe("POST /login/passkey/begin", () => { + it("returns authn options and sets a challenge cookie", async () => { + const response = await app.request( + "http://hollo.test/login/passkey/begin", + { method: "POST" }, + ); + expect(response.status).toBe(200); + const body = (await response.json()) as { + rpId: string; + challenge: string; + userVerification?: string; + }; + expect(body.rpId).toBe("hollo.test"); + expect(typeof body.challenge).toBe("string"); + expect(body.userVerification).toBe("required"); + const setCookie = response.headers.get("Set-Cookie") ?? ""; + expect(setCookie).toMatch(/passkey_login=/); + expect(setCookie).toMatch(/HttpOnly/); + }); + }); + + describe("POST /login/passkey/finish", () => { + it("rejects requests without a challenge cookie", async () => { + const response = await app.request( + "http://hollo.test/login/passkey/finish", + { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + authenticationResponse: { + id: "cred-id-login", + rawId: "cred-id-login", + type: "public-key", + clientExtensionResults: {}, + response: { + clientDataJSON: "", + authenticatorData: "", + signature: "", + }, + }, + }), + }, + ); + expect(response.status).toBe(400); + }); + + it("rejects assertions for unknown credential ids", async () => { + const beginResponse = await app.request( + "http://hollo.test/login/passkey/begin", + { method: "POST" }, + ); + const challengeCookie = + beginResponse.headers.get("Set-Cookie")?.split(";")[0] ?? ""; + const response = await app.request( + "http://hollo.test/login/passkey/finish", + { + method: "POST", + headers: { + "Content-Type": "application/json", + Cookie: challengeCookie, + }, + body: JSON.stringify({ + authenticationResponse: { + id: "unknown-cred", + rawId: "unknown-cred", + type: "public-key", + clientExtensionResults: {}, + response: { + clientDataJSON: "", + authenticatorData: "", + signature: "", + }, + }, + }), + }, + ); + expect(response.status).toBe(400); + expect(mockVerifyAuthentication).not.toHaveBeenCalled(); + }); + + it("sets login and passkey cookies and returns a redirect target on success", async () => { + await seedCredential(); + await seedPasskey(); + vi.mocked(mockVerifyAuthentication).mockResolvedValueOnce({ + newCounter: 12, + }); + + const beginResponse = await app.request( + "http://hollo.test/login/passkey/begin", + { method: "POST" }, + ); + const challengeCookie = + beginResponse.headers.get("Set-Cookie")?.split(";")[0] ?? ""; + + const response = await app.request( + "http://hollo.test/login/passkey/finish", + { + method: "POST", + headers: { + "Content-Type": "application/json", + Cookie: challengeCookie, + }, + body: JSON.stringify({ + next: "/accounts", + authenticationResponse: { + id: "cred-id-login", + rawId: "cred-id-login", + type: "public-key", + clientExtensionResults: {}, + response: { + clientDataJSON: "", + authenticatorData: "", + signature: "", + }, + }, + }), + }, + ); + expect(response.status).toBe(200); + const body = (await response.json()) as { redirect: string }; + expect(body.redirect).toBe("/accounts"); + + const setCookie = response.headers.get("Set-Cookie") ?? ""; + expect(setCookie).toMatch(/login=/); + expect(setCookie).toMatch(/passkey=/); + // The transient challenge cookie is cleared. + expect(setCookie).toMatch(/passkey_login=;/); + + // counter and lastUsed are updated on the row. + const updated = await db.query.passkeys.findFirst({ + where: eq(passkeys.id, "cred-id-login"), + }); + expect(updated?.counter).toBe(12); + expect(updated?.lastUsed).not.toBeNull(); + }); + + it("returns 400 when verification fails and does not update the counter", async () => { + await seedCredential(); + await seedPasskey(); + vi.mocked(mockVerifyAuthentication).mockResolvedValueOnce(null); + + const beginResponse = await app.request( + "http://hollo.test/login/passkey/begin", + { method: "POST" }, + ); + const challengeCookie = + beginResponse.headers.get("Set-Cookie")?.split(";")[0] ?? ""; + + const response = await app.request( + "http://hollo.test/login/passkey/finish", + { + method: "POST", + headers: { + "Content-Type": "application/json", + Cookie: challengeCookie, + }, + body: JSON.stringify({ + authenticationResponse: { + id: "cred-id-login", + rawId: "cred-id-login", + type: "public-key", + clientExtensionResults: {}, + response: { + clientDataJSON: "", + authenticatorData: "", + signature: "", + }, + }, + }), + }, + ); + expect(response.status).toBe(400); + const row = await db.query.passkeys.findFirst({ + where: eq(passkeys.id, "cred-id-login"), + }); + expect(row?.counter).toBe(5); + expect(row?.lastUsed).toBeNull(); + }); + + it("returns 409 when a concurrent assertion has already advanced the counter", async () => { + await seedCredential(); + await seedPasskey(); + // Race: between the row SELECT and the compare-and-set UPDATE, + // another assertion bumps the counter forward. Using the verifier + // mock as a hook point lets us inject that mutation deterministically. + vi.mocked(mockVerifyAuthentication).mockImplementationOnce(async () => { + await db + .update(passkeys) + .set({ counter: 9 }) + .where(eq(passkeys.id, "cred-id-login")); + return { newCounter: 10 }; + }); + + const beginResponse = await app.request( + "http://hollo.test/login/passkey/begin", + { method: "POST" }, + ); + const challengeCookie = + beginResponse.headers.get("Set-Cookie")?.split(";")[0] ?? ""; + + const response = await app.request( + "http://hollo.test/login/passkey/finish", + { + method: "POST", + headers: { + "Content-Type": "application/json", + Cookie: challengeCookie, + }, + body: JSON.stringify({ + authenticationResponse: { + id: "cred-id-login", + rawId: "cred-id-login", + type: "public-key", + clientExtensionResults: {}, + response: { + clientDataJSON: "", + authenticatorData: "", + signature: "", + }, + }, + }), + }, + ); + expect(response.status).toBe(409); + const setCookie = response.headers.get("Set-Cookie") ?? ""; + // login / passkey cookies must NOT be set on a 409 path. + expect(setCookie).not.toMatch(/(^|;\s*)login=/); + expect(setCookie).not.toMatch(/(^|;\s*)passkey=/); + }); + + it("rejects a backslash-prefixed next URL (open-redirect guard)", async () => { + await seedCredential(); + await seedPasskey(); + vi.mocked(mockVerifyAuthentication).mockResolvedValueOnce({ + newCounter: 12, + }); + const beginResponse = await app.request( + "http://hollo.test/login/passkey/begin", + { method: "POST" }, + ); + const challengeCookie = + beginResponse.headers.get("Set-Cookie")?.split(";")[0] ?? ""; + + const response = await app.request( + "http://hollo.test/login/passkey/finish", + { + method: "POST", + headers: { + "Content-Type": "application/json", + Cookie: challengeCookie, + }, + body: JSON.stringify({ + next: "/\\evil.example/path", + authenticationResponse: { + id: "cred-id-login", + rawId: "cred-id-login", + type: "public-key", + clientExtensionResults: {}, + response: { + clientDataJSON: "", + authenticatorData: "", + signature: "", + }, + }, + }), + }, + ); + expect(response.status).toBe(200); + const body = (await response.json()) as { redirect: string }; + expect(body.redirect).toBe("/"); + }); + + it("rejects a stale challenge cookie even before the browser drops it", async () => { + await seedCredential(); + await seedPasskey(); + + // Reach into the in-memory state: ask /begin for a fresh challenge, + // capture the signed cookie, then time-travel past the TTL. + const beginResponse = await app.request( + "http://hollo.test/login/passkey/begin", + { method: "POST" }, + ); + const challengeCookie = + beginResponse.headers.get("Set-Cookie")?.split(";")[0] ?? ""; + + // Move past the 5-minute server-side expiry without waiting in real + // time. Date.now is mocked just for this assertion. + const realNow = Date.now.bind(Date); + const stub = vi + .spyOn(Date, "now") + .mockReturnValue(realNow() + 6 * 60_000); + try { + const response = await app.request( + "http://hollo.test/login/passkey/finish", + { + method: "POST", + headers: { + "Content-Type": "application/json", + Cookie: challengeCookie, + }, + body: JSON.stringify({ + authenticationResponse: { + id: "cred-id-login", + rawId: "cred-id-login", + type: "public-key", + clientExtensionResults: {}, + response: { + clientDataJSON: "", + authenticatorData: "", + signature: "", + }, + }, + }), + }, + ); + expect(response.status).toBe(400); + expect(mockVerifyAuthentication).not.toHaveBeenCalled(); + } finally { + stub.mockRestore(); + } + }); + + it("rejects an external next URL", async () => { + await seedCredential(); + await seedPasskey(); + vi.mocked(mockVerifyAuthentication).mockResolvedValueOnce({ + newCounter: 12, + }); + + const beginResponse = await app.request( + "http://hollo.test/login/passkey/begin", + { method: "POST" }, + ); + const challengeCookie = + beginResponse.headers.get("Set-Cookie")?.split(";")[0] ?? ""; + + const response = await app.request( + "http://hollo.test/login/passkey/finish", + { + method: "POST", + headers: { + "Content-Type": "application/json", + Cookie: challengeCookie, + }, + body: JSON.stringify({ + next: "https://evil.example/phish", + authenticationResponse: { + id: "cred-id-login", + rawId: "cred-id-login", + type: "public-key", + clientExtensionResults: {}, + response: { + clientDataJSON: "", + authenticatorData: "", + signature: "", + }, + }, + }), + }, + ); + expect(response.status).toBe(200); + const body = (await response.json()) as { redirect: string }; + expect(body.redirect).toBe("/"); + }); + }); +}); diff --git a/src/pages/login.tsx b/src/pages/login.tsx index 20c68aa1..d9397f61 100644 --- a/src/pages/login.tsx +++ b/src/pages/login.tsx @@ -1,7 +1,7 @@ import { zValidator } from "@hono/zod-validator"; -import { eq } from "drizzle-orm"; +import { and, eq } from "drizzle-orm"; import { Hono } from "hono"; -import { getSignedCookie, setSignedCookie } from "hono/cookie"; +import { deleteCookie, getSignedCookie, setSignedCookie } from "hono/cookie"; import { z } from "zod"; import { AuthCard } from "../components/AuthCard.tsx"; @@ -9,17 +9,48 @@ import { Layout } from "../components/Layout.tsx"; import { LoginForm } from "../components/LoginForm.tsx"; import { OtpForm } from "../components/OtpForm.tsx"; import { db } from "../db.ts"; -import { credentials } from "../schema.ts"; +import { + buildAuthenticationOptions, + getRpInfo, + verifyAuthentication, +} from "../passkey.ts"; +import { credentials, passkeys } from "../schema.ts"; // oxlint-disable-next-line typescript/dot-notation const SECRET_KEY = process.env["SECRET_KEY"]; if (SECRET_KEY == null) throw new Error("SECRET_KEY is required"); +const PASSKEY_LOGIN_COOKIE = "passkey_login"; +const PASSKEY_LOGIN_MAX_AGE_SECONDS = 5 * 60; + +/** + * Accept only same-origin paths so `next=` can't be hijacked into an open + * redirect. Browsers normalise backslashes to forward slashes during URL + * parsing (so `/\\evil/x` is treated like `//evil/x`), and historical + * implementations have been bitten by `\\` and `/\` prefixes — parsing + * against the current request origin and demanding the parsed origin match + * is the safest filter. + */ +function safeNext(value: unknown, requestUrl: string | URL): string { + if (typeof value !== "string" || value === "") return "/"; + let parsed: URL; + try { + parsed = new URL(value, requestUrl); + } catch { + return "/"; + } + const base = + requestUrl instanceof URL ? requestUrl : new URL(String(requestUrl)); + if (parsed.origin !== base.origin) return "/"; + return `${parsed.pathname}${parsed.search}${parsed.hash}`; +} + const login = new Hono(); -login.get("/", (c) => { +login.get("/", async (c) => { const next = c.req.query("next"); - return c.html(); + const passkeyCount = await db.$count(passkeys); + return c.html( 0} />); }); login.post("/", async (c) => { @@ -27,6 +58,8 @@ login.post("/", async (c) => { const email = form.get("email")?.toString(); const password = form.get("password")?.toString(); const next = form.get("next")?.toString(); + const passkeyCount = await db.$count(passkeys); + const passkeyEnrolled = passkeyCount > 0; if (email == null || password == null) { return c.html( { email: email == null ? "Email is required." : undefined, password: password == null ? "Password is required." : undefined, }} + passkeyEnrolled={passkeyEnrolled} />, 400, ); @@ -56,6 +90,7 @@ login.post("/", async (c) => { email: "Invalid email or password.", password: "Invalid email or password.", }} + passkeyEnrolled={passkeyEnrolled} />, 400, ); @@ -73,21 +108,53 @@ interface LoginPageProps { email?: string; password?: string; }; + passkeyEnrolled: boolean; } function LoginPage(props: LoginPageProps) { + const hasPasswordError = + props.errors?.email != null || props.errors?.password != null; return ( - + {props.passkeyEnrolled ? ( +
+ +

+

+ + Sign in with password instead + + +
+
+ ) : ( + + )}
); @@ -160,4 +227,118 @@ function OtpPage(props: OtpPageProps) { ); } +login.post("/passkey/begin", async (c) => { + const rpInfo = getRpInfo(c.req.url); + const { options, challenge } = await buildAuthenticationOptions({ rpInfo }); + const expiresAt = Date.now() + PASSKEY_LOGIN_MAX_AGE_SECONDS * 1000; + // Bind a server-side expiry into the signed cookie so a captured value + // can't be replayed after the TTL even though Max-Age is just a browser + // hint. Pipe isn't part of the base64url alphabet, so it's a safe + // separator from the challenge. + const value = `${challenge}|${expiresAt.toString()}`; + await setSignedCookie(c, PASSKEY_LOGIN_COOKIE, value, SECRET_KEY, { + httpOnly: true, + secure: rpInfo.origin.startsWith("https://"), + sameSite: "Strict", + path: "/login/passkey", + maxAge: PASSKEY_LOGIN_MAX_AGE_SECONDS, + }); + return c.json(options); +}); + +const passkeyFinishSchema = z.object({ + next: z.string().optional(), + authenticationResponse: z.object({ + id: z.string().min(1), + rawId: z.string(), + type: z.literal("public-key"), + clientExtensionResults: z.record(z.string(), z.unknown()), + authenticatorAttachment: z.string().optional(), + response: z.object({ + clientDataJSON: z.string(), + authenticatorData: z.string(), + signature: z.string(), + userHandle: z.string().optional(), + }), + }), +}); + +login.post("/passkey/finish", async (c) => { + // Read and consume the transient cookie before *any* other parsing so a + // malformed body or signature-failure still burns the cookie — preventing + // replay of the captured value against a freshly-crafted assertion. + const cookieValue = await getSignedCookie( + c, + SECRET_KEY, + PASSKEY_LOGIN_COOKIE, + ); + deleteCookie(c, PASSKEY_LOGIN_COOKIE, { path: "/login/passkey" }); + if (cookieValue == null || cookieValue === false) { + return c.json({ error: "Missing or invalid challenge cookie." }, 400); + } + const [challenge, expiresAtStr] = cookieValue.split("|", 2); + const expiresAt = Number.parseInt(expiresAtStr ?? "", 10); + if (!challenge || !Number.isFinite(expiresAt) || Date.now() > expiresAt) { + return c.json({ error: "Challenge has expired." }, 400); + } + + let rawBody: unknown; + try { + rawBody = await c.req.json(); + } catch { + return c.json({ error: "Invalid JSON body." }, 400); + } + const parsed = passkeyFinishSchema.safeParse(rawBody); + if (!parsed.success) { + return c.json({ error: "Invalid request body." }, 400); + } + const body = parsed.data; + const credentialId = body.authenticationResponse.id; + const storedPasskey = await db.query.passkeys.findFirst({ + where: eq(passkeys.id, credentialId), + }); + if (storedPasskey == null) { + return c.json({ error: "Unknown credential." }, 400); + } + + const rpInfo = getRpInfo(c.req.url); + const verified = await verifyAuthentication({ + rpInfo, + // SimpleWebAuthn validates the wire shape; the Zod schema above + // just rejects obviously wrong payloads. + // oxlint-disable-next-line typescript/no-explicit-any + response: body.authenticationResponse as any, + expectedChallenge: challenge, + storedPasskey, + }); + if (verified == null) { + return c.json({ error: "Authentication could not be verified." }, 400); + } + + // Compare-and-set on the counter to defeat concurrent ceremonies that + // verified against the same old value — if some other assertion already + // advanced the row, this one loses and the caller is told to retry. + const updated = await db + .update(passkeys) + .set({ counter: verified.newCounter, lastUsed: new Date() }) + .where( + and( + eq(passkeys.id, credentialId), + eq(passkeys.counter, storedPasskey.counter), + ), + ) + .returning({ id: passkeys.id }); + if (updated.length === 0) { + return c.json( + { error: "Concurrent assertion detected; please retry." }, + 409, + ); + } + + const loginValue = new Date().toISOString(); + await setSignedCookie(c, "login", loginValue, SECRET_KEY); + await setSignedCookie(c, "passkey", `${loginValue} passkey`, SECRET_KEY); + return c.json({ redirect: safeNext(body.next, c.req.url) }); +}); + export default login; From e235a853ee2d1f30c89e6a61e3cb2a3655199b89 Mon Sep 17 00:00:00 2001 From: Hong Minhee Date: Fri, 15 May 2026 08:40:33 +0900 Subject: [PATCH 07/25] Wire passkey ceremonies up in the browser Add the bit of client-side JavaScript the auth and login pages need to actually drive `navigator.credentials.create/get`. Two files land in src/public/: - simplewebauthn-browser.umd.js is a verbatim copy of the @simplewebauthn/browser v13.3.0 UMD bundle; it exposes `window.SimpleWebAuthnBrowser`. A short note at the top of passkey.js documents how to re-vendor it after a dep bump. - passkey.js is an IIFE glue script that hooks the existing form ids: `#passkey-enroll-form` on the admin Auth page (POSTs to /auth/passkeys/registration/{begin,finish} and reloads on success) and `#passkey-signin-button` on the login page (POSTs to /login/passkey/{begin,finish} and follows the server-supplied redirect). It surfaces a friendly status line via an aria-live="polite" element, distinguishes a NotAllowedError cancellation from a real failure, disables the trigger while a ceremony is in flight, and tolerates the 204 No Content reply from the registration-finish endpoint instead of trying to parse an empty body as JSON. Both files are loaded with `