diff --git a/.test/setup-kf-auth.ts b/.test/setup-kf-auth.ts new file mode 100644 index 000000000..8cfec0628 --- /dev/null +++ b/.test/setup-kf-auth.ts @@ -0,0 +1,9 @@ +import { restoreKfAuth, stubKfAuth } from '../stubstub/kfAuth'; + +beforeAll(() => { + stubKfAuth(); +}); + +afterAll(() => { + restoreKfAuth(); +}); 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/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..196df621b 100644 --- a/client/containers/DashboardCustomScripts/DashboardCustomScripts.tsx +++ b/client/containers/DashboardCustomScripts/DashboardCustomScripts.tsx @@ -26,7 +26,12 @@ 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/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 6d069a00c..000ba88ec 100644 --- a/infra/docker-compose.dev.yml +++ b/infra/docker-compose.dev.yml @@ -23,12 +23,16 @@ 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_ADMIN_API_KEY: dev-admin-api-key-change-me + # 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 +46,11 @@ services: environment: 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"] + command: [ "pnpm", "run", "workers-dev" ] depends_on: db: condition: service_started @@ -51,7 +58,7 @@ services: condition: service_started # app: # condition: service_healthy - networks: [appnet] + networks: [ appnet ] volumes: - ..:/app @@ -63,7 +70,7 @@ services: RABBITMQ_DEFAULT_VHOST: appvhost volumes: - rabbitmqdata:/var/lib/rabbitmq - networks: [appnet] + networks: [ appnet ] ports: - "${RABBITMQ_PORT:-5672}:5672" @@ -76,17 +83,50 @@ 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 + # ADMIN_API_KEY: dev-admin-api-key-change-me + # 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 +153,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 000000000..32d836908 Binary files /dev/null and b/knowledgefutures-sdk-0.1.0.tgz differ 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..3b67bedfe 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-JPMDWiNanY3H6/gLY5WcfCCAWb5yGh3jF2M2pkbd2K4lcyd/BtVWh0bXaG7owN7r2E2GOJdPfkk+Fgl2C7KqKA==, 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..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'; @@ -47,6 +48,7 @@ import { userSubscriptionRouter } from './userSubscription/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..7a73b4daf 100644 --- a/server/envSchema.ts +++ b/server/envSchema.ts @@ -205,6 +205,17 @@ 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_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() + .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..0e6fc973a --- /dev/null +++ b/server/kfAuth.ts @@ -0,0 +1,44 @@ +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, + adminApiKey: env.KF_AUTH_ADMIN_API_KEY, + }); + } + + 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..8bdec3d3d --- /dev/null +++ b/server/kfAuthWebhook/api.ts @@ -0,0 +1,166 @@ +import { createHmac, timingSafeEqual } from 'crypto'; +import { Router } from 'express'; + +import { env } from 'server/env'; +import { User } from 'server/models'; + +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; + console.log('secret', secret, 'body', req.body); + + 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..64bf139f1 100644 --- a/server/login/api.ts +++ b/server/login/api.ts @@ -1,154 +1,91 @@ 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 { getKfSdk } from 'server/kfAuth'; import { User } from 'server/models'; 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, + }); + console.log('result', result); + + if (result.error || !result.data) { + return { status: 401, body: 'Login attempt failed' }; + } + + 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' }; + } + + 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 +97,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..317eb5e87 100644 --- a/server/passwordReset/api.ts +++ b/server/passwordReset/api.ts @@ -1,30 +1,31 @@ -import type { User } from 'types'; - import { Router } from 'express'; +import { getKfSdk } from 'server/kfAuth'; import { wrap } from 'server/wrap'; +import { isDevelopment } from 'utils/environment'; 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 = isDevelopment() + ? `http://localhost:9876/password-reset` + : `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, always return success + await sleep(1000 + Math.random() * 1000); 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); } }), ); @@ -32,12 +33,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/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 = 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..f55a1a11e 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'; @@ -30,7 +29,7 @@ export const accountServer = s.router(contract.account, { const userData = await User.findOne({ where: { id: userId } }); - if (!userData) { + if (!userData?.authId) { return { status: 403, body: { message: 'User not found' }, @@ -38,25 +37,27 @@ 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); + // verify current password by attempting a sign-in + const verifyResult = await kf.signIn.email({ + email: userData.email, + password: body.currentPassword, + }); - await User.update( - { - hash: updatedUser?.dataValues.hash, - salt: updatedUser?.dataValues.salt, - passwordDigest: 'sha512', - }, - { where: { id: userId } }, - ); + if (verifyResult.error) { + return { status: 403, body: { message: 'Current password is incorrect' } }; + } + + const setResult = await kf.setPassword({ + userId: userData.authId, + newPassword: body.newPassword, + }); + + if (!setResult.success) { + return { status: 403, body: { message: 'Failed to update password' } }; + } - // force logout after password change logout(req, res); return { status: 200, body: { success: true } }; @@ -94,8 +95,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 +224,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 +250,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/api.ts b/server/user/api.ts index 59716d593..9a5dc8a87 100644 --- a/server/user/api.ts +++ b/server/user/api.ts @@ -1,5 +1,4 @@ import { Router } from 'express'; -import passport from 'passport'; import { z } from 'zod'; import { verifyCaptchaPayload } from 'server/utils/captcha'; @@ -70,6 +69,7 @@ router.post('/api/users', async (req, res) => { }); 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/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/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/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..aa40f0e2f --- /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 * as kfAuthModule from '../server/kfAuth'; +import { User } from '../server/models'; + +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: {} }), + setPassword: async () => ({ success: true }), + + 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/userToAgentMap.ts b/stubstub/userToAgentMap.ts index 5238edbd1..63ce8e275 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'; @@ -10,29 +8,30 @@ const userToAgentMap = new Map(); let server: Server | null = null; -export const login = async ( - user?: UserWithPrivateFieldsAndHashedPassword, -): Promise => { +export const login = async (user?: any): Promise => { server ??= __appImmutableListenOnly.listen(); if (!user) { 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 +40,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..a6ea6eb16 --- /dev/null +++ b/tools/migrations/2026_05_07_migrate_to_kf_auth.js @@ -0,0 +1,149 @@ +// @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. + * + * 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 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 { User } from "server/models"; +import { getKfSdk } from "server/kfAuth"; + +const BATCH_SIZE = 100; + +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 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'))` + // ), + // ], + }, + order: [["createdAt", "ASC"]], + }); + + stats.total = users.length; + console.log(`[migrate-to-kf-auth] found ${users.length} eligible users without authId`); + + const kf = getKfSdk(); + + for (let i = 0; i < users.length; i += BATCH_SIZE) { + const batch = users.slice(i, i + BATCH_SIZE); + + const importPayload = batch.map((user) => { + const hasHash = user.hash && user.salt && user.passwordDigest === "sha512"; + + if (!hasHash) { + stats.skippedNoHash++; + } + + return { + email: user.email, + name: user.fullName || `${user.firstName} ${user.lastName}`, + image: user.avatar, + givenName: user.firstName, + familyName: user.lastName, + 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); + console.log(result); + + for (const entry of result.results) { + 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 { + 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 --fn upCommit to actually write changes`); + } +}; + +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"); +}; 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(); }