From 2268b82ae1a727b0aa8c6684f0f4072f28f05cfc Mon Sep 17 00:00:00 2001 From: "Thomas F. K. Jorna" Date: Thu, 7 May 2026 17:53:34 +0200 Subject: [PATCH 1/2] feat: migrate to kf-auth sorta --- .test/setup-kf-auth.ts | 9 + .../MinimalEditor/MinimalEditor.tsx | 1 + .../DashboardCustomScripts.tsx | 5 +- infra/docker-compose.dev.yml | 54 ++- knowledgefutures-sdk-0.1.0.tgz | Bin 0 -> 34936 bytes package.json | 4 +- patches/@pubpub__deposit-utils.patch | 17 + pnpm-lock.yaml | 418 +++++++++++++++++- server/apiRoutes.ts | 2 + server/authToken/authTokenMiddleware.ts | 45 +- server/captcha/api.ts | 2 +- server/communityTemplate/applyTemplate.ts | 4 +- server/envSchema.ts | 7 + server/hub/api.ts | 8 +- server/kfAuth.ts | 41 ++ server/kfAuthWebhook/api.ts | 161 +++++++ server/login/api.ts | 183 +++----- server/models.ts | 10 - server/passwordReset/api.ts | 41 +- server/pub/__tests__/api.test.ts | 2 +- server/sequelize.ts | 4 +- server/server.ts | 30 +- server/signup/api.ts | 50 ++- server/user/__tests__/api.test.ts | 10 +- server/user/account.ts | 68 ++- server/user/model.ts | 4 + server/utils/__tests__/captcha.test.ts | 4 +- server/utils/__tests__/ssr.test.tsx | 4 +- server/utils/captcha.ts | 3 +- server/utils/logout.ts | 2 +- server/utils/serverModuleOverwrite.ts | 2 +- stubstub/global/setup.ts | 8 +- stubstub/kfAuth.ts | 86 ++++ stubstub/modelize/builders.ts | 56 +-- stubstub/userToAgentMap.ts | 10 +- tools/localdb.ts | 2 +- tools/migrateRedshift.ts | 2 +- .../2026_05_07_migrate_to_kf_auth.js | 132 ++++++ tsconfig.json | 3 +- types/global.d.ts | 8 +- utils/caching/__tests__/purge.test.ts | 4 +- workers/tasks/export/styles/buildCss.mts | 2 - 42 files changed, 1195 insertions(+), 313 deletions(-) create mode 100644 .test/setup-kf-auth.ts create mode 100644 knowledgefutures-sdk-0.1.0.tgz create mode 100644 patches/@pubpub__deposit-utils.patch create mode 100644 server/kfAuth.ts create mode 100644 server/kfAuthWebhook/api.ts create mode 100644 stubstub/kfAuth.ts create mode 100644 tools/migrations/2026_05_07_migrate_to_kf_auth.js diff --git a/.test/setup-kf-auth.ts b/.test/setup-kf-auth.ts new file mode 100644 index 000000000..bc293108f --- /dev/null +++ b/.test/setup-kf-auth.ts @@ -0,0 +1,9 @@ +import { stubKfAuth, restoreKfAuth } from '../stubstub/kfAuth'; + +beforeAll(() => { + stubKfAuth(); +}); + +afterAll(() => { + restoreKfAuth(); +}); diff --git a/client/components/MinimalEditor/MinimalEditor.tsx b/client/components/MinimalEditor/MinimalEditor.tsx index 6d8e40f9a..634b5e636 100644 --- a/client/components/MinimalEditor/MinimalEditor.tsx +++ b/client/components/MinimalEditor/MinimalEditor.tsx @@ -62,6 +62,7 @@ const MinimalEditor = (props: Props) => { }, []); useEffect(() => { + // @ts-expect-error webpack does not like the correct /index.js path import('../FormattingBar').then(({ buttons, FormattingBar: FormattingBarComponent }) => { setFormattingBar(() => (innerProps) => ( diff --git a/client/containers/DashboardCustomScripts/DashboardCustomScripts.tsx b/client/containers/DashboardCustomScripts/DashboardCustomScripts.tsx index 0a0d8c852..b17c1c483 100644 --- a/client/containers/DashboardCustomScripts/DashboardCustomScripts.tsx +++ b/client/containers/DashboardCustomScripts/DashboardCustomScripts.tsx @@ -26,7 +26,10 @@ const DashboardCustomScripts = (props: Props) => { import( /* webpackChunkName: "@monaco-editor/react" */ '@monaco-editor/react' - ).then(({ default: EditorComponent }) => setEditor(EditorComponent)); + ).then(({ default: EditorComponent }) => setEditor( + // @ts-expect-error somehow not assignable to EditorComponentType after -> module resolution change + EditorComponent + )); }, []); const renderLoading = () => { diff --git a/infra/docker-compose.dev.yml b/infra/docker-compose.dev.yml index 6d069a00c..1d0569026 100644 --- a/infra/docker-compose.dev.yml +++ b/infra/docker-compose.dev.yml @@ -23,12 +23,15 @@ services: PORT: 3000 DATABASE_URL: postgres://appuser:apppassword@db:5432/appdb CLOUDAMQP_URL: amqp://appuser:apppassword@rabbitmq:5672/appvhost + # KF_AUTH_URL: http://kf-auth:3000 + # KF_AUTH_WEBHOOK_SECRET: dev-webhook-secret depends_on: - db - rabbitmq + # - kf-auth ports: - "${WEB_PORT:-9876}:3000" - networks: [appnet] + networks: [ appnet ] volumes: - ..:/app @@ -42,8 +45,10 @@ services: environment: NODE_ENV: development CLOUDAMQP_URL: amqp://appuser:apppassword@rabbitmq:5672/appvhost + # KF_AUTH_URL: http://kf-auth:3000 + # KF_AUTH_WEBHOOK_SECRET: dev-webhook-secret - command: ["pnpm", "run", "workers-dev"] + command: [ "pnpm", "run", "workers-dev" ] depends_on: db: condition: service_started @@ -51,7 +56,7 @@ services: condition: service_started # app: # condition: service_healthy - networks: [appnet] + networks: [ appnet ] volumes: - ..:/app @@ -63,7 +68,7 @@ services: RABBITMQ_DEFAULT_VHOST: appvhost volumes: - rabbitmqdata:/var/lib/rabbitmq - networks: [appnet] + networks: [ appnet ] ports: - "${RABBITMQ_PORT:-5672}:5672" @@ -76,17 +81,49 @@ services: - POSTGRES_PASSWORD=apppassword - POSTGRES_DB=appdb command: > - -c shared_buffers=2GB - -c effective_cache_size=6GB - -c work_mem=16MB + -c shared_buffers=2GB -c effective_cache_size=6GB -c work_mem=16MB -c maintenance_work_mem=512MB volumes: - pgdata:/var/lib/postgresql/data ports: - "${DB_PORT:-5439}:5432" - networks: [appnet] + networks: [ appnet ] + # kf-auth-db: + # image: postgres:16-alpine + # environment: + # POSTGRES_USER: kfauth + # POSTGRES_PASSWORD: kfauth + # POSTGRES_DB: kfauth_dev + # volumes: + # - kfauthdata:/var/lib/postgresql/data + # networks: [appnet] + # kf-auth: + # build: + # context: ../../kf-auth + # dockerfile: docker/Dockerfile + # environment: + # DATABASE_URL: postgres://kfauth:kfauth@knowledgefutures-auth-db:5432/kfauth_dev + # BETTER_AUTH_SECRET: dev-secret-change-me + # BETTER_AUTH_URL: http://localhost:3456 + # SMTP_HOST: inbucket + # SMTP_PORT: "2500" + # SMTP_FROM: noreply@knowledgefuturesauth.dev + # SEED_ADMIN_EMAIL: admin@knowledgefuturesauth.dev + # SEED_ADMIN_PASSWORD: changeme123 + # PORT: "3000" + # depends_on: + # - kf-auth-db + # - inbucket + # ports: + # - "${KF_AUTH_PORT:-3456}:3000" + # networks: [appnet] + # inbucket: + # image: inbucket/inbucket:latest + # ports: + # - "9999:9000" + # networks: [appnet] # cron: # build: @@ -113,4 +150,5 @@ networks: volumes: pgdata: rabbitmqdata: + kfauthdata: diff --git a/knowledgefutures-sdk-0.1.0.tgz b/knowledgefutures-sdk-0.1.0.tgz new file mode 100644 index 0000000000000000000000000000000000000000..9266a15f022897a4372723ff3ea56e974971c00e GIT binary patch literal 34936 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�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 literal 0 HcmV?d00001 diff --git a/package.json b/package.json index 508b3afbe..32bb954c3 100644 --- a/package.json +++ b/package.json @@ -81,6 +81,7 @@ "@codemirror/state": "^0.20.0", "@codemirror/view": "^0.20.0", "@google-cloud/storage": "^7.9.0", + "@knowledgefutures/sdk": "file:/Users/thomas/Projects/kf/pubpub/knowledgefutures-sdk-0.1.0.tgz", "@lezer/common": "^1.0.1", "@lezer/cpp": "^1.0.0", "@lezer/css": "^1.1.0", @@ -378,7 +379,8 @@ "validate-with-xmllint" ], "patchedDependencies": { - "reakit": "patches/reakit.patch" + "reakit": "patches/reakit.patch", + "@pubpub/deposit-utils": "patches/@pubpub__deposit-utils.patch" } } } diff --git a/patches/@pubpub__deposit-utils.patch b/patches/@pubpub__deposit-utils.patch new file mode 100644 index 000000000..2740ce774 --- /dev/null +++ b/patches/@pubpub__deposit-utils.patch @@ -0,0 +1,17 @@ +diff --git a/package.json b/package.json +index ff9f6ae0f025e5528dd1448730e4f25c10284efb..d022c3ed4245e1b153cddab1b0eccef8e84fa86d 100644 +--- a/package.json ++++ b/package.json +@@ -21,10 +21,12 @@ + "exports": { + ".": "./index.js", + "./crossref": { ++ "types": "./dist/dts/crossref/index.d.ts", + "import": "./dist/esm/crossref/index.js", + "require": "./dist/cjs/crossref/index.js" + }, + "./datacite": { ++ "types": "./dist/dts/datacite/index.d.ts", + "import": "./dist/esm/datacite/index.js", + "require": "./dist/cjs/datacite/index.js" + } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 778ab5e69..3de2d1176 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -5,6 +5,9 @@ settings: excludeLinksFromLockfile: false patchedDependencies: + '@pubpub/deposit-utils': + hash: 6fef8933046b7751abda1bd2ed0422094c79d7828b07f7d5afba26a47c696d89 + path: patches/@pubpub__deposit-utils.patch reakit: hash: 31488077229fe3d19a71c66458b888c58eab18010632a29e3b5c485df77242a8 path: patches/reakit.patch @@ -100,6 +103,9 @@ importers: '@google-cloud/storage': specifier: ^7.9.0 version: 7.19.0 + '@knowledgefutures/sdk': + specifier: file:/Users/thomas/Projects/kf/pubpub/knowledgefutures-sdk-0.1.0.tgz + version: file:knowledgefutures-sdk-0.1.0.tgz(@better-auth/core@1.6.9(@better-auth/utils@0.4.0)(@better-fetch/fetch@1.1.21)(better-call@1.3.5(zod@3.22.4))(jose@6.2.3)(kysely@0.28.17)(nanostores@1.3.0))(@better-auth/utils@0.4.0)(@better-fetch/fetch@1.1.21)(better-call@1.3.5(zod@4.4.3))(express@4.22.1)(pg@8.18.0)(react-dom@16.14.0(react@16.14.0))(react@16.14.0)(vitest@4.0.18(@types/node@24.11.0)(jiti@2.6.1)(sass@1.67.0)(terser@5.46.0)(tsx@4.21.0)(yaml@1.10.2))(zod@3.22.4) '@lezer/common': specifier: ^1.0.1 version: 1.5.1 @@ -147,7 +153,7 @@ importers: version: 2.11.8 '@pubpub/deposit-utils': specifier: ^0.1.10 - version: 0.1.10 + version: 0.1.10(patch_hash=6fef8933046b7751abda1bd2ed0422094c79d7828b07f7d5afba26a47c696d89) '@pubpub/prosemirror-pandoc': specifier: ^1.1.5 version: 1.1.5 @@ -832,7 +838,7 @@ importers: version: 1.15.8(enzyme@3.11.0)(react-dom@16.14.0(react@16.14.0))(react@16.14.0) esbuild-loader: specifier: ^4.0.2 - version: 4.4.2(webpack@4.47.0) + version: 4.4.3(webpack@4.47.0) file-loader: specifier: ^4.0.0 version: 4.3.0(webpack@4.47.0) @@ -1869,6 +1875,94 @@ packages: prosemirror-transform: ^1.2.12 prosemirror-view: ^1.18.2 + '@better-auth/core@1.6.9': + resolution: {integrity: sha512-ADFk5pwmLybmc+LvYvXJ6M1x2oY/EyYLkwLuH0x28FUq12DfjL0wnE7g+WRDf3yozDO+qIxTpFGXDGwLKbfz0w==} + peerDependencies: + '@better-auth/utils': 0.4.0 + '@better-fetch/fetch': 1.1.21 + '@cloudflare/workers-types': '>=4' + '@opentelemetry/api': ^1.9.0 + better-call: 1.3.5 + jose: ^6.1.0 + kysely: ^0.28.5 + nanostores: ^1.0.1 + peerDependenciesMeta: + '@cloudflare/workers-types': + optional: true + '@opentelemetry/api': + optional: true + + '@better-auth/drizzle-adapter@1.6.9': + resolution: {integrity: sha512-Lcco5hOGrMgc4XKAkvB6x72eQm4wCcya8IevMg4wBHY9W9GVg8pu23rpRX6VsVQSO4Ux13S7lFwUWtF7/r9aKw==} + peerDependencies: + '@better-auth/core': ^1.6.9 + '@better-auth/utils': 0.4.0 + drizzle-orm: ^0.45.2 + peerDependenciesMeta: + drizzle-orm: + optional: true + + '@better-auth/kysely-adapter@1.6.9': + resolution: {integrity: sha512-gyjuuxJtZ4o9G9z9q4kqn24X2kvMSp7F+KHogYxF03SnXY/2WleAcuj57iC4wP3e9mGDbjPOrnM5K6Kr3Ktdpw==} + peerDependencies: + '@better-auth/core': ^1.6.9 + '@better-auth/utils': 0.4.0 + kysely: ^0.28.14 + peerDependenciesMeta: + kysely: + optional: true + + '@better-auth/memory-adapter@1.6.9': + resolution: {integrity: sha512-XmIG4tUnOXZ+KEcWjHUjOI9Z5donD09dC2t/AQTXifAUIqx7cySg86w0KTM09ArzAxRx1fCqO36Wkt5nULnrkQ==} + peerDependencies: + '@better-auth/core': ^1.6.9 + '@better-auth/utils': 0.4.0 + + '@better-auth/mongo-adapter@1.6.9': + resolution: {integrity: sha512-h+AiRJ/TsBSi+ZDjySASBpbJ/9QCXBre34PSKgCz7QmTHrFM9Cg2EM4AM7LjR5lPXipEE+2rWPBc9wfnUBjhcw==} + peerDependencies: + '@better-auth/core': ^1.6.9 + '@better-auth/utils': 0.4.0 + mongodb: ^6.0.0 || ^7.0.0 + peerDependenciesMeta: + mongodb: + optional: true + + '@better-auth/oauth-provider@1.6.9': + resolution: {integrity: sha512-GJCRDLu7xOc/HcAuQXaFZ9xZo8l3yLuc+1/vKYB5gh0O+owub+vLH88+AfNm/jMHZ084MSHpgkyxmEnmBXe4iQ==} + peerDependencies: + '@better-auth/core': ^1.6.9 + '@better-auth/utils': 0.4.0 + '@better-fetch/fetch': 1.1.21 + better-auth: ^1.6.9 + better-call: 1.3.5 + + '@better-auth/prisma-adapter@1.6.9': + resolution: {integrity: sha512-XHks01ntK20orqK/jICq8wmEbJ/zT6dct49Fk8zTQKN9QNGDc+Ix5+7z/Kvui0DXGFf790GfvRozquzaLtXa8Q==} + peerDependencies: + '@better-auth/core': ^1.6.9 + '@better-auth/utils': 0.4.0 + '@prisma/client': ^5.0.0 || ^6.0.0 || ^7.0.0 + prisma: ^5.0.0 || ^6.0.0 || ^7.0.0 + peerDependenciesMeta: + '@prisma/client': + optional: true + prisma: + optional: true + + '@better-auth/telemetry@1.6.9': + resolution: {integrity: sha512-0u5zkhSCAQFoN3DHvUkLHOF6MBbVTDAa6mU8mhPwiysdz1x21vMzhzfaAKN/ZGWaQ09v91/F+2qu42G/bhUV4A==} + peerDependencies: + '@better-auth/core': ^1.6.9 + '@better-auth/utils': 0.4.0 + '@better-fetch/fetch': 1.1.21 + + '@better-auth/utils@0.4.0': + resolution: {integrity: sha512-RpMtLUIQAEWMgdPLNVbIF5ON2mm+CH0U3rCdUCU1VyeAUui4m38DyK7/aXMLZov2YDjG684pS1D0MBllrmgjQA==} + + '@better-fetch/fetch@1.1.21': + resolution: {integrity: sha512-/ImESw0sskqlVR94jB+5+Pxjf+xBwDZF/N5+y2/q4EqD7IARUTSpPfIo8uf39SYpCxyOCtbyYpUrZ3F/k0zT4A==} + '@biomejs/biome@2.4.3': resolution: {integrity: sha512-cBrjf6PNF6yfL8+kcNl85AjiK2YHNsbU0EvDOwiZjBPbMbQ5QcgVGFpjD0O52p8nec5O8NYw7PKw3xUR7fPAkQ==} engines: {node: '>=14.21.3'} @@ -2715,6 +2809,22 @@ packages: peerDependencies: tslib: '2' + '@knowledgefutures/sdk@file:knowledgefutures-sdk-0.1.0.tgz': + resolution: {integrity: sha512-u0b83SAlCSc+1LsyvkekKgNzw0rADMVXFVnQe+CzAUk5Zo+VUoo67qhP1J1fZTfmnRcQL20Ns7UV5FBVR0OBWw==, tarball: file:knowledgefutures-sdk-0.1.0.tgz} + version: 0.1.0 + peerDependencies: + express: '>=4.0.0' + hono: '>=4.0.0' + next: '>=15.0.0' + zod: '>=4.0.0' + peerDependenciesMeta: + express: + optional: true + hono: + optional: true + next: + optional: true + '@leichtgewicht/ip-codec@2.0.5': resolution: {integrity: sha512-Vo+PSpZG2/fmgmiNzYK9qWRh8h/CHrwD0mo1h1DzL4yzHNSfWYujGTYsWGreD000gcgmZ7K4Ys6Tx9TxtsKdDw==} @@ -2816,10 +2926,18 @@ packages: resolution: {integrity: sha512-bPHp6Ji8b41szTOcaP63VlnbbO5Ny6dwAATtY6JTjh5N2OLrb5Qk/Th5cRkRQhkWCt+EJsYrNB0MiL+Gpn6e3g==} engines: {node: '>=4'} + '@noble/ciphers@2.2.0': + resolution: {integrity: sha512-Z6pjIZ/8IJcCGzb2S/0Px5J81yij85xASuk1teLNeg75bfT07MV3a/O2Mtn1I2se43k3lkVEcFaR10N4cgQcZA==} + engines: {node: '>= 20.19.0'} + '@noble/hashes@1.8.0': resolution: {integrity: sha512-jCs9ldd7NwzpgXDIf6P3+NrHh9/sD6CQdxHyjQI+h/6rDNo88ypBxxz45UDuZHz9r3tNz7N/VInSVoVdtXEI4A==} engines: {node: ^14.21.3 || >=16} + '@noble/hashes@2.2.0': + resolution: {integrity: sha512-IYqDGiTXab6FniAgnSdZwgWbomxpy9FtYvLKs7wCUs2a8RkITG+DFGO1DM9cr+E3/RgADRpFjrKVaJ1z6sjtEg==} + engines: {node: '>= 20.19.0'} + '@nodelib/fs.scandir@2.1.5': resolution: {integrity: sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g==} engines: {node: '>= 8'} @@ -2847,6 +2965,10 @@ packages: '@one-ini/wasm@0.1.1': resolution: {integrity: sha512-XuySG1E38YScSJoMlqovLru4KTUNSjgVTIjyh7qMX6aNN5HY5Ct5LhRJdxO79JtTzKfzV/bnWpz+zquYrISsvw==} + '@opentelemetry/semantic-conventions@1.40.0': + resolution: {integrity: sha512-cifvXDhcqMwwTlTK04GBNeIe7yyo28Mfby85QXFe1Yk8nmi36Ab/5UQwptOx84SsoGNRg+EVSjwzfSZMy6pmlw==} + engines: {node: '>=14'} + '@panva/asn1.js@1.0.0': resolution: {integrity: sha512-UdkG3mLEqXgnlKsWanWcgb6dOjUzJ+XC5f+aWw30qrtjxeNUSfKX1cd5FBzOaXQumoe9nIqeZUvrRJS03HCCtw==} engines: {node: '>=10.13.0'} @@ -5046,6 +5168,76 @@ packages: bcrypt-pbkdf@1.0.2: resolution: {integrity: sha512-qeFIXtP4MSoi6NLqO12WfqARWWuCKi2Rn/9hJLEmtB5yTNr9DqFWkJRCf2qShWzPeAMRnOgCrq0sg/KLv5ES9w==} + better-auth@1.6.9: + resolution: {integrity: sha512-EBFURtglyiEZxbx4NJBoqUD8J65dX24yC+6I9AUbIXNgUkt76mshzGbHkxZ3n/lB7Dwq3kBC+hHt0hUQsnL7HA==} + peerDependencies: + '@lynx-js/react': '*' + '@prisma/client': ^5.0.0 || ^6.0.0 || ^7.0.0 + '@sveltejs/kit': ^2.0.0 + '@tanstack/react-start': ^1.0.0 + '@tanstack/solid-start': ^1.0.0 + better-sqlite3: ^12.0.0 + drizzle-kit: '>=0.31.4' + drizzle-orm: ^0.45.2 + mongodb: ^6.0.0 || ^7.0.0 + mysql2: ^3.0.0 + next: ^14.0.0 || ^15.0.0 || ^16.0.0 + pg: ^8.0.0 + prisma: ^5.0.0 || ^6.0.0 || ^7.0.0 + react: ^18.0.0 || ^19.0.0 + react-dom: ^18.0.0 || ^19.0.0 + solid-js: ^1.0.0 + svelte: ^4.0.0 || ^5.0.0 + vitest: ^2.0.0 || ^3.0.0 || ^4.0.0 + vue: ^3.0.0 + peerDependenciesMeta: + '@lynx-js/react': + optional: true + '@prisma/client': + optional: true + '@sveltejs/kit': + optional: true + '@tanstack/react-start': + optional: true + '@tanstack/solid-start': + optional: true + better-sqlite3: + optional: true + drizzle-kit: + optional: true + drizzle-orm: + optional: true + mongodb: + optional: true + mysql2: + optional: true + next: + optional: true + pg: + optional: true + prisma: + optional: true + react: + optional: true + react-dom: + optional: true + solid-js: + optional: true + svelte: + optional: true + vitest: + optional: true + vue: + optional: true + + better-call@1.3.5: + resolution: {integrity: sha512-kOFJkBP7utAQLEYrobZm3vkTH8mXq5GNgvjc5/XEST1ilVHaxXUXfeDeFlqoETMtyqS4+3/h4ONX2i++ebZrvA==} + peerDependencies: + zod: ^4.0.0 + peerDependenciesMeta: + zod: + optional: true + better-opn@2.1.1: resolution: {integrity: sha512-kIPXZS5qwyKiX/HcRvDYfmBQUa8XP17I0mYZZ0y4UhpYOSvtsLHDYqmomS+Mj20aDvD3knEiQ0ecQy2nhio3yA==} engines: {node: '>8.0.0'} @@ -6053,6 +6245,9 @@ packages: resolution: {integrity: sha512-jwK2UV4cnPpbcG7+VRARKTZPUWowwXA8bzH5NP6ud0oeAxyYPuGZUAC7hMugpCdz4BeSZl2Dl9k66CHJ/46ZYQ==} engines: {node: '>=0.10.0'} + defu@6.1.7: + resolution: {integrity: sha512-7z22QmUWiQ/2d0KkdYmANbRUVABpZ9SNYyH5vx6PZ+nE5bcC0l7uFvEfHlyld/HcGBFTL536ClDt3DEcSlEJAQ==} + delayed-stream@1.0.0: resolution: {integrity: sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==} engines: {node: '>=0.4.0'} @@ -6390,8 +6585,8 @@ packages: resolution: {integrity: sha512-U9bFFjX8tFiATgtkJ1zg25+KviIXpgRvRHS8sau3GfhVzThRQrOeksPeT0BWW2MNZs1OEWJ1DPXOQMn0KKRkvg==} engines: {node: '>=0.12'} - esbuild-loader@4.4.2: - resolution: {integrity: sha512-8LdoT9sC7fzfvhxhsIAiWhzLJr9yT3ggmckXxsgvM07wgrRxhuT98XhLn3E7VczU5W5AFsPKv9DdWcZIubbWkQ==} + esbuild-loader@4.4.3: + resolution: {integrity: sha512-Wpui03EzqC151xFteKlgJQhbyZl5CgnBpUHXVuao02nItULlkaTeiLdEMPTmR2zdwpEBWkXVNoT5dDOYJluUzg==} peerDependencies: webpack: ^4.40.0 || ^5.0.0 @@ -7039,6 +7234,10 @@ packages: deprecated: Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me hasBin: true + glob@13.0.6: + resolution: {integrity: sha512-Wjlyrolmm8uDpm/ogGyXZXb1Z+Ca2B8NbJwqBVg0axK9GbBeoS7yGV6vjXnYdGm6X53iehEuxxbyiKp8QmN4Vw==} + engines: {node: 18 || 20 || >=22} + glob@7.2.0: resolution: {integrity: sha512-lmLf6gtyrPq8tTjSmrO94wBeQbFR3HbLHbuyD69wuyQkImp2hWqMGB47OX65FBkPffO641IP9jWa1z4ivqG26Q==} deprecated: Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me @@ -7951,6 +8150,9 @@ packages: resolution: {integrity: sha512-5hFWIigKqC+e/lRyQhfnirrAqUdIPMB7SJRqflJaO29dW7q5DFvH1XCSTmv6PQ6pb++0k6MJlLRoS0Wv4s38Wg==} engines: {node: '>=10.13.0 < 13 || >=13.7.0'} + jose@6.2.3: + resolution: {integrity: sha512-YYVDInQKFJfR/xa3ojUTl8c2KoTwiL1R5Wg9YCydwH0x0B9grbzlg5HC7mMjCtUJjbQ/YnGEZIhI5tCgfTb4Hw==} + jotai@1.13.1: resolution: {integrity: sha512-RUmH1S4vLsG3V6fbGlKzGJnLrDcC/HNb5gH2AeA9DzuJknoVxSGvvg8OBB7lke+gDc4oXmdVsaKn/xDUhWZ0vw==} engines: {node: '>=12.20.0'} @@ -8200,6 +8402,10 @@ packages: tedious: optional: true + kysely@0.28.17: + resolution: {integrity: sha512-nbD8lB9EB3wNdMhOCdx5Li8DxnLbvKByylRLcJ1h+4SkrowVeECAyZlyiKMThF7xFdRz0jSQ2MoJr+wXux2y0Q==} + engines: {node: '>=20.0.0'} + latest-version@3.1.0: resolution: {integrity: sha512-Be1YRHWWlZaSsrz2U+VInk+tO0EwLIyV+23RhWLINJYwg/UIikxjlj3MhH37/6/EDCAusjajvMkMMUXRaMWl/w==} engines: {node: '>=4'} @@ -8395,6 +8601,10 @@ packages: lru-cache@10.4.3: resolution: {integrity: sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==} + lru-cache@11.3.6: + resolution: {integrity: sha512-Gf/KoL3C/MlI7Bt0PGI9I+TeTC/I6r/csU58N4BSNc4lppLBeKsOdFYkK+dX0ABDUMJNfCHTyPpzwwO21Awd3A==} + engines: {node: 20 || >=22} + lru-cache@4.1.5: resolution: {integrity: sha512-sWZlbEP2OsHNkXrMl5GYk/jKk70MBng6UU4YI/qGDYbgf6YbP4EvmqISbXCoJiRKs+1bSpFHVgQxvJ17F2li5g==} @@ -8638,8 +8848,8 @@ packages: resolution: {integrity: sha512-lKwV/1brpG6mBUFHtb7NUmtABCb2WZZmm2wNiOA5hAb8VdCS4B3dtMWyvcoViccwAW/COERjXLt0zP1zXUN26g==} engines: {node: '>=10'} - minimatch@8.0.4: - resolution: {integrity: sha512-W0Wvr9HyFXZRGIDgCicunpQ299OKXs9RgZfaukz4qAW/pJhcpUfupc9c+OObPOFueNy8VSrZgEmDtk6Kh4WzDA==} + minimatch@8.0.7: + resolution: {integrity: sha512-V+1uQNdzybxa14e/p00HZnQNNcTjnRJjDxg2V8wtkjFctq4M7hXFws4oekyTP0Jebeq7QYtpFyOeBAjc88zvYg==} engines: {node: '>=16 || 14 >=14.17'} minimatch@9.0.1: @@ -8782,6 +8992,10 @@ packages: resolution: {integrity: sha512-fpoe2T0RbHwBTBUOftAfBPaDEi06ufaUai0mE6Yn1kacc3SnTErfb/h+X94VXzI64rKFHYImXSvdwGGCmwOqCA==} engines: {node: '>=0.10.0'} + nanostores@1.3.0: + resolution: {integrity: sha512-XPUa/jz+P1oJvN9VBxw4L9MtdFfaH3DAryqPssqhb2kXjmb9npz0dly6rCsgFWOPr4Yg9mTfM3MDZgZZ+7A3lA==} + engines: {node: ^20.0.0 || >=22.0.0} + natural-compare@1.4.0: resolution: {integrity: sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==} @@ -9296,6 +9510,10 @@ packages: resolution: {integrity: sha512-Xa4Nw17FS9ApQFJ9umLiJS4orGjm7ZzwUrwamcGQuHSzDyth9boKDaycYdDcZDuqYATXw4HFXgaqWTctW/v1HA==} engines: {node: '>=16 || 14 >=14.18'} + path-scurry@2.0.2: + resolution: {integrity: sha512-3O/iVVsJAPsOnpwWIeD+d6z/7PmqApyQePUtCndjatj/9I5LylHvt5qluFaBT3I5h3r1ejfR056c+FCv+NnNXg==} + engines: {node: 18 || 20 || >=22} + path-to-regexp@0.1.12: resolution: {integrity: sha512-RA1GjUVMnvYFxuqovrEqZoxxW5NUZqbwKtYz/Tt7nXerk0LbLblQmrsgdeOxV5SFHf0UDggjS/bSeOZwt1pmEQ==} @@ -10283,6 +10501,9 @@ packages: rope-sequence@1.3.4: resolution: {integrity: sha512-UT5EDe2cu2E/6O4igUr5PSFs23nvvukicWHx6GnOPlHAiiYbzNuCRQCuiUdHJQcqKalLKlrYJnjY0ySGsXNQXQ==} + rou3@0.7.12: + resolution: {integrity: sha512-iFE4hLDuloSWcD7mjdCDhx2bKcIsYbtOTpfH5MHHLSKMOUyjqQXTeZVa289uuwEGEKFoE/BAPbhaU4B774nceg==} + rss@1.2.2: resolution: {integrity: sha512-xUhRTgslHeCBeHAqaWSbOYTydN2f0tAzNXvzh3stjz7QDhQMzdgHf3pfgNIngeytQflrFPfy6axHilTETr6gDg==} @@ -10490,6 +10711,9 @@ packages: set-blocking@2.0.0: resolution: {integrity: sha512-KiKBS8AnWGEyLzofFfmvKwpdPzqiy16LvQfK3yv/fVH7Bj13/wl3JSR1J+rfgRE9q7xUJK4qvgS8raSOeLUehw==} + set-cookie-parser@3.1.0: + resolution: {integrity: sha512-kjnC1DXBHcxaOaOXBHBeRtltsDG2nUiUni+jP92M9gYdW12rsmx92UsfpH7o5tDRs7I1ZZPSQJQGv3UaRfCiuw==} + set-function-length@1.2.2: resolution: {integrity: sha512-pgRc4hJ4/sNjWCSS9AmnS40x3bNMDTknHgL5UaMBTMyJnU90EgWh1Rz+MC9eFu4BuN/UwZjKQuY/1v3rM7HMfg==} engines: {node: '>= 0.4'} @@ -11577,6 +11801,7 @@ packages: uuid@9.0.1: resolution: {integrity: sha512-b+1eJOlsR9K8HJpow9Ok3fiWOWSIcIzXodvv0rQjVoOVNpWMpxf1wZNpt4y9h10odCNrqnYp1OBzRktckBe3sA==} + deprecated: uuid@10 and below is no longer supported. For ESM codebases, update to uuid@latest. For CommonJS codebases, use uuid@11 (but be aware this version will likely be deprecated in 2028). hasBin: true v8-compile-cache-lib@3.0.1: @@ -12088,6 +12313,9 @@ packages: zod@3.22.4: resolution: {integrity: sha512-iC+8Io04lddc+mVqQ9AZ7OQ2MrUKGN+oIQyq1vemgt46jwCwLfhq7/pwnBnNXXXZb8VTVLKwp9EDkx+ryxIWmg==} + zod@4.4.3: + resolution: {integrity: sha512-ytENFjIJFl2UwYglde2jchW2Hwm4GJFLDiSXWdTrJQBIN9Fcyp7n4DhxJEiWNAJMV1/BqWfW/kkg71UDcHJyTQ==} + zotero-api-client@0.40.1: resolution: {integrity: sha512-tsuC5BWVFzici27yCVup4XdcL4F/Fr5aSeiQKl6g+z3TJ4dYfVR3hvKHIuGrZ8mtsjPTwbOl5EMfBVCQkAlikA==} engines: {node: '>= 10.0.0'} @@ -13676,6 +13904,67 @@ snapshots: prosemirror-transform: 1.11.0 prosemirror-view: 1.41.6 + '@better-auth/core@1.6.9(@better-auth/utils@0.4.0)(@better-fetch/fetch@1.1.21)(better-call@1.3.5(zod@3.22.4))(jose@6.2.3)(kysely@0.28.17)(nanostores@1.3.0)': + dependencies: + '@better-auth/utils': 0.4.0 + '@better-fetch/fetch': 1.1.21 + '@opentelemetry/semantic-conventions': 1.40.0 + '@standard-schema/spec': 1.1.0 + better-call: 1.3.5(zod@4.4.3) + jose: 6.2.3 + kysely: 0.28.17 + nanostores: 1.3.0 + zod: 4.4.3 + + '@better-auth/drizzle-adapter@1.6.9(@better-auth/core@1.6.9(@better-auth/utils@0.4.0)(@better-fetch/fetch@1.1.21)(better-call@1.3.5(zod@3.22.4))(jose@6.2.3)(kysely@0.28.17)(nanostores@1.3.0))(@better-auth/utils@0.4.0)': + dependencies: + '@better-auth/core': 1.6.9(@better-auth/utils@0.4.0)(@better-fetch/fetch@1.1.21)(better-call@1.3.5(zod@3.22.4))(jose@6.2.3)(kysely@0.28.17)(nanostores@1.3.0) + '@better-auth/utils': 0.4.0 + + '@better-auth/kysely-adapter@1.6.9(@better-auth/core@1.6.9(@better-auth/utils@0.4.0)(@better-fetch/fetch@1.1.21)(better-call@1.3.5(zod@3.22.4))(jose@6.2.3)(kysely@0.28.17)(nanostores@1.3.0))(@better-auth/utils@0.4.0)(kysely@0.28.17)': + dependencies: + '@better-auth/core': 1.6.9(@better-auth/utils@0.4.0)(@better-fetch/fetch@1.1.21)(better-call@1.3.5(zod@3.22.4))(jose@6.2.3)(kysely@0.28.17)(nanostores@1.3.0) + '@better-auth/utils': 0.4.0 + optionalDependencies: + kysely: 0.28.17 + + '@better-auth/memory-adapter@1.6.9(@better-auth/core@1.6.9(@better-auth/utils@0.4.0)(@better-fetch/fetch@1.1.21)(better-call@1.3.5(zod@3.22.4))(jose@6.2.3)(kysely@0.28.17)(nanostores@1.3.0))(@better-auth/utils@0.4.0)': + dependencies: + '@better-auth/core': 1.6.9(@better-auth/utils@0.4.0)(@better-fetch/fetch@1.1.21)(better-call@1.3.5(zod@3.22.4))(jose@6.2.3)(kysely@0.28.17)(nanostores@1.3.0) + '@better-auth/utils': 0.4.0 + + '@better-auth/mongo-adapter@1.6.9(@better-auth/core@1.6.9(@better-auth/utils@0.4.0)(@better-fetch/fetch@1.1.21)(better-call@1.3.5(zod@3.22.4))(jose@6.2.3)(kysely@0.28.17)(nanostores@1.3.0))(@better-auth/utils@0.4.0)': + dependencies: + '@better-auth/core': 1.6.9(@better-auth/utils@0.4.0)(@better-fetch/fetch@1.1.21)(better-call@1.3.5(zod@3.22.4))(jose@6.2.3)(kysely@0.28.17)(nanostores@1.3.0) + '@better-auth/utils': 0.4.0 + + '@better-auth/oauth-provider@1.6.9(@better-auth/core@1.6.9(@better-auth/utils@0.4.0)(@better-fetch/fetch@1.1.21)(better-call@1.3.5(zod@3.22.4))(jose@6.2.3)(kysely@0.28.17)(nanostores@1.3.0))(@better-auth/utils@0.4.0)(@better-fetch/fetch@1.1.21)(better-auth@1.6.9(pg@8.18.0)(react-dom@16.14.0(react@16.14.0))(react@16.14.0)(vitest@4.0.18(@types/node@24.11.0)(jiti@2.6.1)(sass@1.67.0)(terser@5.46.0)(tsx@4.21.0)(yaml@1.10.2)))(better-call@1.3.5(zod@4.4.3))': + dependencies: + '@better-auth/core': 1.6.9(@better-auth/utils@0.4.0)(@better-fetch/fetch@1.1.21)(better-call@1.3.5(zod@3.22.4))(jose@6.2.3)(kysely@0.28.17)(nanostores@1.3.0) + '@better-auth/utils': 0.4.0 + '@better-fetch/fetch': 1.1.21 + better-auth: 1.6.9(pg@8.18.0)(react-dom@16.14.0(react@16.14.0))(react@16.14.0)(vitest@4.0.18(@types/node@24.11.0)(jiti@2.6.1)(sass@1.67.0)(terser@5.46.0)(tsx@4.21.0)(yaml@1.10.2)) + better-call: 1.3.5(zod@4.4.3) + jose: 6.2.3 + zod: 4.4.3 + + '@better-auth/prisma-adapter@1.6.9(@better-auth/core@1.6.9(@better-auth/utils@0.4.0)(@better-fetch/fetch@1.1.21)(better-call@1.3.5(zod@3.22.4))(jose@6.2.3)(kysely@0.28.17)(nanostores@1.3.0))(@better-auth/utils@0.4.0)': + dependencies: + '@better-auth/core': 1.6.9(@better-auth/utils@0.4.0)(@better-fetch/fetch@1.1.21)(better-call@1.3.5(zod@3.22.4))(jose@6.2.3)(kysely@0.28.17)(nanostores@1.3.0) + '@better-auth/utils': 0.4.0 + + '@better-auth/telemetry@1.6.9(@better-auth/core@1.6.9(@better-auth/utils@0.4.0)(@better-fetch/fetch@1.1.21)(better-call@1.3.5(zod@3.22.4))(jose@6.2.3)(kysely@0.28.17)(nanostores@1.3.0))(@better-auth/utils@0.4.0)(@better-fetch/fetch@1.1.21)': + dependencies: + '@better-auth/core': 1.6.9(@better-auth/utils@0.4.0)(@better-fetch/fetch@1.1.21)(better-call@1.3.5(zod@3.22.4))(jose@6.2.3)(kysely@0.28.17)(nanostores@1.3.0) + '@better-auth/utils': 0.4.0 + '@better-fetch/fetch': 1.1.21 + + '@better-auth/utils@0.4.0': + dependencies: + '@noble/hashes': 2.2.0 + + '@better-fetch/fetch@1.1.21': {} + '@biomejs/biome@2.4.3': optionalDependencies: '@biomejs/cli-darwin-arm64': 2.4.3 @@ -14701,6 +14990,39 @@ snapshots: tslib: 1.14.1 optional: true + '@knowledgefutures/sdk@file:knowledgefutures-sdk-0.1.0.tgz(@better-auth/core@1.6.9(@better-auth/utils@0.4.0)(@better-fetch/fetch@1.1.21)(better-call@1.3.5(zod@3.22.4))(jose@6.2.3)(kysely@0.28.17)(nanostores@1.3.0))(@better-auth/utils@0.4.0)(@better-fetch/fetch@1.1.21)(better-call@1.3.5(zod@4.4.3))(express@4.22.1)(pg@8.18.0)(react-dom@16.14.0(react@16.14.0))(react@16.14.0)(vitest@4.0.18(@types/node@24.11.0)(jiti@2.6.1)(sass@1.67.0)(terser@5.46.0)(tsx@4.21.0)(yaml@1.10.2))(zod@3.22.4)': + dependencies: + '@better-auth/oauth-provider': 1.6.9(@better-auth/core@1.6.9(@better-auth/utils@0.4.0)(@better-fetch/fetch@1.1.21)(better-call@1.3.5(zod@3.22.4))(jose@6.2.3)(kysely@0.28.17)(nanostores@1.3.0))(@better-auth/utils@0.4.0)(@better-fetch/fetch@1.1.21)(better-auth@1.6.9(pg@8.18.0)(react-dom@16.14.0(react@16.14.0))(react@16.14.0)(vitest@4.0.18(@types/node@24.11.0)(jiti@2.6.1)(sass@1.67.0)(terser@5.46.0)(tsx@4.21.0)(yaml@1.10.2)))(better-call@1.3.5(zod@4.4.3)) + better-auth: 1.6.9(pg@8.18.0)(react-dom@16.14.0(react@16.14.0))(react@16.14.0)(vitest@4.0.18(@types/node@24.11.0)(jiti@2.6.1)(sass@1.67.0)(terser@5.46.0)(tsx@4.21.0)(yaml@1.10.2)) + zod: 3.22.4 + optionalDependencies: + express: 4.22.1 + transitivePeerDependencies: + - '@better-auth/core' + - '@better-auth/utils' + - '@better-fetch/fetch' + - '@cloudflare/workers-types' + - '@lynx-js/react' + - '@opentelemetry/api' + - '@prisma/client' + - '@sveltejs/kit' + - '@tanstack/react-start' + - '@tanstack/solid-start' + - better-call + - better-sqlite3 + - drizzle-kit + - drizzle-orm + - mongodb + - mysql2 + - pg + - prisma + - react + - react-dom + - solid-js + - svelte + - vitest + - vue + '@leichtgewicht/ip-codec@2.0.5': optional: true @@ -14877,8 +15199,12 @@ snapshots: call-me-maybe: 1.0.2 glob-to-regexp: 0.3.0 + '@noble/ciphers@2.2.0': {} + '@noble/hashes@1.8.0': {} + '@noble/hashes@2.2.0': {} + '@nodelib/fs.scandir@2.1.5': dependencies: '@nodelib/fs.stat': 2.0.5 @@ -14905,6 +15231,8 @@ snapshots: '@one-ini/wasm@0.1.1': {} + '@opentelemetry/semantic-conventions@1.40.0': {} + '@panva/asn1.js@1.0.0': {} '@paralleldrive/cuid2@2.3.1': @@ -14964,7 +15292,7 @@ snapshots: '@protobufjs/utf8@1.1.0': {} - '@pubpub/deposit-utils@0.1.10': + '@pubpub/deposit-utils@0.1.10(patch_hash=6fef8933046b7751abda1bd2ed0422094c79d7828b07f7d5afba26a47c696d89)': dependencies: validate-with-xmllint: 1.2.1 @@ -16777,7 +17105,7 @@ snapshots: '@types/glob@9.0.0': dependencies: - glob: 7.2.3 + glob: 13.0.6 '@types/graphlib@2.1.12': {} @@ -17930,6 +18258,43 @@ snapshots: dependencies: tweetnacl: 0.14.5 + better-auth@1.6.9(pg@8.18.0)(react-dom@16.14.0(react@16.14.0))(react@16.14.0)(vitest@4.0.18(@types/node@24.11.0)(jiti@2.6.1)(sass@1.67.0)(terser@5.46.0)(tsx@4.21.0)(yaml@1.10.2)): + dependencies: + '@better-auth/core': 1.6.9(@better-auth/utils@0.4.0)(@better-fetch/fetch@1.1.21)(better-call@1.3.5(zod@3.22.4))(jose@6.2.3)(kysely@0.28.17)(nanostores@1.3.0) + '@better-auth/drizzle-adapter': 1.6.9(@better-auth/core@1.6.9(@better-auth/utils@0.4.0)(@better-fetch/fetch@1.1.21)(better-call@1.3.5(zod@3.22.4))(jose@6.2.3)(kysely@0.28.17)(nanostores@1.3.0))(@better-auth/utils@0.4.0) + '@better-auth/kysely-adapter': 1.6.9(@better-auth/core@1.6.9(@better-auth/utils@0.4.0)(@better-fetch/fetch@1.1.21)(better-call@1.3.5(zod@3.22.4))(jose@6.2.3)(kysely@0.28.17)(nanostores@1.3.0))(@better-auth/utils@0.4.0)(kysely@0.28.17) + '@better-auth/memory-adapter': 1.6.9(@better-auth/core@1.6.9(@better-auth/utils@0.4.0)(@better-fetch/fetch@1.1.21)(better-call@1.3.5(zod@3.22.4))(jose@6.2.3)(kysely@0.28.17)(nanostores@1.3.0))(@better-auth/utils@0.4.0) + '@better-auth/mongo-adapter': 1.6.9(@better-auth/core@1.6.9(@better-auth/utils@0.4.0)(@better-fetch/fetch@1.1.21)(better-call@1.3.5(zod@3.22.4))(jose@6.2.3)(kysely@0.28.17)(nanostores@1.3.0))(@better-auth/utils@0.4.0) + '@better-auth/prisma-adapter': 1.6.9(@better-auth/core@1.6.9(@better-auth/utils@0.4.0)(@better-fetch/fetch@1.1.21)(better-call@1.3.5(zod@3.22.4))(jose@6.2.3)(kysely@0.28.17)(nanostores@1.3.0))(@better-auth/utils@0.4.0) + '@better-auth/telemetry': 1.6.9(@better-auth/core@1.6.9(@better-auth/utils@0.4.0)(@better-fetch/fetch@1.1.21)(better-call@1.3.5(zod@3.22.4))(jose@6.2.3)(kysely@0.28.17)(nanostores@1.3.0))(@better-auth/utils@0.4.0)(@better-fetch/fetch@1.1.21) + '@better-auth/utils': 0.4.0 + '@better-fetch/fetch': 1.1.21 + '@noble/ciphers': 2.2.0 + '@noble/hashes': 2.2.0 + better-call: 1.3.5(zod@4.4.3) + defu: 6.1.7 + jose: 6.2.3 + kysely: 0.28.17 + nanostores: 1.3.0 + zod: 4.4.3 + optionalDependencies: + pg: 8.18.0 + react: 16.14.0 + react-dom: 16.14.0(react@16.14.0) + vitest: 4.0.18(@types/node@24.11.0)(jiti@2.6.1)(sass@1.67.0)(terser@5.46.0)(tsx@4.21.0)(yaml@1.10.2) + transitivePeerDependencies: + - '@cloudflare/workers-types' + - '@opentelemetry/api' + + better-call@1.3.5(zod@4.4.3): + dependencies: + '@better-auth/utils': 0.4.0 + '@better-fetch/fetch': 1.1.21 + rou3: 0.7.12 + set-cookie-parser: 3.1.0 + optionalDependencies: + zod: 4.4.3 + better-opn@2.1.1: dependencies: open: 7.4.2 @@ -19234,6 +19599,8 @@ snapshots: is-descriptor: 1.0.3 isobject: 3.0.1 + defu@6.1.7: {} + delayed-stream@1.0.0: {} delegates@1.0.0: {} @@ -19685,13 +20052,13 @@ snapshots: d: 1.0.2 ext: 1.7.0 - esbuild-loader@4.4.2(webpack@4.47.0): + esbuild-loader@4.4.3(webpack@4.47.0): dependencies: esbuild: 0.27.3 get-tsconfig: 4.13.6 loader-utils: 2.0.4 webpack: 4.47.0(webpack-cli@3.3.12) - webpack-sources: 1.4.3 + webpack-sources: 3.3.4 esbuild@0.27.3: optionalDependencies: @@ -20612,6 +20979,12 @@ snapshots: package-json-from-dist: 1.0.1 path-scurry: 1.11.1 + glob@13.0.6: + dependencies: + minimatch: 10.2.2 + minipass: 7.1.3 + path-scurry: 2.0.2 + glob@7.2.0: dependencies: fs.realpath: 1.0.0 @@ -20633,7 +21006,7 @@ snapshots: glob@9.3.5: dependencies: fs.realpath: 1.0.0 - minimatch: 8.0.4 + minimatch: 8.0.7 minipass: 4.2.8 path-scurry: 1.11.1 @@ -21645,6 +22018,8 @@ snapshots: dependencies: '@panva/asn1.js': 1.0.0 + jose@6.2.3: {} + jotai@1.13.1(@babel/core@7.29.0)(@babel/template@7.28.6)(react@16.14.0): dependencies: react: 16.14.0 @@ -21873,6 +22248,8 @@ snapshots: transitivePeerDependencies: - supports-color + kysely@0.28.17: {} + latest-version@3.1.0: dependencies: package-json: 4.0.1 @@ -22060,6 +22437,8 @@ snapshots: lru-cache@10.4.3: {} + lru-cache@11.3.6: {} + lru-cache@4.1.5: dependencies: pseudomap: 1.0.2 @@ -22133,7 +22512,7 @@ snapshots: md5.js@1.3.5: dependencies: - hash-base: 3.0.5 + hash-base: 3.1.2 inherits: 2.0.4 safe-buffer: 5.2.1 @@ -22373,7 +22752,7 @@ snapshots: dependencies: brace-expansion: 2.0.2 - minimatch@8.0.4: + minimatch@8.0.7: dependencies: brace-expansion: 2.0.2 @@ -22549,6 +22928,8 @@ snapshots: transitivePeerDependencies: - supports-color + nanostores@1.3.0: {} + natural-compare@1.4.0: {} nearley@2.20.1: @@ -23118,6 +23499,11 @@ snapshots: lru-cache: 10.4.3 minipass: 7.1.3 + path-scurry@2.0.2: + dependencies: + lru-cache: 11.3.6 + minipass: 7.1.3 + path-to-regexp@0.1.12: {} path-to-regexp@1.9.0: @@ -24437,6 +24823,8 @@ snapshots: rope-sequence@1.3.4: {} + rou3@0.7.12: {} + rss@1.2.2: dependencies: mime-types: 2.1.13 @@ -24712,6 +25100,8 @@ snapshots: set-blocking@2.0.0: {} + set-cookie-parser@3.1.0: {} + set-function-length@1.2.2: dependencies: define-data-property: 1.1.4 @@ -26592,6 +26982,8 @@ snapshots: zod@3.22.4: {} + zod@4.4.3: {} + zotero-api-client@0.40.1: dependencies: '@babel/runtime': 7.28.6 diff --git a/server/apiRoutes.ts b/server/apiRoutes.ts index 727d9d7aa..c250b23af 100644 --- a/server/apiRoutes.ts +++ b/server/apiRoutes.ts @@ -44,9 +44,11 @@ import { router as userDismissableRouter } from './userDismissable/api'; import { router as userNotificationRouter } from './userNotification/api'; import { router as userNotificationPreferencesRouter } from './userNotificationPreferences/api'; import { userSubscriptionRouter } from './userSubscription/api'; +import { router as kfAuthWebhookRouter } from './kfAuthWebhook/api'; import { router as zoteroIntegrationRouter } from './zoteroIntegration/api'; const apiRouter = Router() + .use(kfAuthWebhookRouter) .use(activityItemRouter) .use(captchaRouter) .use(citationRouter) diff --git a/server/authToken/authTokenMiddleware.ts b/server/authToken/authTokenMiddleware.ts index 9324adf78..c45b1208e 100644 --- a/server/authToken/authTokenMiddleware.ts +++ b/server/authToken/authTokenMiddleware.ts @@ -1,31 +1,50 @@ import type { RequestHandler } from 'express'; -import passport from 'passport'; +import type { UserWithPrivateFields } from 'types'; -export const authTokenMiddleware: RequestHandler = async (req, res, next) => { - /** You are only allowed to access API routes with token */ +import { ForbiddenError } from 'server/utils/errors'; +import { ensureUserIsCommunityAdmin } from 'utils/ensureUserIsCommunityAdmin'; + +import { AuthToken, includeUserModel } from '../models'; + +export const authTokenMiddleware: RequestHandler = async (req, _res, next) => { if (!req.path.includes('/api')) { return next(); } - /** Do not try to authenticate by token if user is already authenticated */ if (req.user != null) { return next(); } + const authHeader = req.headers.authorization; + const token = authHeader?.startsWith('Bearer ') ? authHeader.slice(7) : null; + + if (!token) { + return next(); + } + try { - const authenticate = new Promise((resolve, reject) => { - passport.authenticate('bearer', (authErr: Error, user: any) => { - if (authErr) { - return reject(authErr); - } - return resolve(user); - })(req, res); + const authToken = await AuthToken.findOne({ + where: { token }, + include: [includeUserModel({ as: 'user' })], }); - const user = await authenticate; - req.user = user; + if (!authToken) { + return next(); + } + + const { expiresAt, user } = authToken; + + if (expiresAt !== null && expiresAt < new Date()) { + return next(new ForbiddenError(new Error('Token expired'))); + } + await ensureUserIsCommunityAdmin({ + hostname: req.hostname, + user: user as UserWithPrivateFields, + }); + + req.user = user; return next(); } catch (err) { return next(err); diff --git a/server/captcha/api.ts b/server/captcha/api.ts index 00eddd597..f7528d465 100644 --- a/server/captcha/api.ts +++ b/server/captcha/api.ts @@ -1,4 +1,3 @@ -import { createChallenge } from 'altcha-lib'; import { Router } from 'express'; import { getAltchaHmacKey } from 'server/utils/captcha'; @@ -10,6 +9,7 @@ const MAX_NUMBER = 100000; router.get('/api/captcha/challenge', async (_req, res) => { try { const hmacKey = getAltchaHmacKey(); + const { createChallenge } = await import('altcha-lib'); const challenge = await createChallenge({ hmacKey, maxNumber: MAX_NUMBER, diff --git a/server/communityTemplate/applyTemplate.ts b/server/communityTemplate/applyTemplate.ts index deb8a2351..6333bcf86 100644 --- a/server/communityTemplate/applyTemplate.ts +++ b/server/communityTemplate/applyTemplate.ts @@ -317,8 +317,8 @@ async function applyStarterPubs( actorId: string, ) { // Lazy import to avoid circular dependency (resolved once by Node.js module cache) - const { createPub } = await import('server/pub/queries'); - const { upsertDraftCheckpoint } = await import('server/draftCheckpoint/queries'); + const { createPub } = await import('server/pub/queries.js'); + const { upsertDraftCheckpoint } = await import('server/draftCheckpoint/queries.js'); for (const pubDef of pubDefs) { const collectionIds: string[] = []; diff --git a/server/envSchema.ts b/server/envSchema.ts index 453dbffc6..6426190a9 100644 --- a/server/envSchema.ts +++ b/server/envSchema.ts @@ -205,6 +205,13 @@ export const envSchema = z.object({ 'JSON array of search terms for the "By Content" tab. Each element is a string or [name, ...aliases]', ), + // ── kf-auth ───────────────────────────────────────────────────────── + KF_AUTH_URL: z.string().url().optional().describe('Base URL of the kf-auth service'), + KF_AUTH_WEBHOOK_SECRET: z + .string() + .optional() + .describe('HMAC secret for verifying kf-auth webhook signatures'), + // ── Testing ────────────────────────────────────────────────────────── INTEGRATION_TESTING: booleanish.describe('Signals that integration tests are running'), TEST_FASTLY_PURGE: booleanish.describe('Enable Fastly purge calls during tests'), diff --git a/server/hub/api.ts b/server/hub/api.ts index afc99c8b6..ca07d2f8a 100644 --- a/server/hub/api.ts +++ b/server/hub/api.ts @@ -120,7 +120,7 @@ router.get('/api/hubs/brand-helper', async (req, res, next) => { // Check if user is a manager of any hub const userId = initialData.loginData.id; if (!userId) throw new ForbiddenError(); - const { HubManager } = await import('server/hubManager/model'); + const { HubManager } = await import('server/hubManager/model.js'); const mgr = await HubManager.findOne({ where: { userId } }); if (!mgr) throw new ForbiddenError(); } @@ -142,7 +142,7 @@ router.get('/api/hubs/brand-helper/proxy-image', async (req, res, next) => { if (!initialData.loginData.isSuperAdmin) { const userId = initialData.loginData.id; if (!userId) throw new ForbiddenError(); - const { HubManager } = await import('server/hubManager/model'); + const { HubManager } = await import('server/hubManager/model.js'); const mgr = await HubManager.findOne({ where: { userId } }); if (!mgr) throw new ForbiddenError(); } @@ -768,7 +768,7 @@ const requirePubManager = async (req, pubId: string) => { router.get('/api/pubs/:pubId/curating-hubs', async (req, res, next) => { try { await requirePubManager(req, req.params.pubId); - const { getHubsForPub } = await import('server/hubPub/queries'); + const { getHubsForPub } = await import('server/hubPub/queries.js'); const orgs = await getHubsForPub(req.params.pubId); return res.status(200).json(orgs); } catch (err) { @@ -780,7 +780,7 @@ router.get('/api/pubs/:pubId/curating-hubs', async (req, res, next) => { router.delete('/api/pubs/:pubId/curating-hubs/:hubId', async (req, res, next) => { try { await requirePubManager(req, req.params.pubId); - const { removePubFromHub } = await import('server/hubPub/queries'); + const { removePubFromHub } = await import('server/hubPub/queries.js'); await removePubFromHub(req.params.hubId, req.params.pubId); return res.status(200).json({ success: true }); } catch (err) { diff --git a/server/kfAuth.ts b/server/kfAuth.ts new file mode 100644 index 000000000..c5b57fa92 --- /dev/null +++ b/server/kfAuth.ts @@ -0,0 +1,41 @@ +import type { KfSession } from '@knowledgefutures/sdk/middleware/express'; +import type { KfServerSdk } from '@knowledgefutures/sdk/server'; + +import { createKfServerSdk } from '@knowledgefutures/sdk/server'; + +import { env } from 'server/env'; + +declare global { + namespace Express { + interface Request { + kfUser?: KfSession['user']; + kfSession?: KfSession['session']; + kfJwtPayload?: Record; + } + } +} + +export interface SessionMiddlewareOptions { + /** the kf-auth server URL, e.g. "http://localhost:3000" */ + authUrl: string; +} + +let instance: KfServerSdk | null = null; + +export function getKfSdk(): KfServerSdk { + if (!instance) { + const url = env.KF_AUTH_URL; + + if (!url) { + throw new Error('KF_AUTH_URL is not configured'); + } + + instance = createKfServerSdk({ serverUrl: url }); + } + + return instance; +} + +export function resetKfSdk() { + instance = null; +} diff --git a/server/kfAuthWebhook/api.ts b/server/kfAuthWebhook/api.ts new file mode 100644 index 000000000..bcdd23b12 --- /dev/null +++ b/server/kfAuthWebhook/api.ts @@ -0,0 +1,161 @@ +import { createHmac, timingSafeEqual } from 'crypto'; + +import { Router } from 'express'; + +import { User } from 'server/models'; +import { env } from 'server/env'; + +export const router = Router(); + +interface WebhookPayload { + event: string; + timestamp: string; + data: { + id: string; + email: string; + name?: string; + image?: string; + givenName?: string; + familyName?: string; + slug?: string; + }; +} + +function verifySignature(body: string, signature: string, secret: string): boolean { + const expected = createHmac('sha256', secret).update(body).digest('hex'); + + if (expected.length !== signature.length) { + return false; + } + + return timingSafeEqual(Buffer.from(expected), Buffer.from(signature)); +} + +function deriveInitials(firstName?: string, lastName?: string): string { + const first = (firstName || '').trim().slice(0, 1).toUpperCase(); + const last = (lastName || '').trim().slice(0, 1).toUpperCase(); + + return `${first}${last}` || '??'; +} + +function deriveFullName(firstName?: string, lastName?: string, name?: string): string { + if (firstName && lastName) { + return `${firstName} ${lastName}`; + } + + return name || 'Unknown'; +} + +router.post('/api/webhooks/kf-auth', async (req, res) => { + const secret = env.KF_AUTH_WEBHOOK_SECRET; + + if (!secret) { + console.error('[kf-auth webhook] KF_AUTH_WEBHOOK_SECRET not configured'); + return res.status(500).json({ error: 'webhook secret not configured' }); + } + + const signature = req.headers['x-webhook-signature'] as string | undefined; + + if (!signature) { + return res.status(401).json({ error: 'missing signature' }); + } + + const rawBody = JSON.stringify(req.body); + const isValid = verifySignature(rawBody, signature, secret); + + if (!isValid) { + return res.status(401).json({ error: 'invalid signature' }); + } + + const payload = req.body as WebhookPayload; + const { event, data } = payload; + + try { + if (event === 'user.created') { + await handleUserCreated(data); + } else if (event === 'user.updated') { + await handleUserUpdated(data); + } + + return res.status(200).json({ ok: true }); + } catch (err) { + console.error(`[kf-auth webhook] error handling ${event}:`, err); + return res.status(500).json({ error: 'internal error' }); + } +}); + +async function handleUserCreated(data: WebhookPayload['data']) { + // check if we already have a user linked to this auth id + const existingByAuthId = await User.findOne({ where: { authId: data.id } }); + + if (existingByAuthId) { + return; + } + + // check if there's an existing user with this email we should link + const existingByEmail = await User.findOne({ where: { email: data.email } }); + + if (existingByEmail) { + await existingByEmail.update({ authId: data.id }); + return; + } + + const firstName = data.givenName || data.name?.split(' ')[0] || 'Unknown'; + const lastName = data.familyName || data.name?.split(' ').slice(1).join(' ') || ''; + const fullName = deriveFullName(firstName, lastName, data.name); + const initials = deriveInitials(firstName, lastName); + + // generate a slug from the auth slug or the name + const baseSlug = data.slug || fullName.toLowerCase().replace(/[^a-z0-9]/g, '-').replace(/-+/g, '-'); + const slug = `${baseSlug}-${Math.random().toString(36).substring(2, 6)}`; + + await User.create({ + authId: data.id, + firstName, + lastName, + fullName, + initials, + email: data.email, + slug, + avatar: data.image || null, + }); +} + +async function handleUserUpdated(data: WebhookPayload['data']) { + const user = await User.findOne({ where: { authId: data.id } }); + + if (!user) { + return; + } + + const updates: Record = {}; + + if (data.email && data.email !== user.email) { + updates.email = data.email; + } + + if (data.givenName && data.givenName !== user.firstName) { + updates.firstName = data.givenName; + } + + if (data.familyName && data.familyName !== user.lastName) { + updates.lastName = data.familyName; + } + + if (data.image !== undefined && data.image !== user.avatar) { + updates.avatar = data.image; + } + + // recompute derived fields if name parts changed + if (updates.firstName || updates.lastName) { + const newFirst = (updates.firstName as string) || user.firstName; + const newLast = (updates.lastName as string) || user.lastName; + + updates.fullName = deriveFullName(newFirst, newLast, data.name); + updates.initials = deriveInitials(newFirst, newLast); + } + + if (Object.keys(updates).length > 0) { + await user.update(updates); + } +} diff --git a/server/login/api.ts b/server/login/api.ts index 70f4ef562..0425841d0 100644 --- a/server/login/api.ts +++ b/server/login/api.ts @@ -1,154 +1,71 @@ import type { AppRouteImplementation } from '@ts-rest/express'; -import type * as types from 'types'; import type { UserSpamTagFields } from 'types'; import type { contract } from 'utils/api/contract'; -import crypto from 'crypto'; -import passport from 'passport'; -import { promisify } from 'util'; - import { User } from 'server/models'; +import { getKfSdk } from 'server/kfAuth'; import { getSpamTagForUser } from 'server/spamTag/userQueries'; import { verifyCaptchaPayload } from 'server/utils/captcha'; -import { assert } from 'utils/assert'; import { getHashedUserId } from 'utils/caching/getHashedUserId'; import { isDuqDuq, isProd } from 'utils/environment'; -type SetPasswordData = { hash: string; salt: string }; -type Step1Result = [types.UserWithPrivateFields, null] | [null, types.UserWithPrivateFields]; -type Step2Result = [types.UserWithPrivateFields, null] | [null, SetPasswordData]; -type Step3Result = [types.UserWithPrivateFields, null] | [null, types.UserWithPrivateFields[][]]; - type LoginResult = | { status: 201; body: 'success' } | { status: 401; body: 'Login attempt failed' } | { status: 403; body: string } | { status: 500; body: string }; -const performLogin = (req: any, res: any): Promise => { - const authenticate = new Promise((resolve, reject) => { - passport.authenticate('local', (authErr: Error, user: types.UserWithPrivateFields) => { - if (authErr) { - return reject(authErr); - } - return resolve(user); - })(req, res); - }); - return authenticate - .then((user) => { - if (user) { - return [user, null] as Step1Result; - } - const findUser = User.findOne({ - where: { email: req.body.email }, - }); - return Promise.all([null, findUser]) as Promise; - }) - .then(([user, userData]) => { - if (user) { - return [user, null] as Step2Result; - } - if (!userData) { - throw new Error('Invalid email'); - } - if (userData.passwordDigest === 'sha512') { - throw new Error('Invalid password'); - } - const pubpubSha1HashRaw = crypto.pbkdf2Sync( - req.body.password, - userData.salt, - 25000, - 512, - 'sha1', - ); - // @ts-expect-error ts-migrate(2769) FIXME: No overload matches this call. - const pubpubSha1Hash = Buffer.from(pubpubSha1HashRaw, 'binary').toString('hex'); - const isPubPubSha1Valid = pubpubSha1Hash === userData.hash; - - const frankenbookHashRaw = crypto.pbkdf2Sync( - req.body.password, - userData.salt, - 12000, - 512, - 'sha1', - ); - // @ts-expect-error ts-migrate(2769) FIXME: No overload matches this call. - const frankenbookHash = Buffer.from(frankenbookHashRaw, 'binary').toString('hex'); - const isfrankenbookValid = frankenbookHash === userData.hash; - - const isLegacyValid = isPubPubSha1Valid || isfrankenbookValid; - if (!isLegacyValid) { - throw new Error('Invalid password'); - } - const setPassword = promisify((userData as any).setPassword.bind(userData)); - return Promise.all([null, setPassword(req.body.password)]) as Promise; - }) - .then(([user, setPasswordData]) => { - if (user) { - return [user, null] as Step3Result; - } - assert(setPasswordData !== null); - const userUpdateData = { - passwordDigest: 'sha512', - hash: setPasswordData.hash, - salt: setPasswordData.salt, +const performLogin = async (req: any, res: any): Promise => { + try { + const kf = getKfSdk(); + + const result = await kf.signIn.email({ + email: req.body.email, + password: req.body.password, + }); + + if (result.error || !result.data) { + return { status: 401, body: 'Login attempt failed' }; + } + + const user = await User.findOne({ where: { authId: result.data.user.id } }); + + if (!user) { + return { status: 401, body: 'Login attempt failed' }; + } + + const spamTag = await getSpamTagForUser(user.id); + + if (spamTag?.status === 'confirmed-spam') { + const fields = spamTag.fields as UserSpamTagFields | null; + const wasAutomated = !fields?.manuallyMarkedBy?.length; + const automatedNote = wasAutomated + ? ' This action was taken by our automated spam detection systems.' + : ''; + + return { + status: 403, + body: `Your account has been restricted due to activity identified as spam.${automatedNote} If you believe this is an error, please contact help@pubpub.org.`, }; - const updateUser = User.update(userUpdateData, { - where: { email: req.body.email }, - returning: true, - }); - return Promise.all([null, updateUser]) as Promise; - }) - .then(([user, updatedUserData]) => { - if (user) { - return user; - } - assert(updatedUserData !== null); - return updatedUserData[1][0]; - }) - .then(async (user) => { - const spamTag = await getSpamTagForUser(user.id); - if (spamTag?.status === 'confirmed-spam') { - const fields = spamTag.fields as UserSpamTagFields | null; - const wasAutomated = !fields?.manuallyMarkedBy?.length; - throw new Error( - wasAutomated ? 'ACCOUNT_RESTRICTED_AUTOMATED' : 'ACCOUNT_RESTRICTED', - ); - } - const logIn = promisify(req.logIn.bind(req)); - await logIn(user); - const hashedUserId = getHashedUserId(user); - - res.cookie('pp-lic', `pp-li-${hashedUserId}`, { - ...(isProd() && - req.hostname.indexOf('pubpub.org') > -1 && { domain: '.pubpub.org' }), - ...(isDuqDuq() && - req.hostname.indexOf('pubpub.org') > -1 && { domain: '.duqduq.org' }), - maxAge: 30 * 24 * 60 * 60 * 1000, - }); - return { status: 201, body: 'success' } as const; - }) - .catch((err) => { - if ( - err.message === 'ACCOUNT_RESTRICTED' || - err.message === 'ACCOUNT_RESTRICTED_AUTOMATED' - ) { - const isAutomated = err.message === 'ACCOUNT_RESTRICTED_AUTOMATED'; - const automatedNote = isAutomated - ? ' This action was taken by our automated spam detection systems.' - : ''; - return { - status: 403, - body: `Your account has been restricted due to activity identified as spam.${automatedNote} If you believe this is an error, please contact help@pubpub.org.`, - } as const; - } - const unaunthenticatedValues = ['Invalid password', 'Invalid email']; - if (unaunthenticatedValues.includes(err.message)) { - return { status: 401, body: 'Login attempt failed' } as const; - } - return { status: 500, body: err.message } as const; + } + + req.session.userId = user.id; + + const hashedUserId = getHashedUserId(user); + res.cookie('pp-lic', `pp-li-${hashedUserId}`, { + ...(isProd() && + req.hostname.indexOf('pubpub.org') > -1 && { domain: '.pubpub.org' }), + ...(isDuqDuq() && + req.hostname.indexOf('pubpub.org') > -1 && { domain: '.duqduq.org' }), + maxAge: 30 * 24 * 60 * 60 * 1000, }); + + return { status: 201, body: 'success' }; + } catch (err: any) { + console.error('Error in performLogin:', err); + return { status: 500, body: err.message }; + } }; export const loginRouteImplementation: AppRouteImplementation = async ({ @@ -160,8 +77,10 @@ export const loginFromFormRouteImplementation: AppRouteImplementation< typeof contract.auth.loginFromForm > = async ({ req, res }) => { const ok = await verifyCaptchaPayload(req.body.altcha); + if (!ok) { return { status: 400, body: 'Please complete the verification and try again.' } as const; } + return performLogin(req, res); }; diff --git a/server/models.ts b/server/models.ts index 19b4e2c73..a64241f4a 100644 --- a/server/models.ts +++ b/server/models.ts @@ -1,5 +1,3 @@ -import passportLocalSequelize from 'passport-local-sequelize'; - /* Import and create all models. */ /* Also import them to make them available to other modules */ import { ActivityItem } from './activityItem/model'; @@ -132,14 +130,6 @@ sequelize.addModels([ export const { facetModels, FacetBinding } = createSequelizeModelsFromFacetDefinitions(sequelize); -passportLocalSequelize.attachToUser(User, { - usernameField: 'email', - hashField: 'hash', - saltField: 'salt', - digest: 'sha512', - iterations: 25000, -}); - export const attributesPublicUser = [ 'id', 'firstName', diff --git a/server/passwordReset/api.ts b/server/passwordReset/api.ts index e1e56dcac..0174e5ba8 100644 --- a/server/passwordReset/api.ts +++ b/server/passwordReset/api.ts @@ -1,30 +1,28 @@ -import type { User } from 'types'; - import { Router } from 'express'; +import { getKfSdk } from 'server/kfAuth'; import { wrap } from 'server/wrap'; import { sleep } from 'utils/promises'; -import { createPasswordReset, updatePasswordReset } from './queries'; - export const router = Router(); router.post( '/api/password-reset', wrap(async (req, res) => { - const user = req.user || {}; try { - await createPasswordReset(req.body, user as User, req.hostname); + const kf = getKfSdk(); + const redirectTo = `https://${req.hostname}/password-reset`; + + await kf.forgetPassword({ + email: req.body.email, + redirectTo, + }); + return res.status(200).json('success'); } catch (err: any) { - // do not leak user information - if (err.message === "User doesn't exist") { - // fake sleep to simulate delay - await sleep(1000 + Math.random() * 1000); - return res.status(200).json('success'); - } - console.error('Error in postPasswordReset: ', err); - return res.status(500).json(err.message); + // do not leak user information, always return success + await sleep(1000 + Math.random() * 1000); + return res.status(200).json('success'); } }), ); @@ -32,12 +30,21 @@ router.post( router.put( '/api/password-reset', wrap(async (req, res) => { - const user = req.user || {}; try { - await updatePasswordReset(req.body, user as User); + const kf = getKfSdk(); + + const result = await kf.resetPassword({ + newPassword: req.body.password, + token: req.body.token, + }); + + if (result.error) { + return res.status(400).json(result.error.message); + } + return res.status(200).json('success'); } catch (err: any) { - console.error('Error in putPasswordReset: ', err); + console.error('Error in putPasswordReset:', err); return res.status(500).json(err.message); } }), diff --git a/server/pub/__tests__/api.test.ts b/server/pub/__tests__/api.test.ts index 21aec9258..4bbfe0b95 100644 --- a/server/pub/__tests__/api.test.ts +++ b/server/pub/__tests__/api.test.ts @@ -588,7 +588,7 @@ describe('GET /api/pubs', () => { vi.mock('utils/import/uploadAndConvertImages', async () => { if (process.env.INTEGRATION) { - return import('utils/import/uploadAndConvertImages'); + return import('utils/import/uploadAndConvertImages.js'); } return { uploadAndConvertImages: (files) => files, diff --git a/server/sequelize.ts b/server/sequelize.ts index c4c245527..3a27307da 100644 --- a/server/sequelize.ts +++ b/server/sequelize.ts @@ -128,7 +128,7 @@ export const sequelizeSyncPromise: Promise = installSearchTriggers, backfillPubSearchVectors, backfillCommunitySearchVectors, - } = await import('server/search2/searchTriggers'); + } = await import('./search2/searchTriggers.js'); await installSearchTriggers(); // Run backfill in the background so it doesn't block app startup. @@ -144,7 +144,7 @@ export const sequelizeSyncPromise: Promise = // Create analytics materialized views (idempotent — no-ops if they exist). // Refresh is handled by the nightly cron, not at startup, because it can // take several minutes and would delay deploys. - const { createSummaryViews } = await import('server/analytics/summaryViews'); + const { createSummaryViews } = await import('./analytics/summaryViews.js'); await createSummaryViews(); })() : Promise.resolve(); diff --git a/server/server.ts b/server/server.ts index 1cd9c2fa2..627cc916b 100755 --- a/server/server.ts +++ b/server/server.ts @@ -5,7 +5,7 @@ import cors from 'cors'; import express, { type ErrorRequestHandler, Router } from 'express'; import enforce from 'express-sslify'; import noSlash from 'no-slash'; -import passport from 'passport'; +import passport from 'passport'; // kept only for zotero oauth import path from 'path'; import { env } from './env'; @@ -107,7 +107,6 @@ import { schedulePurge } from 'utils/caching/schedulePurgeWithSentry'; import { abortStorage } from './abort'; import { authTokenMiddleware } from './authToken/authTokenMiddleware'; -import { bearerStrategy } from './authToken/strategy'; const SequelizeStore = CreateSequelizeStore(session.Store); @@ -151,14 +150,29 @@ appRouter.use((req, res, next) => { /* ------------------- */ /* Configure app login */ /* ------------------- */ + +// session deserialization: load the pubpub user from req.session.userId +// this replaces passport's deserializeUser and keeps req.user as the full User model instance +appRouter.use(async (req, _res, next) => { + /** @ts-expect-error */ + const id = req.session?.userId; + console.log('id', req.session); + if (!id) { + return next(); + } + + const user = await User.findByPk(id); + + if (user) { + req.user = user; + } + + next(); +}); + +// passport is only used for zotero oauth, not for user auth appRouter.use(passport.initialize()); -appRouter.use(passport.session()); -passport.use(User.createStrategy()); passport.use('zotero', zoteroAuthStrategy()); -passport.use('bearer', bearerStrategy()); - -passport.serializeUser(User.serializeUser()); -passport.deserializeUser(User.deserializeUser()); /* ---------------- */ /* Server Endpoints */ /* ---------------- */ diff --git a/server/signup/api.ts b/server/signup/api.ts index d4374d50a..019d9477b 100644 --- a/server/signup/api.ts +++ b/server/signup/api.ts @@ -1,28 +1,56 @@ import { Router } from 'express'; +import { getKfSdk } from 'server/kfAuth'; +import { User } from 'server/models'; import { verifyCaptchaPayload } from 'server/utils/captcha'; import { isHoneypotFilled } from 'server/utils/honeypot'; -import { createSignup, DuplicateEmailError } from './queries'; - export const router = Router(); router.post('/api/signup', async (req, res) => { if (isHoneypotFilled(req.body)) { return res.status(201).json(true); } + const ok = await verifyCaptchaPayload(req.body.altcha); + if (!ok) { return res.status(400).json('Please complete the verification and try again.'); } - const { _honeypot, altcha: _altcha, ...body } = req.body; - return createSignup(body, req.hostname) - .then(() => res.status(201).json(true)) - .catch((err) => { - if (err instanceof DuplicateEmailError) { - return res.status(409).json(err.message); - } - console.error('Error in postSignup: ', err); - return res.status(500).json(err.message); + + const email = req.body.email?.toLowerCase().trim(); + + if (!email) { + return res.status(400).json('Email is required.'); + } + + // check for existing pubpub user + const existingUser = await User.findOne({ where: { email } }); + + if (existingUser) { + return res.status(409).json('Email already used'); + } + + try { + const kf = getKfSdk(); + + const callbackURL = `https://${req.hostname}/user/create`; + + const result = await kf.signUp.email({ + email, + password: req.body.password, + name: req.body.name || email.split('@')[0], + callbackURL, }); + + if (result.error) { + console.error('kf-auth signup error:', result.error); + return res.status(500).json('Failed to create account. Please try again.'); + } + + return res.status(201).json(true); + } catch (err: any) { + console.error('Error in postSignup:', err); + return res.status(500).json(err.message); + } }); diff --git a/server/user/__tests__/api.test.ts b/server/user/__tests__/api.test.ts index be521af57..14e4c8f4e 100644 --- a/server/user/__tests__/api.test.ts +++ b/server/user/__tests__/api.test.ts @@ -50,14 +50,14 @@ const { deleteSessionsForUser } = vitest.hoisted(() => { }; }); -vitest.mock(import('server/utils/session'), async (importOriginal) => { +vitest.mock(import('server/utils/session.js'), async (importOriginal) => { const og = await importOriginal(); return { ...og, deleteSessionsForUser: deleteSessionsForUser, }; }); -vitest.mock(import('server/spamTag/notifications'), async (importOriginal) => { +vitest.mock(import('server/spamTag/notifications/index.js'), async (importOriginal) => { const og = await importOriginal(); return { ...og, @@ -115,7 +115,7 @@ describe('/api/users', () => { .expect(403); const createdUser = await User.findOne({ where: { email: spamSignup.email } }); expect(createdUser).toBeDefined(); - const { getSpamTagForUser } = await import('server/spamTag/userQueries'); + const { getSpamTagForUser } = await import('server/spamTag/userQueries.js'); const spamTag = await getSpamTagForUser(createdUser!.id); expect(spamTag?.status).toBe('confirmed-spam'); await agent @@ -143,7 +143,7 @@ describe('/api/users', () => { if (!createdUser) { throw new Error('Expected user to be created'); } - const { getSpamTagForUser } = await import('server/spamTag/userQueries'); + const { getSpamTagForUser } = await import('server/spamTag/userQueries.js'); const spamTag = await getSpamTagForUser(createdUser.id); expect(spamTag).toBeNull(); await agent @@ -169,7 +169,7 @@ describe('/api/users', () => { .expect(403); const createdUser = await User.findOne({ where: { email: restrictedSignup.email } }); expect(createdUser).toBeDefined(); - const { getSpamTagForUser } = await import('server/spamTag/userQueries'); + const { getSpamTagForUser } = await import('server/spamTag/userQueries.js'); const spamTag = await getSpamTagForUser(createdUser!.id); expect(spamTag?.status).toEqual('confirmed-spam'); diff --git a/server/user/account.ts b/server/user/account.ts index 7995b9c89..1bfbae346 100644 --- a/server/user/account.ts +++ b/server/user/account.ts @@ -1,9 +1,8 @@ import { initServer } from '@ts-rest/express'; import { Op } from 'sequelize'; -import { promisify } from 'util'; +import { getKfSdk } from 'server/kfAuth'; import { EmailChangeToken, User, WorkerTask } from 'server/models'; -import { authenticate } from 'server/utils/authenticate'; import { sendEmailChangeEmail } from 'server/utils/email'; import { logout } from 'server/utils/logout'; import { addWorkerTask } from 'server/utils/workers'; @@ -29,8 +28,9 @@ export const accountServer = s.router(contract.account, { } const userData = await User.findOne({ where: { id: userId } }); + const hasAuthId = userData?.authId; - if (!userData) { + if (!userData || !hasAuthId) { return { status: 403, body: { message: 'User not found' }, @@ -38,25 +38,18 @@ export const accountServer = s.router(contract.account, { } try { - await authenticate(userData, body.currentPassword); - } catch (_error) { - return { status: 403, body: { message: 'Current password is incorrect' } }; - } + const kf = getKfSdk(); - try { - const setPassword = promisify(userData.setPassword.bind(userData)); - const updatedUser = await setPassword(body.newPassword); + const result = await kf.changePassword({ + currentPassword: body.currentPassword, + newPassword: body.newPassword, + sessionToken: req.kfSession?.token || '', + }); - await User.update( - { - hash: updatedUser?.dataValues.hash, - salt: updatedUser?.dataValues.salt, - passwordDigest: 'sha512', - }, - { where: { id: userId } }, - ); + if (result.error) { + return { status: 403, body: { message: 'Current password is incorrect' } }; + } - // force logout after password change logout(req, res); return { status: 200, body: { success: true } }; @@ -94,8 +87,17 @@ export const accountServer = s.router(contract.account, { }; } + // verify password via kf-auth sign-in try { - await authenticate(userData, body.password); + const kf = getKfSdk(); + const authResult = await kf.signIn.email({ + email: userData.email, + password: body.password, + }); + + if (authResult.error) { + return { status: 403, body: { message: 'Password is incorrect' } }; + } } catch (_error) { return { status: 403, body: { message: 'Password is incorrect' } }; } @@ -214,14 +216,22 @@ export const accountServer = s.router(contract.account, { }; } - // Require password confirmation + // verify password via kf-auth sign-in try { - await authenticate(userData, body.password); + const kf = getKfSdk(); + const authResult = await kf.signIn.email({ + email: userData.email, + password: body.password, + }); + + if (authResult.error) { + return { status: 403, body: { message: 'Password is incorrect' } }; + } } catch (_error) { return { status: 403, body: { message: 'Password is incorrect' } }; } - // Block deletion if user is sole admin of any community + // block deletion if user is sole admin of any community const audit = await getUserDeletionAudit(userId); if (audit.soleAdminCommunities.length > 0) { return { @@ -232,8 +242,18 @@ export const accountServer = s.router(contract.account, { }; } - // Destroy the account first so logout only happens after successful deletion await destroyUser(userId); + + // also delete the user from kf-auth + if (userData.authId) { + try { + const kf = getKfSdk(); + await kf.deleteUser(userData.authId); + } catch (err) { + console.error('Failed to delete user from kf-auth:', err); + } + } + logout(req, res); return { status: 200, body: { success: true } }; diff --git a/server/user/model.ts b/server/user/model.ts index c2d6fb83b..ac1b1e8ad 100644 --- a/server/user/model.ts +++ b/server/user/model.ts @@ -246,6 +246,10 @@ export class User extends ModelWithPassport, InferCreation @Column(DataType.TEXT) declare salt: CreationOptional; + @Unique + @Column(DataType.TEXT) + declare authId: string | null; + @Default(null) @Column(DataType.BOOLEAN) declare gdprConsent: CreationOptional; diff --git a/server/utils/__tests__/captcha.test.ts b/server/utils/__tests__/captcha.test.ts index 16c0b7acc..0198be192 100644 --- a/server/utils/__tests__/captcha.test.ts +++ b/server/utils/__tests__/captcha.test.ts @@ -1,8 +1,7 @@ -import { createChallenge, solveChallenge } from 'altcha-lib'; - import { getAltchaHmacKey, verifyCaptchaPayload } from '../captcha'; const createValidPayload = async (): Promise => { + const { createChallenge, solveChallenge } = await import('altcha-lib'); const hmacKey = getAltchaHmacKey(); const challenge = await createChallenge({ hmacKey, maxNumber: 1000 }); const { promise } = solveChallenge( @@ -50,6 +49,7 @@ describe('verifyCaptchaPayload', () => { it('returns false for a payload with a wrong solution number', async () => { const hmacKey = getAltchaHmacKey(); + const { createChallenge } = await import('altcha-lib'); const challenge = await createChallenge({ hmacKey, maxNumber: 1000 }); const tampered = btoa( JSON.stringify({ diff --git a/server/utils/__tests__/ssr.test.tsx b/server/utils/__tests__/ssr.test.tsx index ca9b4ec86..465e5adbc 100644 --- a/server/utils/__tests__/ssr.test.tsx +++ b/server/utils/__tests__/ssr.test.tsx @@ -72,7 +72,7 @@ describe('generateMetaComponents', () => { }; }); - const ssrModule = await import('../ssr'); + const ssrModule = await import('../ssr.js'); expect(ssrModule.generateMetaComponents(props as any)).toMatchInlineSnapshot(` [ @@ -226,7 +226,7 @@ describe('generateMetaComponents', () => { }; }); - const ssrModule = await import('../ssr'); + const ssrModule = await import('../ssr.js'); expect(ssrModule.generateMetaComponents(props as any)).toContainEqual( , ); diff --git a/server/utils/captcha.ts b/server/utils/captcha.ts index 028e2beec..9a2279be0 100644 --- a/server/utils/captcha.ts +++ b/server/utils/captcha.ts @@ -1,5 +1,3 @@ -import { verifySolution } from 'altcha-lib'; - import { env } from 'server/env'; import { isProd } from 'utils/environment'; @@ -36,5 +34,6 @@ export const verifyCaptchaPayload = async (payload: unknown): Promise = if (isCaptchaBypassed()) return true; if (!payload || typeof payload !== 'string') return false; const hmacKey = getAltchaHmacKey(); + const { verifySolution } = await import('altcha-lib'); return verifySolution(payload, hmacKey); }; diff --git a/server/utils/logout.ts b/server/utils/logout.ts index f3a4d3012..6ddfe4eb8 100644 --- a/server/utils/logout.ts +++ b/server/utils/logout.ts @@ -8,5 +8,5 @@ export const logout = (req: Request, res: Response) => { ...(isProd() && req.hostname.indexOf('pubpub.org') > -1 && { domain: '.pubpub.org' }), ...(isDuqDuq() && req.hostname.indexOf('pubpub.org') > -1 && { domain: '.duqduq.org' }), }); - req.logout(); + req.session.destroy(() => {}); }; diff --git a/server/utils/serverModuleOverwrite.ts b/server/utils/serverModuleOverwrite.ts index 8340cfb35..dd17009b3 100644 --- a/server/utils/serverModuleOverwrite.ts +++ b/server/utils/serverModuleOverwrite.ts @@ -5,7 +5,7 @@ const Module = require('module'); const originalRequire = Module.prototype.require; Module.prototype.require = function (...args) { - if (args[0].indexOf('.scss') > -1) { + if (args[0].indexOf('.scss') > -1 || args[0].indexOf('.css') > -1) { return () => {}; } return originalRequire.apply(this, args); diff --git a/stubstub/global/setup.ts b/stubstub/global/setup.ts index 17a445241..4ab54f927 100644 --- a/stubstub/global/setup.ts +++ b/stubstub/global/setup.ts @@ -27,9 +27,7 @@ export default async () => { const dotenv = require('dotenv'); dotenv.config({ path: path.join(__dirname, '..', '..', 'infra', '.env.test') }); - console.log(process.env); - const { env, refreshEnv } = await import('server/env'); - console.log(env); + const { env, refreshEnv } = await import('server/env.js'); if (!process.env.DATABASE_URL) { console.log('\nSit tight while a local test database is created...'); @@ -51,7 +49,7 @@ export default async () => { * create the tables in the test db, leading to "relation does not exist" errors when running * tests */ - const { sequelize } = await import('../../server/models'); + const { sequelize } = await import('../../server/models.js'); // Install pg_trgm before sync so the User model's GIN trigram indexes can be created await sequelize.query('CREATE EXTENSION IF NOT EXISTS pg_trgm;'); await sequelize.sync(); @@ -62,7 +60,7 @@ export default async () => { * * If this is not here, then the tests will fail. */ - const { FeatureFlag } = await import('../../server/models'); + const { FeatureFlag } = await import('../../server/models.js'); await FeatureFlag.findOrCreate({ where: { diff --git a/stubstub/kfAuth.ts b/stubstub/kfAuth.ts new file mode 100644 index 000000000..556027614 --- /dev/null +++ b/stubstub/kfAuth.ts @@ -0,0 +1,86 @@ +/** + * test stub for the kf-auth SDK module. + * + * replaces the real kf-auth sdk with a fake that looks up users locally + * and checks plain passwords, so tests don't need a running kf-auth instance. + */ + +import sinon from 'sinon'; + +import { User } from '../server/models'; + +import * as kfAuthModule from '../server/kfAuth'; + +let restoreFn: (() => void) | null = null; + +export function stubKfAuth() { + if (restoreFn) { + return; + } + + const stub = sinon.stub(kfAuthModule, 'getKfSdk').returns({ + signIn: { + email: async (data: { email: string; password: string }) => { + const user = await User.findOne({ where: { email: data.email } }); + + if (!user || !user.authId) { + return { error: { message: 'Invalid credentials' } }; + } + + // in tests, the plain password is stored on the user object by the builder, + // but we can't access it here. instead, we just trust the login since + // the test builder sets known passwords. + // the real verification happens in the login route's integration test. + return { + data: { + user: { + id: user.authId, + email: user.email, + name: user.fullName, + }, + }, + }; + }, + }, + + signUp: { + email: async (data: { email: string; password: string; name: string }) => { + return { + data: { + user: { + id: `test-auth-${Date.now()}`, + email: data.email, + name: data.name, + }, + }, + }; + }, + }, + + forgetPassword: async () => ({ data: {} }), + resetPassword: async () => ({ data: {} }), + + changePassword: async () => ({ data: {} }), + + importUsers: async (users: any[]) => ({ + results: users.map((u) => ({ + email: u.email, + id: `imported-${Date.now()}`, + status: 'created' as const, + })), + }), + + deleteUser: async () => ({ success: true }), + } as any); + + restoreFn = () => { + stub.restore(); + restoreFn = null; + }; +} + +export function restoreKfAuth() { + if (restoreFn) { + restoreFn(); + } +} diff --git a/stubstub/modelize/builders.ts b/stubstub/modelize/builders.ts index f0cd835c0..8478c591a 100644 --- a/stubstub/modelize/builders.ts +++ b/stubstub/modelize/builders.ts @@ -9,8 +9,6 @@ import type { UserWithPrivateFields as UserType, } from 'types'; -import encHex from 'crypto-js/enc-hex'; -import SHA3 from 'crypto-js/sha3'; import uuid from 'uuid'; import { getEmptyDoc } from 'client/components/Editor'; @@ -75,15 +73,6 @@ export const builders = { }, ): Promise => { const uniqueness = uuid.v4(); - const defaults = { - firstName: 'Test', - lastName: 'Testington', - email: `testuser-${uniqueness}@email.su`, - slug: uniqueness, - password: 'password123', - }; - - const input = { ...defaults, ...args }; const { firstName = 'Test', @@ -95,33 +84,30 @@ export const builders = { password = 'password123', id, isSuperAdmin = false, - } = input; + ...rest + } = { ...args } as any; - const sha3hashedPassword = SHA3(password).toString(encHex); - return new Promise((resolve, reject) => { - User.register( - { - ...(id && { id }), - firstName, - lastName, - fullName, - email, - slug, - initials, - isSuperAdmin, - passwordDigest: 'sha512', - }, - sha3hashedPassword, - (err, user) => { - if (err || !user) { - return reject(err); - } + const authId = uuid.v4(); - user.sha3hashedPassword = sha3hashedPassword; - return resolve(user); - }, - ); + const user = await User.create({ + ...(id && { id }), + firstName, + lastName, + fullName, + email, + slug, + initials, + isSuperAdmin, + authId, + hash: '', + salt: '', + ...rest, }); + + // store the plain password so the login helper can use it + (user as any).plainPassword = password; + + return user; }, Community: async ( diff --git a/stubstub/userToAgentMap.ts b/stubstub/userToAgentMap.ts index 5238edbd1..fa1e50dcf 100644 --- a/stubstub/userToAgentMap.ts +++ b/stubstub/userToAgentMap.ts @@ -1,7 +1,5 @@ import type { Server } from 'http'; -import type { UserWithPrivateFieldsAndHashedPassword } from 'types'; - import supertest from 'supertest'; import { __appImmutableListenOnly } from '../server/server'; @@ -11,7 +9,7 @@ const userToAgentMap = new Map(); let server: Server | null = null; export const login = async ( - user?: UserWithPrivateFieldsAndHashedPassword, + user?: any, ): Promise => { server ??= __appImmutableListenOnly.listen(); @@ -19,20 +17,23 @@ export const login = async ( const loggedOutAgent = supertest.agent(server); return loggedOutAgent; } + if (userToAgentMap.get(user)) { return userToAgentMap.get(user); } const createAgent = async () => { const agent = supertest.agent(server); + try { await agent .post('/api/login') .send({ email: user.email, - password: user.sha3hashedPassword, + password: user.plainPassword, }) .expect(201); + return agent; } catch (err) { throw new Error(`Failed to log in user ${user.email}: ${err}`); @@ -41,6 +42,7 @@ export const login = async ( const entry = await createAgent(); userToAgentMap.set(user, entry); + return entry; }; diff --git a/tools/localdb.ts b/tools/localdb.ts index 32033cef6..ce0ee7fac 100644 --- a/tools/localdb.ts +++ b/tools/localdb.ts @@ -2,7 +2,7 @@ import { setupLocalDatabase } from '../localDatabase'; const main = async () => { await setupLocalDatabase(true); - const { modelize } = await import('stubstub'); + const { modelize } = await import('stubstub/index.js'); const models = modelize` Community { createFullCommunity: true diff --git a/tools/migrateRedshift.ts b/tools/migrateRedshift.ts index 2aabcea21..c0a594805 100644 --- a/tools/migrateRedshift.ts +++ b/tools/migrateRedshift.ts @@ -445,7 +445,7 @@ async function main() { log('[7/7] creating & refreshing summary materialized views...'); const { createSummaryViews, refreshSummaryViews } = await import( - 'server/analytics/summaryViews' + 'server/analytics/summaryViews.js' ); await createSummaryViews(); await refreshSummaryViews(); diff --git a/tools/migrations/2026_05_07_migrate_to_kf_auth.js b/tools/migrations/2026_05_07_migrate_to_kf_auth.js new file mode 100644 index 000000000..5cf388f5f --- /dev/null +++ b/tools/migrations/2026_05_07_migrate_to_kf_auth.js @@ -0,0 +1,132 @@ +// @ts-check + +/** + * one-time migration tool to import pubpub users into kf-auth. + * + * reads all users with valid password hashes (passwordDigest = 'sha512'), + * formats them as pubpub::, calls the kf-auth bulk import API, + * and stores the returned authId in pubpub's Users table. + * + * usage: + * pnpm tools migrations/migrate-to-kf-auth # dry run + * pnpm tools migrations/migrate-to-kf-auth --commit # actually write + */ +import { Op } from "sequelize"; +import { SpamTag, User } from "server/models"; +import { getKfSdk } from "server/kfAuth"; +const BATCH_SIZE = 100; + +// interface MigrationStats { +// total: number; +// migrated: number; +// skippedExisting: number; +// skippedNoHash: number; +// errors: number; +// } + +export const up = async ({ Sequelize, sequelize }) => { + await sequelize.queryInterface.addColumn("Users", "authId", { + type: Sequelize.TEXT, + allowNull: true, + defaultValue: null, + }); + + const commit = process.argv.includes("--commit"); + const stats = { + total: 0, + migrated: 0, + skippedExisting: 0, + skippedNoHash: 0, + errors: 0, + }; + console.log(`[migrate-to-kf-auth] mode: ${commit ? "COMMIT" : "DRY RUN"}`); + const users = await User.findAll({ + where: { + authId: { [Op.is]: null }, + }, + include: [ + { + model: SpamTag, + as: "spamTag", + where: { status: { [Op.in]: ["confirmed-not-spam", "unreviewed"] } }, + required: false, + }, + ], + order: [["createdAt", "ASC"]], + }); + stats.total = users.length; + + console.log(`[migrate-to-kf-auth] found ${users.length} users without authId`); + + const kf = getKfSdk(); + const batches = []; + + for (let i = 0; i < users.length; i += BATCH_SIZE) { + batches.push(users.slice(i, i + BATCH_SIZE)); + } + + for (const batch of batches) { + const importable = batch.filter((user) => { + const hasValidHash = user.hash && user.salt && user.passwordDigest === "sha512"; + if (!hasValidHash) { + stats.skippedNoHash++; + } + return true; + }); + const importPayload = importable.map((user) => { + const hasHash = user.hash && user.salt && user.passwordDigest === "sha512"; + return { + email: user.email, + name: user.fullName || `${user.firstName} ${user.lastName}`, + givenName: user.firstName, + familyName: user.lastName, + // if no valid hash, import without password (user can reset via forgot-password) + passwordHash: hasHash ? `pubpub:${user.salt}:${user.hash}` : "", + emailVerified: true, + }; + }); + if (!commit) { + for (const payload of importPayload) { + console.log(`[dry-run] would import: ${payload.email} (${payload.name})`); + stats.migrated++; + } + continue; + } + try { + const result = await kf.importUsers(importPayload); + for (const entry of result.results) { + if (entry.status === "created" && entry.id) { + const user = batch.find((u) => u.email === entry.email); + if (user) { + await User.update({ authId: entry.id }, { where: { id: user.id } }); + stats.migrated++; + } + } else if (entry.status === "exists" && entry.id) { + const user = batch.find((u) => u.email === entry.email); + if (user) { + await User.update({ authId: entry.id }, { where: { id: user.id } }); + stats.skippedExisting++; + } + } else if (entry.status === "error") { + console.error(`[error] ${entry.email}: ${entry.error}`); + stats.errors++; + } + } + } catch (err) { + console.error(`[error] batch import failed:`, err); + stats.errors += importPayload.length; + } + } + console.log(`\n[migrate-to-kf-auth] results:`); + console.log(` total users: ${stats.total}`); + console.log(` migrated: ${stats.migrated}`); + console.log(` already existed: ${stats.skippedExisting}`); + console.log(` no valid hash: ${stats.skippedNoHash}`); + console.log(` errors: ${stats.errors}`); + if (!commit) { + console.log(`\n run with --commit to actually write changes`); + } +}; +export const down = async () => { + throw new Error("this migration is not reversible from here; clear authId manually if needed"); +}; diff --git a/tsconfig.json b/tsconfig.json index 97b79e1da..c43dd0571 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -10,7 +10,7 @@ /* Visit https://aka.ms/tsconfig.json to read more about this file */ /* Basic Options */ "target": "es6" /* Specify ECMAScript target version: 'ES3' (default), 'ES5', 'ES2015', 'ES2016', 'ES2017', 'ES2018', 'ES2019', 'ES2020', or 'ESNEXT'. */, - "module": "commonjs" /* Specify module code generation: 'none', 'commonjs', 'amd', 'system', 'umd', 'es2015', 'es2020', or 'ESNext'. */, + "module": "nodenext" /* Specify module code generation: 'none', 'commonjs', 'amd', 'system', 'umd', 'es2015', 'es2020', or 'ESNext'. */, "lib": ["es2019", "dom"] /* Specify library files to be included in the compilation. */, "allowJs": true /* Allow javascript files to be compiled. */, // "checkJs": true, /* Report errors in .js files. */ @@ -30,6 +30,7 @@ // "isolatedModules": true, /* Transpile each file as a separate module (similar to 'ts.transpileModule'). */ /* Strict Type-Checking Options */ "strict": true /* Enable all strict type-checking options. */, + "moduleResolution": "node16", "noImplicitAny": false /* Raise error on expressions and declarations with an implied 'any' type. */, // "strictNullChecks": true, /* Enable strict null checks. */ // "strictFunctionTypes": true, /* Enable strict checking of function types. */ diff --git a/types/global.d.ts b/types/global.d.ts index 6527e9606..90b0b469f 100644 --- a/types/global.d.ts +++ b/types/global.d.ts @@ -1,4 +1,6 @@ -import { UserWithPrivateFields } from './user'; +import type { KfSession } from '@knowledgefutures/sdk/middleware/express'; + +import type { UserWithPrivateFields } from './user'; export {}; @@ -6,6 +8,10 @@ declare global { namespace Express { export interface Request { user?: UserWithPrivateFields; + + kfUser?: KfSession['user']; + kfSession?: KfSession['session']; + kfJwtPayload?: Record; } } } diff --git a/utils/caching/__tests__/purge.test.ts b/utils/caching/__tests__/purge.test.ts index 2f6bf6dde..5d7a90945 100644 --- a/utils/caching/__tests__/purge.test.ts +++ b/utils/caching/__tests__/purge.test.ts @@ -149,7 +149,7 @@ let serviceId: string; setup(beforeAll, async () => { await models.resolve(); - const { env } = await import('server/env'); + const { env } = await import('server/env.js'); token = env.FASTLY_PURGE_TOKEN; serviceId = env.FASTLY_SERVICE_ID; @@ -165,7 +165,7 @@ setup(beforeAll, async () => { }); teardown(afterAll, async () => { - const { env } = await import('server/env'); + const { env } = await import('server/env.js'); env.TEST_FASTLY_PURGE = false; setEnvironment(false, false, false); diff --git a/workers/tasks/export/styles/buildCss.mts b/workers/tasks/export/styles/buildCss.mts index 51be237ed..1d2ce1be7 100644 --- a/workers/tasks/export/styles/buildCss.mts +++ b/workers/tasks/export/styles/buildCss.mts @@ -7,7 +7,6 @@ const katexCdnPrefix = 'https://cdnjs.cloudflare.com/ajax/libs/KaTeX/0.13.18/'; * this is run once per build to generate the export CSS */ export const buildExportCss = async () => { - // @ts-expect-error shh const stylesDir = path.join(new URL('.', import.meta.url).pathname); const entrypoint = path.join(stylesDir, 'printDocument.scss'); const cssPath = path.join(stylesDir, 'printDocument.css'); @@ -39,7 +38,6 @@ export const buildExportCss = async () => { return cssPath; }; -// @ts-expect-error shh if (import.meta.main) { buildExportCss(); } From 1d6dc91841abbee1017964e6182efa221fe57113 Mon Sep 17 00:00:00 2001 From: "Thomas F. K. Jorna" Date: Thu, 7 May 2026 19:55:31 +0200 Subject: [PATCH 2/2] fix: kf auth!! --- .test/setup-kf-auth.ts | 2 +- .../AccountSecuritySettings.tsx | 8 +- .../DashboardCustomScripts.tsx | 10 +- client/containers/Login/Login.tsx | 4 +- .../PasswordReset/PasswordReset.tsx | 70 ++++++---- client/containers/UserCreate/UserCreate.tsx | 4 +- infra/docker-compose.dev.yml | 3 + knowledgefutures-sdk-0.1.0.tgz | Bin 34936 -> 36546 bytes pnpm-lock.yaml | 2 +- server/apiRoutes.ts | 2 +- server/envSchema.ts | 4 + server/kfAuth.ts | 5 +- server/kfAuthWebhook/api.ts | 11 +- server/login/api.ts | 30 ++++- server/passwordReset/api.ts | 7 +- server/routes/passwordReset.tsx | 7 +- server/user/account.ts | 22 +++- server/user/api.ts | 24 ++-- server/user/queries.ts | 38 +++--- stubstub/kfAuth.ts | 4 +- stubstub/modelize/builders.ts | 56 +++++--- stubstub/userToAgentMap.ts | 4 +- .../2026_05_07_migrate_to_kf_auth.js | 123 ++++++++++-------- 23 files changed, 272 insertions(+), 168 deletions(-) diff --git a/.test/setup-kf-auth.ts b/.test/setup-kf-auth.ts index bc293108f..8cfec0628 100644 --- a/.test/setup-kf-auth.ts +++ b/.test/setup-kf-auth.ts @@ -1,4 +1,4 @@ -import { stubKfAuth, restoreKfAuth } from '../stubstub/kfAuth'; +import { restoreKfAuth, stubKfAuth } from '../stubstub/kfAuth'; beforeAll(() => { stubKfAuth(); diff --git a/client/components/AccountSecuritySettings/AccountSecuritySettings.tsx b/client/components/AccountSecuritySettings/AccountSecuritySettings.tsx index 55398802b..455beab94 100644 --- a/client/components/AccountSecuritySettings/AccountSecuritySettings.tsx +++ b/client/components/AccountSecuritySettings/AccountSecuritySettings.tsx @@ -1,8 +1,6 @@ import React, { useState } from 'react'; import { Button, Callout, Card } from '@blueprintjs/core'; -import encHex from 'crypto-js/enc-hex'; -import SHA3 from 'crypto-js/sha3'; import { apiFetch } from 'client/utils/apiFetch'; import { InputField } from 'components'; @@ -47,8 +45,8 @@ const AccountSecuritySettings = ({ userEmail }: { userEmail: string }) => { apiFetch('/api/account/password', { method: 'PUT', body: JSON.stringify({ - currentPassword: SHA3(currentPassword).toString(encHex), - newPassword: SHA3(newPassword).toString(encHex), + currentPassword, + newPassword, }), }) .then(() => { @@ -82,7 +80,7 @@ const AccountSecuritySettings = ({ userEmail }: { userEmail: string }) => { method: 'POST', body: JSON.stringify({ newEmail: submittedEmailValue, - password: SHA3(emailPassword).toString(encHex), + password: emailPassword, }), }) .then(() => { diff --git a/client/containers/DashboardCustomScripts/DashboardCustomScripts.tsx b/client/containers/DashboardCustomScripts/DashboardCustomScripts.tsx index b17c1c483..196df621b 100644 --- a/client/containers/DashboardCustomScripts/DashboardCustomScripts.tsx +++ b/client/containers/DashboardCustomScripts/DashboardCustomScripts.tsx @@ -26,10 +26,12 @@ const DashboardCustomScripts = (props: Props) => { import( /* webpackChunkName: "@monaco-editor/react" */ '@monaco-editor/react' - ).then(({ default: EditorComponent }) => setEditor( - // @ts-expect-error somehow not assignable to EditorComponentType after -> module resolution change - EditorComponent - )); + ).then(({ default: EditorComponent }) => + setEditor( + // @ts-expect-error somehow not assignable to EditorComponentType after -> module resolution change + EditorComponent, + ), + ); }, []); const renderLoading = () => { diff --git a/client/containers/Login/Login.tsx b/client/containers/Login/Login.tsx index 9de6f7fef..d2a228f7e 100644 --- a/client/containers/Login/Login.tsx +++ b/client/containers/Login/Login.tsx @@ -3,8 +3,6 @@ import type { AltchaRef } from 'components'; import React, { useRef, useState } from 'react'; import { AnchorButton, Button, Classes, NonIdealState } from '@blueprintjs/core'; -import encHex from 'crypto-js/enc-hex'; -import SHA3 from 'crypto-js/sha3'; import { apiFetch } from 'client/utils/apiFetch'; import { Altcha, Avatar, GridWrapper, InputField } from 'components'; @@ -44,7 +42,7 @@ const Login = () => { // @ts-expect-error ts-migrate(2531) FIXME: Object is possibly 'null'. email: emailRef.current.value.toLowerCase(), // @ts-expect-error ts-migrate(2531) FIXME: Object is possibly 'null'. - password: SHA3(passwordRef.current.value).toString(encHex), + password: passwordRef.current.value, altcha: altchaPayload, }), }) diff --git a/client/containers/PasswordReset/PasswordReset.tsx b/client/containers/PasswordReset/PasswordReset.tsx index 895b03a72..582455821 100644 --- a/client/containers/PasswordReset/PasswordReset.tsx +++ b/client/containers/PasswordReset/PasswordReset.tsx @@ -1,8 +1,6 @@ import React, { useState } from 'react'; import { AnchorButton, Button, Classes, NonIdealState } from '@blueprintjs/core'; -import encHex from 'crypto-js/enc-hex'; -import SHA3 from 'crypto-js/sha3'; import { apiFetch } from 'client/utils/apiFetch'; import { GridWrapper, InputField } from 'components'; @@ -61,9 +59,8 @@ const PasswordReset = (props: Props) => { return apiFetch('/api/password-reset', { method: 'PUT', body: JSON.stringify({ - password: SHA3(password).toString(encHex), - slug: locationData.params.slug, - resetHash: locationData.params.resetHash, + password, + token: locationData.query.token || locationData.params.resetHash, }), }) .then(() => { @@ -81,11 +78,15 @@ const PasswordReset = (props: Props) => { return (
- {!showConfirmation && !resetHash &&

Reset Password

} - {!showConfirmation && !!resetHash &&

Set Password

} + {!showConfirmation && !resetHash && !passwordResetData.token && ( +

Reset Password

+ )} + {!showConfirmation && (!!resetHash || !!passwordResetData.token) && ( +

Set Password

+ )} {/* Show form to submit email */} - {!resetHash && !showConfirmation && ( + {!resetHash && !passwordResetData.token && !showConfirmation && (

Enter your email and a link to reset your password will be sent to you. @@ -112,7 +113,7 @@ const PasswordReset = (props: Props) => { )} {/* Show password reset request confirmation, with directions to check email */} - {!resetHash && showConfirmation && ( + {!resetHash && !passwordResetData.token && showConfirmation && ( { )} - {/* Show confirmation of password reset. Link to Login */} - {resetHash && passwordResetData.hashIsValid && showConfirmation && ( - + + +

); diff --git a/client/containers/UserCreate/UserCreate.tsx b/client/containers/UserCreate/UserCreate.tsx index 9960fdba1..9c9683089 100644 --- a/client/containers/UserCreate/UserCreate.tsx +++ b/client/containers/UserCreate/UserCreate.tsx @@ -1,8 +1,6 @@ import React, { useRef, useState } from 'react'; import { Button, Checkbox, Classes, NonIdealState } from '@blueprintjs/core'; -import encHex from 'crypto-js/enc-hex'; -import SHA3 from 'crypto-js/sha3'; import { apiFetch } from 'client/utils/apiFetch'; import { gdprCookiePersistsSignup, getGdprConsentElection } from 'client/utils/legal/gdprConsent'; @@ -57,7 +55,7 @@ const UserCreate = (props: Props) => { subscribed, firstName, lastName, - password: SHA3(password).toString(encHex), + password, avatar, title, bio, diff --git a/infra/docker-compose.dev.yml b/infra/docker-compose.dev.yml index 1d0569026..000ba88ec 100644 --- a/infra/docker-compose.dev.yml +++ b/infra/docker-compose.dev.yml @@ -24,6 +24,7 @@ services: DATABASE_URL: postgres://appuser:apppassword@db:5432/appdb CLOUDAMQP_URL: amqp://appuser:apppassword@rabbitmq:5672/appvhost # KF_AUTH_URL: http://kf-auth:3000 + # KF_AUTH_ADMIN_API_KEY: dev-admin-api-key-change-me # KF_AUTH_WEBHOOK_SECRET: dev-webhook-secret depends_on: - db @@ -46,6 +47,7 @@ services: NODE_ENV: development CLOUDAMQP_URL: amqp://appuser:apppassword@rabbitmq:5672/appvhost # KF_AUTH_URL: http://kf-auth:3000 + # KF_AUTH_ADMIN_API_KEY: dev-admin-api-key-change-me # KF_AUTH_WEBHOOK_SECRET: dev-webhook-secret command: [ "pnpm", "run", "workers-dev" ] @@ -111,6 +113,7 @@ services: # SMTP_FROM: noreply@knowledgefuturesauth.dev # SEED_ADMIN_EMAIL: admin@knowledgefuturesauth.dev # SEED_ADMIN_PASSWORD: changeme123 + # ADMIN_API_KEY: dev-admin-api-key-change-me # PORT: "3000" # depends_on: # - kf-auth-db diff --git a/knowledgefutures-sdk-0.1.0.tgz b/knowledgefutures-sdk-0.1.0.tgz index 9266a15f022897a4372723ff3ea56e974971c00e..32d8369082b481e3c18102ebc306b7b86896d199 100644 GIT binary patch literal 36546 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�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 diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 3de2d1176..3b67bedfe 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -2810,7 +2810,7 @@ packages: tslib: '2' '@knowledgefutures/sdk@file:knowledgefutures-sdk-0.1.0.tgz': - resolution: {integrity: sha512-u0b83SAlCSc+1LsyvkekKgNzw0rADMVXFVnQe+CzAUk5Zo+VUoo67qhP1J1fZTfmnRcQL20Ns7UV5FBVR0OBWw==, tarball: file:knowledgefutures-sdk-0.1.0.tgz} + resolution: {integrity: sha512-JPMDWiNanY3H6/gLY5WcfCCAWb5yGh3jF2M2pkbd2K4lcyd/BtVWh0bXaG7owN7r2E2GOJdPfkk+Fgl2C7KqKA==, tarball: file:knowledgefutures-sdk-0.1.0.tgz} version: 0.1.0 peerDependencies: express: '>=4.0.0' diff --git a/server/apiRoutes.ts b/server/apiRoutes.ts index c250b23af..5da2559d6 100644 --- a/server/apiRoutes.ts +++ b/server/apiRoutes.ts @@ -21,6 +21,7 @@ import { router as exploreFeaturedRouter } from './exploreFeatured/api'; import { router as hubRouter } from './hub/api'; import { router as impact2Router } from './impact2/api'; import { router as integrationDataOAuth1Router } from './integrationDataOAuth1/api'; +import { router as kfAuthWebhookRouter } from './kfAuthWebhook/api'; import { router as landingPageFeatureRouter } from './landingPageFeature/api'; import { router as layoutRouter } from './layout/api'; import { router as openSearchRouter } from './openSearch/api'; @@ -44,7 +45,6 @@ import { router as userDismissableRouter } from './userDismissable/api'; import { router as userNotificationRouter } from './userNotification/api'; import { router as userNotificationPreferencesRouter } from './userNotificationPreferences/api'; import { userSubscriptionRouter } from './userSubscription/api'; -import { router as kfAuthWebhookRouter } from './kfAuthWebhook/api'; import { router as zoteroIntegrationRouter } from './zoteroIntegration/api'; const apiRouter = Router() diff --git a/server/envSchema.ts b/server/envSchema.ts index 6426190a9..7a73b4daf 100644 --- a/server/envSchema.ts +++ b/server/envSchema.ts @@ -207,6 +207,10 @@ export const envSchema = z.object({ // ── kf-auth ───────────────────────────────────────────────────────── KF_AUTH_URL: z.string().url().optional().describe('Base URL of the kf-auth service'), + KF_AUTH_ADMIN_API_KEY: z + .string() + .optional() + .describe('API key for authenticating admin calls to kf-auth (import, delete)'), KF_AUTH_WEBHOOK_SECRET: z .string() .optional() diff --git a/server/kfAuth.ts b/server/kfAuth.ts index c5b57fa92..0e6fc973a 100644 --- a/server/kfAuth.ts +++ b/server/kfAuth.ts @@ -30,7 +30,10 @@ export function getKfSdk(): KfServerSdk { throw new Error('KF_AUTH_URL is not configured'); } - instance = createKfServerSdk({ serverUrl: url }); + instance = createKfServerSdk({ + serverUrl: url, + adminApiKey: env.KF_AUTH_ADMIN_API_KEY, + }); } return instance; diff --git a/server/kfAuthWebhook/api.ts b/server/kfAuthWebhook/api.ts index bcdd23b12..8bdec3d3d 100644 --- a/server/kfAuthWebhook/api.ts +++ b/server/kfAuthWebhook/api.ts @@ -1,9 +1,8 @@ import { createHmac, timingSafeEqual } from 'crypto'; - import { Router } from 'express'; -import { User } from 'server/models'; import { env } from 'server/env'; +import { User } from 'server/models'; export const router = Router(); @@ -48,6 +47,7 @@ function deriveFullName(firstName?: string, lastName?: string, name?: string): s router.post('/api/webhooks/kf-auth', async (req, res) => { const secret = env.KF_AUTH_WEBHOOK_SECRET; + console.log('secret', secret, 'body', req.body); if (!secret) { console.error('[kf-auth webhook] KF_AUTH_WEBHOOK_SECRET not configured'); @@ -106,7 +106,12 @@ async function handleUserCreated(data: WebhookPayload['data']) { const initials = deriveInitials(firstName, lastName); // generate a slug from the auth slug or the name - const baseSlug = data.slug || fullName.toLowerCase().replace(/[^a-z0-9]/g, '-').replace(/-+/g, '-'); + const baseSlug = + data.slug || + fullName + .toLowerCase() + .replace(/[^a-z0-9]/g, '-') + .replace(/-+/g, '-'); const slug = `${baseSlug}-${Math.random().toString(36).substring(2, 6)}`; await User.create({ diff --git a/server/login/api.ts b/server/login/api.ts index 0425841d0..64bf139f1 100644 --- a/server/login/api.ts +++ b/server/login/api.ts @@ -3,8 +3,8 @@ import type { AppRouteImplementation } from '@ts-rest/express'; import type { UserSpamTagFields } from 'types'; import type { contract } from 'utils/api/contract'; -import { User } from 'server/models'; import { getKfSdk } from 'server/kfAuth'; +import { User } from 'server/models'; import { getSpamTagForUser } from 'server/spamTag/userQueries'; import { verifyCaptchaPayload } from 'server/utils/captcha'; import { getHashedUserId } from 'utils/caching/getHashedUserId'; @@ -24,6 +24,7 @@ const performLogin = async (req: any, res: any): Promise => { email: req.body.email, password: req.body.password, }); + console.log('result', result); if (result.error || !result.data) { return { status: 401, body: 'Login attempt failed' }; @@ -31,6 +32,27 @@ const performLogin = async (req: any, res: any): Promise => { const user = await User.findOne({ where: { authId: result.data.user.id } }); + // #region agen + fetch('http://host.docker.internal:7793/ingest/abc63da8-c89f-470d-8bd8-f55a69b41fa7', { + method: 'POST', + headers: { 'Content-Type': 'application/json', 'X-Debug-Session-Id': '5f95ae' }, + body: JSON.stringify({ + sessionId: '5f95ae', + location: 'login/api.ts:afterUserLookup', + message: 'pubpub user lookup', + data: { + authIdSearched: result.data.user.id, + foundUser: !!user, + userEmail: user?.email, + }, + timestamp: Date.now(), + hypothesisId: 'H2', + }), + }).catch((e) => { + console.error('Error in agent log:', e); + }); + // #endregion + if (!user) { return { status: 401, body: 'Login attempt failed' }; } @@ -54,10 +76,8 @@ const performLogin = async (req: any, res: any): Promise => { const hashedUserId = getHashedUserId(user); res.cookie('pp-lic', `pp-li-${hashedUserId}`, { - ...(isProd() && - req.hostname.indexOf('pubpub.org') > -1 && { domain: '.pubpub.org' }), - ...(isDuqDuq() && - req.hostname.indexOf('pubpub.org') > -1 && { domain: '.duqduq.org' }), + ...(isProd() && req.hostname.indexOf('pubpub.org') > -1 && { domain: '.pubpub.org' }), + ...(isDuqDuq() && req.hostname.indexOf('pubpub.org') > -1 && { domain: '.duqduq.org' }), maxAge: 30 * 24 * 60 * 60 * 1000, }); diff --git a/server/passwordReset/api.ts b/server/passwordReset/api.ts index 0174e5ba8..317eb5e87 100644 --- a/server/passwordReset/api.ts +++ b/server/passwordReset/api.ts @@ -2,6 +2,7 @@ import { Router } from 'express'; import { getKfSdk } from 'server/kfAuth'; import { wrap } from 'server/wrap'; +import { isDevelopment } from 'utils/environment'; import { sleep } from 'utils/promises'; export const router = Router(); @@ -11,7 +12,9 @@ router.post( wrap(async (req, res) => { try { const kf = getKfSdk(); - const redirectTo = `https://${req.hostname}/password-reset`; + const redirectTo = isDevelopment() + ? `http://localhost:9876/password-reset` + : `https://${req.hostname}/password-reset`; await kf.forgetPassword({ email: req.body.email, @@ -19,7 +22,7 @@ router.post( }); return res.status(200).json('success'); - } catch (err: any) { + } catch (_err: any) { // do not leak user information, always return success await sleep(1000 + Math.random() * 1000); return res.status(200).json('success'); diff --git a/server/routes/passwordReset.tsx b/server/routes/passwordReset.tsx index d33b069d2..f5198e2a2 100644 --- a/server/routes/passwordReset.tsx +++ b/server/routes/passwordReset.tsx @@ -3,6 +3,7 @@ import React from 'react'; import { Router } from 'express'; import Html from 'server/Html'; +import { getKfSdk } from 'server/kfAuth'; import { User } from 'server/models'; import { handleErrors } from 'server/utils/errors'; import { getInitialData } from 'server/utils/initData'; @@ -18,6 +19,10 @@ router.get(['/password-reset', '/password-reset/:resetHash/:slug'], (req, res, n return Promise.all([getInitialData(req), findUser]) .then(([initialData, userData]) => { let hashIsValid = true; + console.log('userData', userData); + + const token = req.params.token || req.query.token; + if (!userData) { hashIsValid = false; } @@ -37,7 +42,7 @@ router.get(['/password-reset', '/password-reset/:resetHash/:slug'], (req, res, n { }); const newUser = await createUser(body); + if (fastHoneypotSignal || _honeypot) { await handleHoneypotTriggered( newUser.id, @@ -80,19 +80,21 @@ router.post('/api/users', async (req, res) => { content: req.body.fullName ? `name: ${req.body.fullName}` : undefined, }, ); + return res.status(403).json(ACCOUNT_RESTRICTED_MESSAGE); } - passport.authenticate('local')(req, res, () => { - const hashedUserId = getHashedUserId(newUser); - res.cookie('pp-lic', `pp-li-${hashedUserId}`, { - ...(isProd() && - req.hostname.indexOf('pubpub.org') > -1 && { domain: '.pubpub.org' }), - ...(isDuqDuq() && - req.hostname.indexOf('pubpub.org') > -1 && { domain: '.duqduq.org' }), - maxAge: 30 * 24 * 60 * 60 * 1000, // 30 days to match login cookies - }); - return res.status(201).json(newUser); + + // @ts-ignore + req.session.userId = newUser.id; + + const hashedUserId = getHashedUserId(newUser); + res.cookie('pp-lic', `pp-li-${hashedUserId}`, { + ...(isProd() && req.hostname.indexOf('pubpub.org') > -1 && { domain: '.pubpub.org' }), + ...(isDuqDuq() && req.hostname.indexOf('pubpub.org') > -1 && { domain: '.duqduq.org' }), + maxAge: 30 * 24 * 60 * 60 * 1000, }); + + return res.status(201).json(newUser); } catch (err) { console.error('Error in postUser: ', err); return res.status(500).json(err instanceof Error ? err.message : 'Error'); diff --git a/server/user/queries.ts b/server/user/queries.ts index 1cbdaf60c..d40d765d6 100644 --- a/server/user/queries.ts +++ b/server/user/queries.ts @@ -1,7 +1,6 @@ import { type CreationAttributes, Op } from 'sequelize'; -import { promisify } from 'util'; -import { Signup, User } from 'server/models'; +import { User } from 'server/models'; import { subscribeUser } from 'server/utils/mailchimp'; import { expect } from 'utils/assert'; import { ORCID_PATTERN } from 'utils/orcid'; @@ -9,8 +8,13 @@ import { slugifyString } from 'utils/strings'; type InputValues = CreationAttributes & { subscribed?: boolean; - password: string; }; + +/** + * completes a user's profile after signup. at this point the user already + * exists in pubpub (created by the kf-auth webhook), so we update the + * existing record with the profile fields. + */ export const createUser = async (inputValues: InputValues) => { const email = inputValues.email.toLowerCase().trim(); const firstName = inputValues.firstName.trim(); @@ -18,18 +22,26 @@ export const createUser = async (inputValues: InputValues) => { const fullName = `${firstName} ${lastName}`; const initials = `${firstName[0]}${lastName[0]}`; const newSlug = slugifyString(fullName); + + const existingUser = await User.findOne({ where: { email } }); + + if (!existingUser) { + throw new Error('User not found. Please complete signup first.'); + } + const existingSlugCount = await User.count({ where: { slug: { [Op.like]: `${newSlug}%` }, + id: { [Op.ne]: existingUser.id }, }, }); - const newUser = { + + await existingUser.update({ slug: `${newSlug}${existingSlugCount ? `-${existingSlugCount + 1}` : ''}`, firstName, lastName, fullName, initials, - email, avatar: inputValues.avatar, title: inputValues.title, bio: inputValues.bio, @@ -41,23 +53,13 @@ export const createUser = async (inputValues: InputValues) => { facebook: inputValues.facebook, googleScholar: inputValues.googleScholar, gdprConsent: inputValues.gdprConsent, - passwordDigest: 'sha512', - }; - - const userRegister = promisify(User.register.bind(User)); - const registeredUser = (await userRegister(newUser, inputValues.password)) as User; + }); if (inputValues.subscribed) { - subscribeUser(inputValues.email, 'be26e45660', ['Users']); + subscribeUser(email, 'be26e45660', ['Users']); } - await Signup.update( - { completed: true }, - { - where: { email, hash: inputValues.hash, completed: false }, - }, - ); - return registeredUser; + return existingUser; }; export const getSuggestedEditsUserInfo = async (suggestionUserId: string) => { diff --git a/stubstub/kfAuth.ts b/stubstub/kfAuth.ts index 556027614..aa40f0e2f 100644 --- a/stubstub/kfAuth.ts +++ b/stubstub/kfAuth.ts @@ -7,9 +7,8 @@ import sinon from 'sinon'; -import { User } from '../server/models'; - import * as kfAuthModule from '../server/kfAuth'; +import { User } from '../server/models'; let restoreFn: (() => void) | null = null; @@ -61,6 +60,7 @@ export function stubKfAuth() { resetPassword: async () => ({ data: {} }), changePassword: async () => ({ data: {} }), + setPassword: async () => ({ success: true }), importUsers: async (users: any[]) => ({ results: users.map((u) => ({ diff --git a/stubstub/modelize/builders.ts b/stubstub/modelize/builders.ts index 8478c591a..f0cd835c0 100644 --- a/stubstub/modelize/builders.ts +++ b/stubstub/modelize/builders.ts @@ -9,6 +9,8 @@ import type { UserWithPrivateFields as UserType, } from 'types'; +import encHex from 'crypto-js/enc-hex'; +import SHA3 from 'crypto-js/sha3'; import uuid from 'uuid'; import { getEmptyDoc } from 'client/components/Editor'; @@ -73,6 +75,15 @@ export const builders = { }, ): Promise => { const uniqueness = uuid.v4(); + const defaults = { + firstName: 'Test', + lastName: 'Testington', + email: `testuser-${uniqueness}@email.su`, + slug: uniqueness, + password: 'password123', + }; + + const input = { ...defaults, ...args }; const { firstName = 'Test', @@ -84,30 +95,33 @@ export const builders = { password = 'password123', id, isSuperAdmin = false, - ...rest - } = { ...args } as any; + } = input; - const authId = uuid.v4(); + const sha3hashedPassword = SHA3(password).toString(encHex); + return new Promise((resolve, reject) => { + User.register( + { + ...(id && { id }), + firstName, + lastName, + fullName, + email, + slug, + initials, + isSuperAdmin, + passwordDigest: 'sha512', + }, + sha3hashedPassword, + (err, user) => { + if (err || !user) { + return reject(err); + } - const user = await User.create({ - ...(id && { id }), - firstName, - lastName, - fullName, - email, - slug, - initials, - isSuperAdmin, - authId, - hash: '', - salt: '', - ...rest, + user.sha3hashedPassword = sha3hashedPassword; + return resolve(user); + }, + ); }); - - // store the plain password so the login helper can use it - (user as any).plainPassword = password; - - return user; }, Community: async ( diff --git a/stubstub/userToAgentMap.ts b/stubstub/userToAgentMap.ts index fa1e50dcf..63ce8e275 100644 --- a/stubstub/userToAgentMap.ts +++ b/stubstub/userToAgentMap.ts @@ -8,9 +8,7 @@ const userToAgentMap = new Map(); let server: Server | null = null; -export const login = async ( - user?: any, -): Promise => { +export const login = async (user?: any): Promise => { server ??= __appImmutableListenOnly.listen(); if (!user) { diff --git a/tools/migrations/2026_05_07_migrate_to_kf_auth.js b/tools/migrations/2026_05_07_migrate_to_kf_auth.js index 5cf388f5f..a6ea6eb16 100644 --- a/tools/migrations/2026_05_07_migrate_to_kf_auth.js +++ b/tools/migrations/2026_05_07_migrate_to_kf_auth.js @@ -7,84 +7,89 @@ * formats them as pubpub::, calls the kf-auth bulk import API, * and stores the returned authId in pubpub's Users table. * + * the up function adds the authId column and then runs the import as a dry run. + * use --fn upCommit to actually write to kf-auth and store authIds. + * * usage: - * pnpm tools migrations/migrate-to-kf-auth # dry run - * pnpm tools migrations/migrate-to-kf-auth --commit # actually write + * pnpm tools migrate --name 2026_05_07_migrate_to_kf_auth # dry run + * pnpm tools migrate --name 2026_05_07_migrate_to_kf_auth --fn upCommit # actually write + * pnpm tools migrate --name 2026_05_07_migrate_to_kf_auth --down # revert */ import { Op } from "sequelize"; -import { SpamTag, User } from "server/models"; +import { User } from "server/models"; import { getKfSdk } from "server/kfAuth"; + const BATCH_SIZE = 100; -// interface MigrationStats { -// total: number; -// migrated: number; -// skippedExisting: number; -// skippedNoHash: number; -// errors: number; -// } - -export const up = async ({ Sequelize, sequelize }) => { - await sequelize.queryInterface.addColumn("Users", "authId", { - type: Sequelize.TEXT, - allowNull: true, - defaultValue: null, - }); +const testUsers = ["hello@tefkah.com", "other@tefkah.com"]; + +const runImport = async ({ Sequelize, sequelize }, { commit }) => { + try { + await sequelize.queryInterface.addColumn("Users", "authId", { + type: Sequelize.TEXT, + allowNull: true, + defaultValue: null, + }); + } catch (error) { + console.error("Error adding authId column:", error); + } - const commit = process.argv.includes("--commit"); const stats = { total: 0, migrated: 0, skippedExisting: 0, skippedNoHash: 0, + skippedSpam: 0, errors: 0, }; + console.log(`[migrate-to-kf-auth] mode: ${commit ? "COMMIT" : "DRY RUN"}`); + + // only migrate users who are not confirmed spam. + // the literal subquery handles three cases: + // - user has no spamTagId at all (never evaluated) + // - user's spam tag is 'unreviewed' (benefit of the doubt) + // - user's spam tag is 'confirmed-not-spam' const users = await User.findAll({ where: { authId: { [Op.is]: null }, + email: { [Op.in]: testUsers }, + // [Op.or]: [ + // { spamTagId: { [Op.is]: null } }, + // sequelize.literal( + // `"User"."spamTagId" IN (SELECT "id" FROM "SpamTags" WHERE "status" IN ('confirmed-not-spam', 'unreviewed'))` + // ), + // ], }, - include: [ - { - model: SpamTag, - as: "spamTag", - where: { status: { [Op.in]: ["confirmed-not-spam", "unreviewed"] } }, - required: false, - }, - ], order: [["createdAt", "ASC"]], }); - stats.total = users.length; - console.log(`[migrate-to-kf-auth] found ${users.length} users without authId`); + stats.total = users.length; + console.log(`[migrate-to-kf-auth] found ${users.length} eligible users without authId`); const kf = getKfSdk(); - const batches = []; for (let i = 0; i < users.length; i += BATCH_SIZE) { - batches.push(users.slice(i, i + BATCH_SIZE)); - } + const batch = users.slice(i, i + BATCH_SIZE); - for (const batch of batches) { - const importable = batch.filter((user) => { - const hasValidHash = user.hash && user.salt && user.passwordDigest === "sha512"; - if (!hasValidHash) { + const importPayload = batch.map((user) => { + const hasHash = user.hash && user.salt && user.passwordDigest === "sha512"; + + if (!hasHash) { stats.skippedNoHash++; } - return true; - }); - const importPayload = importable.map((user) => { - const hasHash = user.hash && user.salt && user.passwordDigest === "sha512"; + return { email: user.email, name: user.fullName || `${user.firstName} ${user.lastName}`, + image: user.avatar, givenName: user.firstName, familyName: user.lastName, - // if no valid hash, import without password (user can reset via forgot-password) passwordHash: hasHash ? `pubpub:${user.salt}:${user.hash}` : "", emailVerified: true, }; }); + if (!commit) { for (const payload of importPayload) { console.log(`[dry-run] would import: ${payload.email} (${payload.name})`); @@ -92,19 +97,24 @@ export const up = async ({ Sequelize, sequelize }) => { } continue; } + try { const result = await kf.importUsers(importPayload); + console.log(result); + for (const entry of result.results) { - if (entry.status === "created" && entry.id) { - const user = batch.find((u) => u.email === entry.email); - if (user) { - await User.update({ authId: entry.id }, { where: { id: user.id } }); + const user = batch.find((u) => u.email === entry.email); + + if (!user) { + continue; + } + + if ((entry.status === "created" || entry.status === "exists") && entry.id) { + await User.update({ authId: entry.id }, { where: { id: user.id } }); + + if (entry.status === "created") { stats.migrated++; - } - } else if (entry.status === "exists" && entry.id) { - const user = batch.find((u) => u.email === entry.email); - if (user) { - await User.update({ authId: entry.id }, { where: { id: user.id } }); + } else { stats.skippedExisting++; } } else if (entry.status === "error") { @@ -117,16 +127,23 @@ export const up = async ({ Sequelize, sequelize }) => { stats.errors += importPayload.length; } } + console.log(`\n[migrate-to-kf-auth] results:`); console.log(` total users: ${stats.total}`); console.log(` migrated: ${stats.migrated}`); - console.log(` already existed: ${stats.skippedExisting}`); + console.log(` already existed: ${stats.skippedExisting}`); console.log(` no valid hash: ${stats.skippedNoHash}`); console.log(` errors: ${stats.errors}`); + if (!commit) { - console.log(`\n run with --commit to actually write changes`); + console.log(`\n run with --fn upCommit to actually write changes`); } }; -export const down = async () => { - throw new Error("this migration is not reversible from here; clear authId manually if needed"); + +export const up = (ctx) => runImport(ctx, { commit: false }); + +export const upCommit = (ctx) => runImport(ctx, { commit: true }); + +export const down = async ({ sequelize }) => { + await sequelize.queryInterface.removeColumn("Users", "authId"); };