From 78abbf21e7abb61b1822f6396201294e10a2030c Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 7 Oct 2026 06:38:58 +0000 Subject: [PATCH 01/25] =?UTF-8?q?fix(api):=20=F0=9F=94=92=20resolve=20real?= =?UTF-8?q?=20client=20IPs=20for=20the=20in-process=20API=20and=20behind?= =?UTF-8?q?=20trusted=20proxies?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The Single App mounted the API without handing over the accepted socket, so every browser call resolved to 127.0.0.1 and shared one rate-limit bucket. The API also ignored VITNODE_TRUSTED_PROXY_HOPS, so behind a reverse proxy every visitor shared the proxy's bucket. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01BcTiLrsGK6BGwvs4uieRMB --- apps/web/src/routes/api/$.ts | 9 ++- apps/web/src/server/api-bridge.ts | 19 ++++- .../api-single-app/src/routes/api/$.ts | 9 ++- .../api-single-app/src/server/api-bridge.ts | 18 ++++- .../vitnode/src/api/lib/client-ip.test.ts | 75 ++++++++++++++++++- packages/vitnode/src/api/lib/client-ip.ts | 31 +++++++- 6 files changed, 145 insertions(+), 16 deletions(-) diff --git a/apps/web/src/routes/api/$.ts b/apps/web/src/routes/api/$.ts index d4d9915cbb..bb8d22ca6b 100644 --- a/apps/web/src/routes/api/$.ts +++ b/apps/web/src/routes/api/$.ts @@ -1,10 +1,17 @@ import { createFileRoute } from '@tanstack/react-router' +import { getRequestIP } from '@tanstack/react-start/server' import { apiBridge } from '@/server/vitnode-api.server' +// `getRequestIP()` without `xForwardedFor` is the accepted socket, which a +// visitor cannot write. Without it every browser call shares one address and +// one rate-limit bucket. export const Route = createFileRoute('/api/$')({ server: { handlers: ({ createHandlers }) => - createHandlers({ ANY: async ({ request }) => apiBridge(request) }), + createHandlers({ + ANY: async ({ request }) => + apiBridge(request, { clientAddress: getRequestIP() }), + }), }, }) diff --git a/apps/web/src/server/api-bridge.ts b/apps/web/src/server/api-bridge.ts index 30a6df067e..34310ceb71 100644 --- a/apps/web/src/server/api-bridge.ts +++ b/apps/web/src/server/api-bridge.ts @@ -1,10 +1,21 @@ -export type ApiBridge = (request: Request) => Promise | Response +/** What the in-process API reads the visitor's address from - see `client-ip.ts`. */ +export interface ApiBridgeEnv { + clientAddress?: string +} + +export type ApiBridge = ( + request: Request, + env?: ApiBridgeEnv, +) => Promise | Response interface FetchableApp { - fetch: (request: Request) => Promise | Response + fetch: ( + request: Request, + env?: ApiBridgeEnv, + ) => Promise | Response } export const createApiBridge = (app: FetchableApp): ApiBridge => - async (request) => - app.fetch(request) + async (request, env) => + app.fetch(request, env) diff --git a/packages/create-vitnode-app/copy-of-vitnode-app/api-single-app/src/routes/api/$.ts b/packages/create-vitnode-app/copy-of-vitnode-app/api-single-app/src/routes/api/$.ts index 8a843120b2..f63a0ed83d 100644 --- a/packages/create-vitnode-app/copy-of-vitnode-app/api-single-app/src/routes/api/$.ts +++ b/packages/create-vitnode-app/copy-of-vitnode-app/api-single-app/src/routes/api/$.ts @@ -1,10 +1,17 @@ import { createFileRoute } from "@tanstack/react-router"; +import { getRequestIP } from "@tanstack/react-start/server"; import { apiBridge } from "@/server/vitnode-api.server"; +// `getRequestIP()` without `xForwardedFor` is the accepted socket, which a +// visitor cannot write. Without it every browser call shares one address and +// one rate-limit bucket. export const Route = createFileRoute("/api/$")({ server: { handlers: ({ createHandlers }) => - createHandlers({ ANY: async ({ request }) => apiBridge(request) }), + createHandlers({ + ANY: async ({ request }) => + apiBridge(request, { clientAddress: getRequestIP() }), + }), }, }); diff --git a/packages/create-vitnode-app/copy-of-vitnode-app/api-single-app/src/server/api-bridge.ts b/packages/create-vitnode-app/copy-of-vitnode-app/api-single-app/src/server/api-bridge.ts index f24f783be6..d663f30314 100644 --- a/packages/create-vitnode-app/copy-of-vitnode-app/api-single-app/src/server/api-bridge.ts +++ b/packages/create-vitnode-app/copy-of-vitnode-app/api-single-app/src/server/api-bridge.ts @@ -1,11 +1,21 @@ +/** What the in-process API reads the visitor's address from - see `client-ip.ts`. */ +export interface ApiBridgeEnv { + clientAddress?: string; +} -export type ApiBridge = (request: Request) => Promise | Response +export type ApiBridge = ( + request: Request, + env?: ApiBridgeEnv, +) => Promise | Response; interface FetchableApp { - fetch: (request: Request) => Promise | Response + fetch: ( + request: Request, + env?: ApiBridgeEnv, + ) => Promise | Response; } export const createApiBridge = (app: FetchableApp): ApiBridge => - async (request) => - app.fetch(request) + async (request, env) => + app.fetch(request, env); diff --git a/packages/vitnode/src/api/lib/client-ip.test.ts b/packages/vitnode/src/api/lib/client-ip.test.ts index 851cb386fc..26762ec108 100644 --- a/packages/vitnode/src/api/lib/client-ip.test.ts +++ b/packages/vitnode/src/api/lib/client-ip.test.ts @@ -15,9 +15,11 @@ interface Env { } const resolve = async ({ + env, headers, socket, }: { + env?: Record; headers?: Record; socket?: string; }): Promise => { @@ -28,9 +30,10 @@ const resolve = async ({ const res = await app.request( "/", { headers }, - socket === undefined - ? undefined - : { incoming: { socket: { remoteAddress: socket } } }, + env ?? + (socket === undefined + ? undefined + : { incoming: { socket: { remoteAddress: socket } } }), ); return await res.text(); @@ -66,6 +69,72 @@ describe("clientIpMiddleware", () => { ).resolves.toBe("127.0.0.1"); }); + it("uses the address a host app hands over when it mounts the API in-process", async () => { + await expect( + resolve({ env: { clientAddress: "203.0.113.7" } }), + ).resolves.toBe("203.0.113.7"); + }); + + describe("behind trusted proxies", () => { + const PROXY = "10.0.0.5"; + + afterEach(() => { + vi.unstubAllEnvs(); + }); + + it("takes the hop the closest trusted proxy appended", async () => { + vi.stubEnv("VITNODE_TRUSTED_PROXY_HOPS", "1"); + + await expect( + resolve({ + socket: PROXY, + headers: { "x-forwarded-for": "9.9.9.9, 203.0.113.7" }, + }), + ).resolves.toBe("203.0.113.7"); + }); + + it("skips one hop per trusted proxy", async () => { + vi.stubEnv("VITNODE_TRUSTED_PROXY_HOPS", "2"); + + await expect( + resolve({ + socket: PROXY, + headers: { "x-forwarded-for": "9.9.9.9, 203.0.113.7, 10.0.0.4" }, + }), + ).resolves.toBe("203.0.113.7"); + }); + + it("applies to the address a host app hands over", async () => { + vi.stubEnv("VITNODE_TRUSTED_PROXY_HOPS", "1"); + + await expect( + resolve({ + env: { clientAddress: PROXY }, + headers: { "x-forwarded-for": "203.0.113.7" }, + }), + ).resolves.toBe("203.0.113.7"); + }); + + it("keeps the socket when the trusted hop is not an IP address", async () => { + vi.stubEnv("VITNODE_TRUSTED_PROXY_HOPS", "1"); + + await expect( + resolve({ + socket: PROXY, + headers: { "x-forwarded-for": "not-an-ip" }, + }), + ).resolves.toBe(PROXY); + }); + + it("never reads a forwarded chain without a socket to anchor it", async () => { + vi.stubEnv("VITNODE_TRUSTED_PROXY_HOPS", "1"); + + await expect( + resolve({ headers: { "x-forwarded-for": "203.0.113.7" } }), + ).resolves.toBe("127.0.0.1"); + }); + }); + describe("with a signed forwarded address from the web server", () => { const SECRET = "a-long-random-cron-secret"; const SOCKET = "10.0.0.5"; diff --git a/packages/vitnode/src/api/lib/client-ip.ts b/packages/vitnode/src/api/lib/client-ip.ts index 0c37e5d945..be834a7d36 100644 --- a/packages/vitnode/src/api/lib/client-ip.ts +++ b/packages/vitnode/src/api/lib/client-ip.ts @@ -7,7 +7,11 @@ import { resolveForwardedIpSecret, verifyForwardedFor, } from "@/lib/fetcher/forwarded-signature.server"; -import { FORWARDED_IP_FALLBACK } from "@/lib/fetcher/request-context"; +import { CONFIG } from "@/lib/config"; +import { + FORWARDED_IP_FALLBACK, + resolveVisitorIp, +} from "@/lib/fetcher/request-context"; const UNKNOWN_CLIENT_IP = "127.0.0.1"; @@ -52,6 +56,13 @@ const denoSocketAddress = ( return isRecord(remoteAddr) ? nonEmptyString(remoteAddr.hostname) : undefined; }; +/** + * The API mounted in-process by a host app (the Single App's `/api/*` route) + * has no socket of its own; the host hands over the one it accepted. + */ +const hostSocketAddress = (env: Record): string | undefined => + nonEmptyString(env.clientAddress); + const socketAddress = (c: Context): string | undefined => { const env: unknown = c.env; if (!isRecord(env)) return undefined; @@ -59,10 +70,24 @@ const socketAddress = (c: Context): string | undefined => { return ( nodeSocketAddress(env) ?? bunSocketAddress(env, c.req.raw) ?? - denoSocketAddress(env) + denoSocketAddress(env) ?? + hostSocketAddress(env) ); }; +// Behind `VITNODE_TRUSTED_PROXY_HOPS` proxies the socket is the closest proxy, +// so the visitor is that many hops from the right of `X-Forwarded-For`. +// Anything further left is client-written and never read. +const proxiedAddress = (c: Context): string | undefined => { + const address = resolveVisitorIp({ + forwardedFor: c.req.header("x-forwarded-for"), + socketAddress: socketAddress(c), + trustedProxyHops: CONFIG.trustedProxyHops, + }); + + return address && isIP(address) ? address : socketAddress(c); +}; + const signedForwardedAddress = (c: Context): string | undefined => { const secret = resolveForwardedIpSecret(); const forwardedFor = c.req.header("x-forwarded-for"); @@ -79,7 +104,7 @@ const signedForwardedAddress = (c: Context): string | undefined => { }; export const resolveClientIp = (c: Context): string => - signedForwardedAddress(c) ?? socketAddress(c) ?? UNKNOWN_CLIENT_IP; + signedForwardedAddress(c) ?? proxiedAddress(c) ?? UNKNOWN_CLIENT_IP; export const clientIpMiddleware = async ( c: Context, From 3f74868393afbaee188421bfe9772bdde6f86aed Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 7 Oct 2026 06:47:27 +0000 Subject: [PATCH 02/25] =?UTF-8?q?fix(api):=20=F0=9F=94=92=20add=20security?= =?UTF-8?q?=20headers=20and=20a=20stricter=20rate=20limit=20for=20credenti?= =?UTF-8?q?al=20routes?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - secureHeaders on every API response (nosniff, frame options, COOP, ...), without HSTS and CORP, which belong to the deployment. - sign-in, sign-up, password reset and passkey sign-in get their own 10/min budget (rateLimiter.auth) on top of the global one. - IPv6 visitors are counted per /64 instead of per address. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01BcTiLrsGK6BGwvs4uieRMB --- .../docs/dev/advanced/rate-limiter.mdx | 31 ++++- apps/web/src/server/api-bridge.ts | 5 +- .../api-single-app/src/server/api-bridge.ts | 5 +- packages/vitnode/src/api/config.ts | 26 +++- packages/vitnode/src/api/lib/client-ip.ts | 2 +- .../rate-limiter.middleware.test.ts | 118 +++++++++++++++++- .../middlewares/rate-limiter.middleware.ts | 76 ++++++++++- packages/vitnode/src/vitnode.config.ts | 8 +- 8 files changed, 249 insertions(+), 22 deletions(-) diff --git a/apps/web/content/docs/dev/advanced/rate-limiter.mdx b/apps/web/content/docs/dev/advanced/rate-limiter.mdx index edf00fb6c9..89362016d6 100644 --- a/apps/web/content/docs/dev/advanced/rate-limiter.mdx +++ b/apps/web/content/docs/dev/advanced/rate-limiter.mdx @@ -26,6 +26,30 @@ export const vitNodeApiConfig = buildApiConfig({ The default configuration allows **80 requests per 60 seconds** per IP address. +IPv6 visitors are counted per `/64` network, not per address - an ISP usually hands one person a whole `/64`, and we'd rather not give them 18 quintillion fresh budgets. + +## Stricter budget for sign-in + +Routes that check a password or send an email to whoever is named in the request get a second, much smaller budget on top of the global one: **10 requests per 60 seconds** per IP address. That covers: + +- `POST /users/sign_in` and `POST /users/sign_up` +- `POST /users/reset-password` and `POST /users/change-password` +- `POST /users/passkeys/sign-in` and `POST /users/passkeys/admin-sign-in` + +Tune it with `rateLimiter.auth`: + +```ts title="apps/api/src/vitnode.api.config.ts" +export const vitNodeApiConfig = buildApiConfig({ + rateLimiter: { + // [!code ++:4] + auth: { + points: 5, + duration: 60, + }, + }, +}) +``` + Rate limiting is automatically bypassed when `NODE_ENV=development` to prevent interruptions during local coding. @@ -73,7 +97,7 @@ When your web app renders a page on the server, it calls the API itself - so the The API never believes a plain `X-Forwarded-For` header (anyone can type one). Instead, the web server works out the visitor's address, signs it with a key derived from your `CRON_SECRET` - the secret the web app and the API already share - and the API checks that signature before trusting it. - Set the same `CRON_SECRET` in both apps (you need it there anyway). -- Tell the web app how many proxies sit in front of it with `VITNODE_TRUSTED_PROXY_HOPS`. It skips exactly that many hops from the right of `X-Forwarded-For` and ignores everything further left, because a visitor can write whatever they like there. +- Tell each app how many proxies sit in front of it with `VITNODE_TRUSTED_PROXY_HOPS`. It skips exactly that many hops from the right of `X-Forwarded-For` and ignores everything further left, because a visitor can write whatever they like there. The API reads it too, for browser calls that reach it through your proxy - in a split deployment, set it to the proxies in front of the **API**, and leave it at `0` if the API is only reachable from the web server. - The API trusts the forwarded address only when the signature matches and is less than 5 minutes old. - While `CRON_SECRET` is unset or still a published placeholder, nothing is trusted: the API keys every request on the connection address. @@ -107,6 +131,11 @@ The API never believes a plain `X-Forwarded-For` header (anyone can type one). I description: "Window length in seconds before points reset.", type: "number", }, + auth: { + default: "{ points: 10, duration: 60 }", + description: "Separate budget for sign-in, sign-up, password reset and passkey sign-in.", + type: "{ points?: number; duration?: number }", + }, }} /> diff --git a/apps/web/src/server/api-bridge.ts b/apps/web/src/server/api-bridge.ts index 34310ceb71..016d4c52fe 100644 --- a/apps/web/src/server/api-bridge.ts +++ b/apps/web/src/server/api-bridge.ts @@ -9,10 +9,7 @@ export type ApiBridge = ( ) => Promise | Response interface FetchableApp { - fetch: ( - request: Request, - env?: ApiBridgeEnv, - ) => Promise | Response + fetch: (request: Request, env?: ApiBridgeEnv) => Promise | Response } export const createApiBridge = diff --git a/packages/create-vitnode-app/copy-of-vitnode-app/api-single-app/src/server/api-bridge.ts b/packages/create-vitnode-app/copy-of-vitnode-app/api-single-app/src/server/api-bridge.ts index d663f30314..06f7e51f58 100644 --- a/packages/create-vitnode-app/copy-of-vitnode-app/api-single-app/src/server/api-bridge.ts +++ b/packages/create-vitnode-app/copy-of-vitnode-app/api-single-app/src/server/api-bridge.ts @@ -9,10 +9,7 @@ export type ApiBridge = ( ) => Promise | Response; interface FetchableApp { - fetch: ( - request: Request, - env?: ApiBridgeEnv, - ) => Promise | Response; + fetch: (request: Request, env?: ApiBridgeEnv) => Promise | Response; } export const createApiBridge = diff --git a/packages/vitnode/src/api/config.ts b/packages/vitnode/src/api/config.ts index 37fcbe48bc..6ac122d6af 100644 --- a/packages/vitnode/src/api/config.ts +++ b/packages/vitnode/src/api/config.ts @@ -6,6 +6,7 @@ import { bodyLimit } from "hono/body-limit"; import { cors } from "hono/cors"; import { csrf } from "hono/csrf"; import { HTTPException } from "hono/http-exception"; +import { secureHeaders } from "hono/secure-headers"; import type { VitNodeApiConfig } from "@/vitnode.config"; @@ -31,7 +32,10 @@ import { globalAdminMiddleware, globalMiddleware, } from "./middlewares/global.middleware"; -import { rateLimiterMiddleware } from "./middlewares/rate-limiter.middleware"; +import { + authRateLimiterMiddleware, + rateLimiterMiddleware, +} from "./middlewares/rate-limiter.middleware"; import { registerCronJobs } from "./modules/cron/helpers/register-cron-jobs"; /** 25 MB: room for an image upload, and nothing like enough to be a weapon. */ @@ -109,6 +113,18 @@ export function VitNodeAPI({ const webOrigin = CONFIG.web.origin; + // API responses are JSON, so the browser-hardening headers cost nothing. + // Left out on purpose: HSTS (with `includeSubDomains` it would pin every + // subdomain of a Single App's host - the proxy's call to make) and + // `Cross-Origin-Resource-Policy` (uploads served from the API's origin are + // embedded by the web origin in a split deployment). + app.use( + secureHeaders({ + crossOriginResourcePolicy: false, + referrerPolicy: "strict-origin-when-cross-origin", + strictTransportSecurity: false, + }), + ); app.use(cors(corsOptions ?? { credentials: true, origin: webOrigin })); app.use( csrf( @@ -132,10 +148,10 @@ export function VitNodeAPI({ onError: c => c.json({ error: "Payload Too Large" }, 413), }), ); - app.use( - "*", - rateLimiterMiddleware(vitNodeApiConfig.rateLimiter, redisClient), - ); + const { auth: authRateLimiter, ...rateLimiter } = + vitNodeApiConfig.rateLimiter ?? {}; + app.use("*", rateLimiterMiddleware(rateLimiter, redisClient)); + app.use("*", authRateLimiterMiddleware(authRateLimiter, redisClient)); if (docsEnabled) { app.get("/swagger", swaggerUI({ url: "/api/swagger/doc" })); } diff --git a/packages/vitnode/src/api/lib/client-ip.ts b/packages/vitnode/src/api/lib/client-ip.ts index be834a7d36..a8c4be0e19 100644 --- a/packages/vitnode/src/api/lib/client-ip.ts +++ b/packages/vitnode/src/api/lib/client-ip.ts @@ -2,12 +2,12 @@ import type { Context } from "hono"; import { isIP } from "node:net"; +import { CONFIG } from "@/lib/config"; import { FORWARDED_SIGNATURE_HEADER, resolveForwardedIpSecret, verifyForwardedFor, } from "@/lib/fetcher/forwarded-signature.server"; -import { CONFIG } from "@/lib/config"; import { FORWARDED_IP_FALLBACK, resolveVisitorIp, diff --git a/packages/vitnode/src/api/middlewares/rate-limiter.middleware.test.ts b/packages/vitnode/src/api/middlewares/rate-limiter.middleware.test.ts index 018f8ce9df..afd55c9641 100644 --- a/packages/vitnode/src/api/middlewares/rate-limiter.middleware.test.ts +++ b/packages/vitnode/src/api/middlewares/rate-limiter.middleware.test.ts @@ -1,7 +1,11 @@ import { Hono } from "hono"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; -import { rateLimiterMiddleware } from "./rate-limiter.middleware"; +import { + authRateLimiterMiddleware, + rateLimiterMiddleware, + rateLimitKey, +} from "./rate-limiter.middleware"; interface Env { Variables: { ipAddress: string }; @@ -63,3 +67,115 @@ describe("rateLimiterMiddleware", () => { expect(res.status).toBe(200); }); }); + +describe("rateLimitKey", () => { + it.each([ + ["203.0.113.7", "203.0.113.7"], + ["::ffff:203.0.113.7", "203.0.113.7"], + ["2001:db8:1:2:3:4:5:6", "2001:db8:1:2::/64"], + ["2001:0db8:0001:0002:ffff:ffff:ffff:ffff", "2001:db8:1:2::/64"], + ["2001:db8::1", "2001:db8:0:0::/64"], + ["::1", "0:0:0:0::/64"], + ])("keys %s as %s", (ipAddress, key) => { + expect(rateLimitKey(ipAddress)).toBe(key); + }); +}); + +describe("rateLimiterMiddleware keying", () => { + beforeEach(() => { + vi.stubEnv("NODE_ENV", "production"); + }); + + afterEach(() => { + vi.unstubAllEnvs(); + }); + + it("shares one budget across an IPv6 /64", async () => { + const app = new Hono(); + app.use("*", async (c, next) => { + c.set("ipAddress", c.req.header("x-test-ip") ?? ""); + + return next(); + }); + app.use("*", rateLimiterMiddleware({ points: 1, duration: 60 })); + app.get("/", c => c.json({ ok: true })); + + const first = await app.request("/", { + headers: { "x-test-ip": "2001:db8:aa:bb::1" }, + }); + const rotated = await app.request("/", { + headers: { "x-test-ip": "2001:db8:aa:bb::2" }, + }); + const otherNetwork = await app.request("/", { + headers: { "x-test-ip": "2001:db8:aa:cc::1" }, + }); + + expect(first.status).toBe(200); + expect(rotated.status).toBe(429); + expect(otherNetwork.status).toBe(200); + }); +}); + +describe("authRateLimiterMiddleware", () => { + beforeEach(() => { + vi.stubEnv("NODE_ENV", "production"); + }); + + afterEach(() => { + vi.unstubAllEnvs(); + }); + + const buildAuthApp = (ipAddress: string) => { + const app = new Hono().basePath("/api"); + app.use("*", async (c, next) => { + c.set("ipAddress", ipAddress); + + return next(); + }); + app.use("*", authRateLimiterMiddleware({ points: 2, duration: 60 })); + app.post("/@vitnode/core/users/sign_in", c => c.json({ ok: true })); + app.post("/@vitnode/core/users/passkeys/sign-in", c => + c.json({ ok: true }), + ); + app.get("/@vitnode/core/users/session", c => c.json({ ok: true })); + + return app; + }; + + it("limits sign-in attempts with its own budget", async () => { + const app = buildAuthApp("auth-sign-in"); + const statuses: number[] = []; + for (let attempt = 0; attempt < 3; attempt++) { + const res = await app.request("/api/@vitnode/core/users/sign_in", { + method: "POST", + }); + statuses.push(res.status); + } + + expect(statuses).toEqual([200, 200, 429]); + }); + + it("counts every credential route against the same budget", async () => { + const app = buildAuthApp("auth-shared"); + await app.request("/api/@vitnode/core/users/sign_in", { method: "POST" }); + await app.request("/api/@vitnode/core/users/passkeys/sign-in", { + method: "POST", + }); + const res = await app.request("/api/@vitnode/core/users/sign_in", { + method: "POST", + }); + + expect(res.status).toBe(429); + }); + + it("leaves other routes to the global budget", async () => { + const app = buildAuthApp("auth-other"); + const statuses: number[] = []; + for (let attempt = 0; attempt < 3; attempt++) { + const res = await app.request("/api/@vitnode/core/users/session"); + statuses.push(res.status); + } + + expect(statuses).toEqual([200, 200, 200]); + }); +}); diff --git a/packages/vitnode/src/api/middlewares/rate-limiter.middleware.ts b/packages/vitnode/src/api/middlewares/rate-limiter.middleware.ts index e832f36746..153eed704b 100644 --- a/packages/vitnode/src/api/middlewares/rate-limiter.middleware.ts +++ b/packages/vitnode/src/api/middlewares/rate-limiter.middleware.ts @@ -1,5 +1,6 @@ import type { Context, Next } from "hono"; +import { isIP } from "node:net"; import { type IRateLimiterOptions, type RateLimiterAbstract, @@ -42,9 +43,43 @@ const createRateLimiter = ({ }); }; +/** + * An IPv6 visitor is routinely handed a whole /64, so keying on the full address + * gives one attacker 2^64 fresh buckets. Count the network instead. IPv4-mapped + * addresses (`::ffff:203.0.113.7`) are the IPv4 visitor they wrap. + */ +export const rateLimitKey = (ipAddress: string): string => { + const mapped = /^::ffff:(\d{1,3}(?:\.\d{1,3}){3})$/i.exec(ipAddress); + if (mapped) return mapped[1]; + if (isIP(ipAddress) !== 6) return ipAddress; + + const [head, tail = ""] = ipAddress.toLowerCase().split("::"); + const headGroups = head ? head.split(":") : []; + const tailGroups = tail ? tail.split(":") : []; + const groups = ipAddress.includes("::") + ? [ + ...headGroups, + ...Array(8 - headGroups.length - tailGroups.length).fill("0"), + ...tailGroups, + ] + : headGroups; + + return `${groups + .slice(0, 4) + .map(group => group.replace(/^0+(?=.)/, "")) + .join(":")}::/64`; +}; + export const rateLimiterMiddleware = ( - options?: Omit, + options?: Partial>, storeClient?: CacheClient | null, + { + defaults = { duration: 60, points: 80 }, + keyPrefix = "vitnode-api-rate-limiter", + }: { + defaults?: { duration: number; points: number }; + keyPrefix?: string; + } = {}, ) => { if (CONFIG.node_development) { // In development, we disable the rate limiter for easier testing @@ -53,18 +88,18 @@ export const rateLimiterMiddleware = ( }; } - const duration = options?.duration ?? 60; + const duration = options?.duration ?? defaults.duration; const rateLimiter = createRateLimiter({ ...options, - keyPrefix: "vitnode-api-rate-limiter", + keyPrefix, duration, - points: options?.points ?? 80, + points: options?.points ?? defaults.points, storeClient, }); return async (c: Context, next: Next) => { - const key = c.get("ipAddress"); + const key = rateLimitKey(c.get("ipAddress")); try { await rateLimiter.consume(key); @@ -84,3 +119,34 @@ export const rateLimiterMiddleware = ( await next(); }; }; + +/** + * Routes that test a credential or send an email to whoever is named in the + * body. The global budget is sized for browsing, which is ~115k password + * guesses a day per address; these get their own, much smaller one. + */ +export const AUTH_RATE_LIMITED_PATHS = [ + "/@vitnode/core/users/sign_in", + "/@vitnode/core/users/sign_up", + "/@vitnode/core/users/reset-password", + "/@vitnode/core/users/change-password", + "/@vitnode/core/users/passkeys/sign-in", + "/@vitnode/core/users/passkeys/admin-sign-in", +] as const; + +export const isAuthRateLimitedRequest = (c: Context): boolean => + c.req.method === "POST" && + AUTH_RATE_LIMITED_PATHS.some(path => c.req.path.endsWith(path)); + +export const authRateLimiterMiddleware = ( + options?: Partial>, + storeClient?: CacheClient | null, +) => { + const limit = rateLimiterMiddleware(options, storeClient, { + defaults: { duration: 60, points: 10 }, + keyPrefix: "vitnode-api-auth-rate-limiter", + }); + + return async (c: Context, next: Next) => + isAuthRateLimitedRequest(c) ? limit(c, next) : next(); +}; diff --git a/packages/vitnode/src/vitnode.config.ts b/packages/vitnode/src/vitnode.config.ts index 5a929c848e..dca1cfb369 100644 --- a/packages/vitnode/src/vitnode.config.ts +++ b/packages/vitnode/src/vitnode.config.ts @@ -119,7 +119,13 @@ export interface VitNodeApiConfig { metadata: VitNodeMetadata; notifications?: Partial; plugins: BuildPluginApiReturn[]; - rateLimiter?: Omit; + rateLimiter?: Omit & { + /** + * Separate, smaller budget for sign-in, sign-up, password reset and passkey + * sign-in. Defaults to 10 requests per 60 seconds per address. + */ + auth?: Partial>; + }; redis?: CacheConfig; From f6eb7425106f2a2cf872261b65faf10bbec0a411 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 7 Oct 2026 06:41:20 +0000 Subject: [PATCH 03/25] =?UTF-8?q?fix(security):=20=F0=9F=94=92=20ignore=20?= =?UTF-8?q?every=20.env=20mode=20file=20and=20the=20Docker=20Postgres=20da?= =?UTF-8?q?ta=20dir?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `.env.production`, `.env.development` and `.env.staging` were not ignored although Vite's loadEnv reads `.env.[mode]`. Ignore `.env*` and keep `.env.example`. The single-app and API-only templates now ignore the `./docker/dev` Postgres data dir like the monorepo template does. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01BcTiLrsGK6BGwvs4uieRMB --- .gitignore | 9 ++++----- apps/api/.gitignore | 14 ++++++++------ apps/web/.gitignore | 5 ++++- .../copy-of-vitnode-app/api/.gitignore_template | 14 ++++++++------ .../monorepo/.gitignore_template | 9 ++++----- .../copy-of-vitnode-app/root/.gitignore_template | 13 +++++++------ 6 files changed, 35 insertions(+), 29 deletions(-) diff --git a/.gitignore b/.gitignore index 200767ba9a..b71733250b 100644 --- a/.gitignore +++ b/.gitignore @@ -5,11 +5,10 @@ node_modules .turbo # Local env files -.env -.env.local -.env.development.local -.env.test.local -.env.production.local +# Every mode Vite loads (`.env.production`, `.env.staging.local`, ...) - only +# the example file is committed. +.env* +!.env.example # Build Outputs out/ diff --git a/apps/api/.gitignore b/apps/api/.gitignore index a6055938d1..adf39f7c7a 100644 --- a/apps/api/.gitignore +++ b/apps/api/.gitignore @@ -19,14 +19,16 @@ yarn-debug.log* yarn-error.log* # Local env files -.env -.env.local -.env.development.local -.env.test.local -.env.production.local +# Every mode Vite loads (`.env.production`, `.env.staging.local`, ...) - only +# the example file is committed. +.env* +!.env.example # Others .vercel # Local storage adapter uploads -/public/uploads \ No newline at end of file +/public/uploads + +# VitNode - Postgres data of `docker-compose.yml` (mounted at ./docker/dev) +/docker diff --git a/apps/web/.gitignore b/apps/web/.gitignore index 8b25bb54ef..44f3a1aa47 100644 --- a/apps/web/.gitignore +++ b/apps/web/.gitignore @@ -3,7 +3,10 @@ node_modules dist dist-ssr *.local -.env +# Every mode Vite loads (`.env.production`, `.env.staging.local`, ...) - only +# the example file is committed. +.env* +!.env.example .nitro .tanstack .wrangler diff --git a/packages/create-vitnode-app/copy-of-vitnode-app/api/.gitignore_template b/packages/create-vitnode-app/copy-of-vitnode-app/api/.gitignore_template index a6055938d1..adf39f7c7a 100644 --- a/packages/create-vitnode-app/copy-of-vitnode-app/api/.gitignore_template +++ b/packages/create-vitnode-app/copy-of-vitnode-app/api/.gitignore_template @@ -19,14 +19,16 @@ yarn-debug.log* yarn-error.log* # Local env files -.env -.env.local -.env.development.local -.env.test.local -.env.production.local +# Every mode Vite loads (`.env.production`, `.env.staging.local`, ...) - only +# the example file is committed. +.env* +!.env.example # Others .vercel # Local storage adapter uploads -/public/uploads \ No newline at end of file +/public/uploads + +# VitNode - Postgres data of `docker-compose.yml` (mounted at ./docker/dev) +/docker diff --git a/packages/create-vitnode-app/copy-of-vitnode-app/monorepo/.gitignore_template b/packages/create-vitnode-app/copy-of-vitnode-app/monorepo/.gitignore_template index 9df573c7b3..5a58ba01d4 100644 --- a/packages/create-vitnode-app/copy-of-vitnode-app/monorepo/.gitignore_template +++ b/packages/create-vitnode-app/copy-of-vitnode-app/monorepo/.gitignore_template @@ -6,11 +6,10 @@ node_modules .pnp.js # Local env files -.env -.env.local -.env.development.local -.env.test.local -.env.production.local +# Every mode Vite loads (`.env.production`, `.env.staging.local`, ...) - only +# the example file is committed. +.env* +!.env.example # Testing coverage diff --git a/packages/create-vitnode-app/copy-of-vitnode-app/root/.gitignore_template b/packages/create-vitnode-app/copy-of-vitnode-app/root/.gitignore_template index 7c19698bbd..44950ef91f 100644 --- a/packages/create-vitnode-app/copy-of-vitnode-app/root/.gitignore_template +++ b/packages/create-vitnode-app/copy-of-vitnode-app/root/.gitignore_template @@ -27,15 +27,16 @@ yarn-debug.log* yarn-error.log* # Local env files -.env -.env.local -.env.development.local -.env.test.local -.env.production.local +# Every mode Vite loads (`.env.production`, `.env.staging.local`, ...) - only +# the example file is committed. +.env* +!.env.example # Others .vercel -.env*.local # Local storage adapter uploads /public/uploads + +# VitNode - Postgres data of `docker-compose.yml` (mounted at ./docker/dev) +/docker From 388621f75231105fb13e1172c5991f3196bcdea3 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 7 Oct 2026 06:43:10 +0000 Subject: [PATCH 04/25] =?UTF-8?q?fix(security):=20=F0=9F=94=92=20redact=20?= =?UTF-8?q?bound=20query=20params=20and=20echoed=20values=20from=20error?= =?UTF-8?q?=20logs?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `describeError()` feeds `app.onError` into `core_logs` and the console, and drizzle-orm's `DrizzleQueryError` message carries every bound parameter (addresses, password hashes, generated server secrets). Keep the SQL and the cause, redact the params, the values Postgres echoes in `detail`, and credential-looking strings. The redaction patterns are shared with `sanitizeDeliveryError` through `api/lib/redact.ts`. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01BcTiLrsGK6BGwvs4uieRMB --- .../vitnode/src/api/lib/error-details.test.ts | 76 +++++++++++++++++-- packages/vitnode/src/api/lib/error-details.ts | 35 ++++++++- .../vitnode/src/api/lib/notifications/safe.ts | 10 +-- packages/vitnode/src/api/lib/redact.ts | 13 ++++ 4 files changed, 116 insertions(+), 18 deletions(-) create mode 100644 packages/vitnode/src/api/lib/redact.ts diff --git a/packages/vitnode/src/api/lib/error-details.test.ts b/packages/vitnode/src/api/lib/error-details.test.ts index a706143f9b..6b50129e17 100644 --- a/packages/vitnode/src/api/lib/error-details.test.ts +++ b/packages/vitnode/src/api/lib/error-details.test.ts @@ -1,14 +1,14 @@ // @vitest-environment node +import { DrizzleQueryError } from "drizzle-orm"; import { describe, expect, it } from "vitest"; import { describeError } from "./error-details"; -const drizzleQueryError = (sql: string, cause: unknown) => { - const error = new Error(`Failed query: ${sql}\nparams: 26`, { cause }); - error.name = "DrizzleQueryError"; - - return error; -}; +const drizzleQueryError = ( + sql: string, + cause: unknown, + params: unknown[] = [26], +) => new DrizzleQueryError(sql, params, cause as Error); const postgresError = (message: string, fields: Record) => Object.assign(new Error(message), fields); @@ -24,7 +24,7 @@ describe("describeError", () => { ); expect(describeError(error)).toBe( - 'Failed query: select "example_articles"."animation" from "example_articles"\nparams: 26\n' + + 'Failed query: select "example_articles"."animation" from "example_articles"\nparams: [redacted]\n' + 'Caused by: column "animation" does not exist (code: 42703, position: 8)', ); }); @@ -72,4 +72,66 @@ describe("describeError", () => { expect(describeError(undefined)).toBe("Unknown error"); expect(describeError(null)).toBe("Unknown error"); }); + + it("redacts bound query parameters and keeps the SQL and the cause", () => { + const error = drizzleQueryError( + 'insert into "core_users" ("email", "password") values ($1, $2)', + postgresError( + 'duplicate key value violates unique constraint "core_users_email_unique"', + { + code: "23505", + detail: "Key (email)=(bob@example.com) already exists.", + }, + ), + ["bob@example.com", "$argon2id$v=19$m=65536$hash"], + ); + + const message = describeError(error); + + expect(message).toBe( + 'Failed query: insert into "core_users" ("email", "password") values ($1, $2)\nparams: [redacted]\n' + + 'Caused by: duplicate key value violates unique constraint "core_users_email_unique" (code: 23505, detail: Key (email)=([redacted]) already exists.)', + ); + expect(message).not.toContain("bob@example.com"); + expect(message).not.toContain("argon2id"); + }); + + it("redacts a generated secret bound to an insert", () => { + const secret = "f3c1d2e4b5a6978812345678abcdef00"; + const error = drizzleQueryError( + 'insert into "core_secrets" ("name", "value") values ($1, $2)', + new Error("connection terminated unexpectedly"), + ["cron", secret], + ); + + expect(describeError(error)).not.toContain(secret); + }); + + it("redacts the row Postgres echoes on a not-null violation", () => { + const error = postgresError( + 'null value in column "name" violates not-null constraint', + { + code: "23502", + detail: "Failing row contains (1, null, bob@example.com, $argon2id$x).", + }, + ); + + expect(describeError(error)).toBe( + 'null value in column "name" violates not-null constraint (code: 23502, detail: Failing row contains ([redacted]).)', + ); + }); + + it("redacts a query error that only survives as text", () => { + expect( + describeError("Failed query: select 1 where a = $1\nparams: hunter2"), + ).toBe("Failed query: select 1 where a = $1\nparams: [redacted]"); + }); + + it("redacts addresses and credentials in any message", () => { + expect( + describeError( + new Error("rejected bob@example.com token=abc123 Bearer abc.def"), + ), + ).toBe("rejected [email] token=[redacted] Bearer [redacted]"); + }); }); diff --git a/packages/vitnode/src/api/lib/error-details.ts b/packages/vitnode/src/api/lib/error-details.ts index d32b598173..ad1a4c48a6 100644 --- a/packages/vitnode/src/api/lib/error-details.ts +++ b/packages/vitnode/src/api/lib/error-details.ts @@ -1,3 +1,5 @@ +import { redactSecrets } from "@/api/lib/redact"; + const DRIVER_FIELDS = [ "code", "detail", @@ -11,6 +13,23 @@ const DRIVER_FIELDS = [ const MAX_CAUSE_DEPTH = 5; +// drizzle-orm's `DrizzleQueryError` is `Failed query: \nparams: `, +// and the values are whatever was bound - addresses, password hashes, secrets. +// The SQL keeps its `$1` placeholders, so it stays. +const QUERY_PARAMS = /^(Failed query: [\s\S]*?)\nparams: [\s\S]*$/; +// Postgres echoes the offending values in `detail`: +// `Key (email)=(bob@example.com) already exists.`, `Failing row contains (...)`. +const DETAIL_FAILING_ROW = /Failing row contains \([\s\S]*\)/; +const DETAIL_KEY_VALUES = /=\([\s\S]*\)/; + +const redactMessage = (message: string): string => + redactSecrets(message.replace(QUERY_PARAMS, "$1\nparams: [redacted]")); + +const redactDetail = (detail: string): string => + detail + .replace(DETAIL_FAILING_ROW, "Failing row contains ([redacted])") + .replace(DETAIL_KEY_VALUES, "=([redacted])"); + const readDriverFields = (error: Error): string[] => { const fields = error as unknown as Record; @@ -18,7 +37,9 @@ const readDriverFields = (error: Error): string[] => { const value = fields[key]; if (typeof value === "string" && value.length > 0) { - return [`${key}: ${value}`]; + const safe = key === "detail" ? redactDetail(value) : value; + + return [`${key}: ${redactSecrets(safe)}`]; } if (typeof value === "number") { @@ -32,18 +53,24 @@ const readDriverFields = (error: Error): string[] => { const describeOne = (error: unknown): string => { if (error instanceof Error) { const fields = readDriverFields(error); - const message = error.message.length > 0 ? error.message : error.name; + const message = + error.message.length > 0 ? redactMessage(error.message) : error.name; return fields.length > 0 ? `${message} (${fields.join(", ")})` : message; } if (typeof error === "string") { - return error; + return redactMessage(error); } - return String(error); + return redactMessage(String(error)); }; +/** + * One line per error in the `cause` chain, for logs. Bound query parameters, + * values Postgres echoes back and credential-looking strings are redacted - the + * result ends up in `core_logs` and on the console. + */ export const describeError = (error: unknown): string => { const seen = new Set(); const parts: string[] = []; diff --git a/packages/vitnode/src/api/lib/notifications/safe.ts b/packages/vitnode/src/api/lib/notifications/safe.ts index eee06eb118..a79c9f77dd 100644 --- a/packages/vitnode/src/api/lib/notifications/safe.ts +++ b/packages/vitnode/src/api/lib/notifications/safe.ts @@ -1,3 +1,5 @@ +import { redactSecrets } from "@/api/lib/redact"; + const MAX_TARGET_LENGTH = 2048; const MAX_TEXT_LENGTH = 500; const MAX_ERROR_LENGTH = 500; @@ -42,13 +44,7 @@ export const sanitizeDeliveryError = (error: unknown): string => { ? error : "Unknown error"; - return raw - .replace(/[^\s@<>"']+@[^\s@<>"']+\.[^\s@<>"']+/g, "[email]") - .replace( - /(key|token|secret|password|authorization)=[^\s&]+/gi, - "$1=[redacted]", - ) - .replace(/Bearer\s+[A-Za-z0-9._~+/=-]+/g, "Bearer [redacted]") + return redactSecrets(raw) .replace(/\s+/g, " ") .trim() .slice(0, MAX_ERROR_LENGTH); diff --git a/packages/vitnode/src/api/lib/redact.ts b/packages/vitnode/src/api/lib/redact.ts new file mode 100644 index 0000000000..6c5dbe5815 --- /dev/null +++ b/packages/vitnode/src/api/lib/redact.ts @@ -0,0 +1,13 @@ +const EMAIL = /[^\s@<>"']+@[^\s@<>"']+\.[^\s@<>"']+/g; +const CREDENTIAL_PAIR = /(key|token|secret|password|authorization)=[^\s&]+/gi; +const BEARER = /Bearer\s+[A-Za-z0-9._~+/=-]+/g; + +/** + * Masks the values that most often leak into an error message - addresses, + * `secret=...` pairs and bearer tokens - before it is logged or stored. + */ +export const redactSecrets = (text: string): string => + text + .replace(EMAIL, "[email]") + .replace(CREDENTIAL_PAIR, "$1=[redacted]") + .replace(BEARER, "Bearer [redacted]"); From b41ad7c1bdffb50cfa649df18f7229c06d42dd1c Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 7 Oct 2026 06:45:11 +0000 Subject: [PATCH 05/25] =?UTF-8?q?fix(security):=20=F0=9F=94=92=20log=20and?= =?UTF-8?q?=20throw=20only=20the=20API=20path=20from=20the=20fetcher?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `rawApiFetch` put the full URL - the internal `VITNODE_API_URL` origin and the query, which on the SSO callback carries the OAuth `code` and `state` - into the thrown 500 and the >= 400 console line. That message can reach the SSR hydration payload. Use the pathname only. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01BcTiLrsGK6BGwvs4uieRMB --- packages/vitnode/src/lib/fetcher/raw.test.ts | 45 ++++++++++++++++++-- packages/vitnode/src/lib/fetcher/raw.ts | 10 ++++- 2 files changed, 50 insertions(+), 5 deletions(-) diff --git a/packages/vitnode/src/lib/fetcher/raw.test.ts b/packages/vitnode/src/lib/fetcher/raw.test.ts index 9ebdd23888..f58bca8e5b 100644 --- a/packages/vitnode/src/lib/fetcher/raw.test.ts +++ b/packages/vitnode/src/lib/fetcher/raw.test.ts @@ -24,6 +24,7 @@ const mountedApi = () => { plugin.get("/broken", () => { throw new Error("boom"); }); + plugin.get("/broken-quietly", c => c.text("Internal Server Error", 500)); const app = new Hono().basePath("/api"); app.route(`/${PLUGIN_ID}`, plugin); @@ -209,14 +210,52 @@ describe("rawApiFetch against the mounted API", () => { expect(response.status).toBe(404); }); - it("throws on a 500 with the URL and the body", async () => { + it("throws on a 500 with the path and the body", async () => { await expect( rawApiFetch({ method: "get", - module: "broken", + module: "broken-quietly", path: "/", pluginId: PLUGIN_ID, }), - ).rejects.toThrow(`/api/${PLUGIN_ID}/broken`); + ).rejects.toThrow( + `500 - /api/${PLUGIN_ID}/broken-quietly\nInternal Server Error`, + ); + }); + + it("keeps the origin and the query out of a thrown 500", async () => { + const error: unknown = await rawApiFetch({ + method: "get", + module: "broken", + path: "/", + pluginId: PLUGIN_ID, + query: { code: "oauth-code", state: "oauth-state" }, + }).catch((caught: unknown) => caught); + + expect(error).toBeInstanceOf(Error); + const { message } = error as Error; + expect(message).toContain(`/api/${PLUGIN_ID}/broken`); + expect(message).not.toContain(ORIGIN); + expect(message).not.toContain("oauth-code"); + expect(message).not.toContain("oauth-state"); + }); + + it("logs a 4xx with the path only", async () => { + const logError = vi + .spyOn(console, "error") + .mockImplementation(() => undefined); + + await rawApiFetch({ + method: "get", + module: "guarded", + path: "/", + pluginId: PLUGIN_ID, + query: { code: "oauth-code" }, + }); + + const logged = logError.mock.calls.flat().join(" "); + expect(logged).toContain(`/api/${PLUGIN_ID}/guarded`); + expect(logged).not.toContain(ORIGIN); + expect(logged).not.toContain("oauth-code"); }); }); diff --git a/packages/vitnode/src/lib/fetcher/raw.ts b/packages/vitnode/src/lib/fetcher/raw.ts index 154d77e188..79a1ec150d 100644 --- a/packages/vitnode/src/lib/fetcher/raw.ts +++ b/packages/vitnode/src/lib/fetcher/raw.ts @@ -110,6 +110,12 @@ export const rawApiFetch = async ({ body: formData ?? (body === undefined ? undefined : JSON.stringify(body)), }); + // Only the path. The origin is the internal `VITNODE_API_URL`, and the query + // can carry credentials - the SSO callback forwards the OAuth `code` and + // `state` - while this message reaches logs and, from a loader, the error + // payload serialised into the SSR HTML. + const loggedPath = url.pathname; + if (response.status === 500) { const errorText = await response.text(); throw new Error( @@ -117,7 +123,7 @@ export const rawApiFetch = async ({ // `statusText ?? errorText`, which never fell through - `statusText` is // essentially always a non-empty string - so the one part that says *what* // went wrong was discarded on every 500. - `${response.status} - ${url.toString()}\n${errorText.trim() === "" ? response.statusText : errorText}`, + `${response.status} - ${loggedPath}\n${errorText.trim() === "" ? response.statusText : errorText}`, ); } @@ -126,7 +132,7 @@ export const rawApiFetch = async ({ const errorText = await response.clone().text(); // oxlint-disable-next-line no-console console.error( - `\x1b[34m[VitNode - API]\x1b[0m \x1b[31m${response.status}\x1b[0m - \x1b[33m${url.toString()}\x1b[0m\n\x1b[36mError: ${errorText}\x1b[0m`, + `\x1b[34m[VitNode - API]\x1b[0m \x1b[31m${response.status}\x1b[0m - \x1b[33m${loggedPath}\x1b[0m\n\x1b[36mError: ${errorText}\x1b[0m`, ); } From a419d867f4ee8d19bb28a09c6e688ea56e71acaa Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 7 Oct 2026 06:47:43 +0000 Subject: [PATCH 06/25] =?UTF-8?q?fix(security):=20=F0=9F=94=92=20keep=20th?= =?UTF-8?q?e=20content=20revalidation=20bearer=20off=20weak=20secrets=20an?= =?UTF-8?q?d=20plain=20http?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `dispatchContentRevalidation` sent `CRON_SECRET` as the bearer to every `content.revalidateOrigins` origin, including the published default and over plain http. Outside development it now refuses to dispatch while the secret is rejected (warning once), and refuses non-https origins other than localhost/127.0.0.1. The wire format is unchanged. The docs now tell receivers to compare the bearer in constant time and reject a stale `x-vitnode-timestamp`. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01BcTiLrsGK6BGwvs4uieRMB --- .../content-engine/public-api-and-caching.mdx | 35 ++++ .../content/server/revalidate-bridge.test.ts | 175 ++++++++++++++++++ .../src/content/server/revalidate-bridge.ts | 68 ++++++- 3 files changed, 275 insertions(+), 3 deletions(-) create mode 100644 packages/vitnode/src/content/server/revalidate-bridge.test.ts diff --git a/apps/web/content/docs/dev/content-engine/public-api-and-caching.mdx b/apps/web/content/docs/dev/content-engine/public-api-and-caching.mdx index 5452a4e389..11990a7a3e 100644 --- a/apps/web/content/docs/dev/content-engine/public-api-and-caching.mdx +++ b/apps/web/content/docs/dev/content-engine/public-api-and-caching.mdx @@ -245,3 +245,38 @@ export const vitNodeApiConfig = buildApiConfig({ ``` Leave it unset when your pages read the public API on every request. + +Because the request carries your `CRON_SECRET` - the same key that runs every cron job - VitNode is picky about where it sends it: + +- Each origin must use `https`. Plain `http` is only allowed for `localhost` and `127.0.0.1`. +- Outside development, nothing is sent while `CRON_SECRET` is unset or still the built-in default. The API logs a warning once and the scheduled run records the origins as not delivered. + +### Build the revalidate endpoint + +Your endpoint is the lock on that door, so check two things before you expire anything: + +1. **The bearer, in constant time.** A plain `===` stops at the first wrong character, and that timing leaks the secret one character at a time. +2. **The `x-vitnode-timestamp` header.** It holds the send time in milliseconds. Refuse anything more than 5 minutes away from your clock, so a captured request can't be replayed next week. + +```ts title="apps/web/src/routes/api/vitnode/content/revalidate.ts" +import { createHash, timingSafeEqual } from "node:crypto"; + +const MAX_SKEW_MS = 5 * 60 * 1000; + +// Hashing first gives both sides the same length, which `timingSafeEqual` needs. +const digest = (value: string) => createHash("sha256").update(value).digest(); + +export const isTrustedRevalidation = (request: Request): boolean => { + const secret = process.env.CRON_SECRET; + if (!secret) return false; + + const bearer = /^Bearer (.+)$/.exec(request.headers.get("authorization") ?? "")?.[1]; + if (!bearer || !timingSafeEqual(digest(bearer), digest(secret))) return false; + + const sentAt = Number(request.headers.get("x-vitnode-timestamp")); + + return Number.isFinite(sentAt) && Math.abs(Date.now() - sentAt) <= MAX_SKEW_MS; +}; +``` + +Answer `403` when it returns `false` - VitNode reads that as a wrong secret and skips the retry - and a `2xx` once the tags are expired. The JSON body is the invalidation input: the content type, the record `id`, its `slugs` and whether it `wasPublic` and `isPublic`. Pass it to `contentInvalidationTags` from `@vitnode/core/content` to get the exact tags to expire. diff --git a/packages/vitnode/src/content/server/revalidate-bridge.test.ts b/packages/vitnode/src/content/server/revalidate-bridge.test.ts new file mode 100644 index 0000000000..4cf5a233a6 --- /dev/null +++ b/packages/vitnode/src/content/server/revalidate-bridge.test.ts @@ -0,0 +1,175 @@ +// @vitest-environment node +import type { Context } from "hono"; + +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +import type { ContentRevalidationRequest } from "./revalidate-bridge"; + +import { INSECURE_DEFAULT_CRON_SECRET } from "../../lib/config"; + +const SECRET = "a-long-random-production-secret"; +const INPUT: ContentRevalidationRequest = { + contentTypeId: "blog.post", + id: 1, + isPublic: true, + mode: "immediate", + slugs: ["hello"], + wasPublic: false, +}; + +const harness = ({ + cronSecret = SECRET, + origins, +}: { + cronSecret?: string; + origins: string[]; +}) => { + const fetch = vi.fn( + async (_url: string, _init: RequestInit) => + await Promise.resolve(new Response(null, { status: 200 })), + ); + vi.stubGlobal("fetch", fetch); + + const log = { + error: vi.fn().mockResolvedValue(undefined), + warn: vi.fn().mockResolvedValue(undefined), + }; + const store: Record = { + core: { contentRevalidateOrigins: origins, cronSecret }, + log, + }; + const c = { get: (key: string) => store[key] } as unknown as Context; + + return { c, fetch, log }; +}; + +// A fresh module per test, so the warn-once memory does not leak between them. +const load = async () => await import("./revalidate-bridge"); + +describe("dispatchContentRevalidation", () => { + beforeEach(() => { + vi.resetModules(); + vi.stubEnv("NODE_ENV", "production"); + }); + + afterEach(() => { + vi.unstubAllEnvs(); + vi.unstubAllGlobals(); + }); + + it("posts the bearer and a timestamp to every https origin", async () => { + const { dispatchContentRevalidation, CONTENT_REVALIDATE_PATH } = + await load(); + const { c, fetch } = harness({ + origins: ["https://www.example.com", "https://mirror.example.com"], + }); + + await expect(dispatchContentRevalidation(c, INPUT)).resolves.toEqual({ + attempted: 2, + delivered: 2, + }); + + expect(fetch.mock.calls.map(([url]) => url)).toEqual([ + `https://www.example.com${CONTENT_REVALIDATE_PATH}`, + `https://mirror.example.com${CONTENT_REVALIDATE_PATH}`, + ]); + const headers = fetch.mock.calls[0][1].headers as Record; + expect(headers.authorization).toBe(`Bearer ${SECRET}`); + expect(Number(headers["x-vitnode-timestamp"])).toBeGreaterThan(0); + }); + + it.each([ + ["the built-in default", INSECURE_DEFAULT_CRON_SECRET], + ["a placeholder", "changeme"], + ["an empty value", ""], + ])( + "sends nothing in production when CRON_SECRET is %s", + async (_, cronSecret) => { + const { dispatchContentRevalidation } = await load(); + const { c, fetch, log } = harness({ + cronSecret, + origins: ["https://www.example.com"], + }); + + await expect(dispatchContentRevalidation(c, INPUT)).resolves.toEqual({ + attempted: 1, + delivered: 0, + }); + expect(fetch).not.toHaveBeenCalled(); + expect(log.warn).toHaveBeenCalledWith( + expect.stringContaining("CRON_SECRET"), + ); + }, + ); + + it("warns about a rejected secret only once", async () => { + const { dispatchContentRevalidation } = await load(); + const { c, log } = harness({ + cronSecret: INSECURE_DEFAULT_CRON_SECRET, + origins: ["https://www.example.com"], + }); + + await dispatchContentRevalidation(c, INPUT); + await dispatchContentRevalidation(c, INPUT); + + expect(log.warn).toHaveBeenCalledTimes(1); + }); + + it("still sends the default secret in development", async () => { + vi.stubEnv("NODE_ENV", "development"); + const { dispatchContentRevalidation } = await load(); + const { c, fetch } = harness({ + cronSecret: INSECURE_DEFAULT_CRON_SECRET, + origins: ["http://web:3000"], + }); + + await expect(dispatchContentRevalidation(c, INPUT)).resolves.toEqual({ + attempted: 1, + delivered: 1, + }); + expect(fetch).toHaveBeenCalledOnce(); + }); + + it("refuses a plain http origin and keeps notifying the others", async () => { + const { dispatchContentRevalidation } = await load(); + const { c, fetch, log } = harness({ + origins: ["http://www.example.com", "https://mirror.example.com"], + }); + + await expect(dispatchContentRevalidation(c, INPUT)).resolves.toEqual({ + attempted: 2, + delivered: 1, + }); + expect(fetch.mock.calls.map(([url]) => new URL(url).origin)).toEqual([ + "https://mirror.example.com", + ]); + expect(log.warn).toHaveBeenCalledWith( + expect.stringContaining("http://www.example.com"), + ); + }); + + it.each(["http://localhost:3000", "http://127.0.0.1:3000"])( + "allows the loopback origin %s over http", + async origin => { + const { dispatchContentRevalidation } = await load(); + const { c, fetch } = harness({ origins: [origin] }); + + await expect(dispatchContentRevalidation(c, INPUT)).resolves.toEqual({ + attempted: 1, + delivered: 1, + }); + expect(fetch).toHaveBeenCalledOnce(); + }, + ); + + it("refuses an origin that is not a URL", async () => { + const { dispatchContentRevalidation } = await load(); + const { c, fetch } = harness({ origins: ["www.example.com"] }); + + await expect(dispatchContentRevalidation(c, INPUT)).resolves.toEqual({ + attempted: 1, + delivered: 0, + }); + expect(fetch).not.toHaveBeenCalled(); + }); +}); diff --git a/packages/vitnode/src/content/server/revalidate-bridge.ts b/packages/vitnode/src/content/server/revalidate-bridge.ts index 0d9fefddb1..3cc1320469 100644 --- a/packages/vitnode/src/content/server/revalidate-bridge.ts +++ b/packages/vitnode/src/content/server/revalidate-bridge.ts @@ -5,7 +5,7 @@ import type { ContentInvalidationMode, } from "../cache"; -import { CONFIG } from "../../lib/config"; +import { CONFIG, isCronSecretRejected } from "../../lib/config"; import { contentInvalidationTags } from "../cache"; /** The path a front end mounts if it wants to be told. */ @@ -24,6 +24,41 @@ export interface ContentRevalidationRequest extends ContentInvalidationInput { mode: ContentInvalidationMode; } +const LOOPBACK_HOSTS = new Set(["127.0.0.1", "[::1]", "localhost"]); + +/** + * Each misconfiguration is reported once per process. Every scheduled run hits + * the same one, and a warning per run buries the log it is meant to stand out + * in. + */ +const warned = new Set(); + +const warnOnce = (c: Context, key: string, message: string): void => { + if (warned.has(key)) return; + warned.add(key); + void log(c, message, "warn"); +}; + +/** + * The bearer is `CRON_SECRET` itself - the key to every cron job - so it only + * travels over TLS. Plain `http` is allowed for a loopback front end, which is + * what development looks like. + */ +const isSafeOrigin = (origin: string): boolean => { + if (CONFIG.node_development) return true; + + try { + const url = new URL(origin); + + return ( + url.protocol === "https:" || + (url.protocol === "http:" && LOOPBACK_HOSTS.has(url.hostname)) + ); + } catch { + return false; + } +}; + const sleep = async (ms: number): Promise => await new Promise(resolve => setTimeout(resolve, ms)); @@ -49,6 +84,20 @@ export const dispatchContentRevalidation = async ( if (origins.length === 0) return { attempted: 0, delivered: 0 }; const secret = c.get("core")?.cronSecret ?? CONFIG.cronJobSecret; + + // The built-in default is in the repository, so outside development sending + // it proves nothing and hands it to every origin listed. Every origin counts + // as not delivered, so the run records the failure instead of hiding it. + if (isCronSecretRejected(secret)) { + warnOnce( + c, + "secret", + "Not notifying content.revalidateOrigins: CRON_SECRET is unset or still the built-in default. Set CRON_SECRET to a random value.", + ); + + return { attempted: origins.length, delivered: 0 }; + } + const body = JSON.stringify(input); let delivered = 0; @@ -56,6 +105,15 @@ export const dispatchContentRevalidation = async ( // with separate caches, and a stale page on one is not a reason for a stale // page on all of them. for (const origin of origins) { + if (!isSafeOrigin(origin)) { + warnOnce( + c, + `origin:${origin}`, + `Not notifying ${origin}: content.revalidateOrigins must use https (plain http only for localhost), because the request carries CRON_SECRET.`, + ); + continue; + } + if (await post(c, origin, body, secret)) delivered += 1; } @@ -114,11 +172,15 @@ const post = async ( * Logging is itself best effort: it writes to the database, so it can fail for * the same reasons the request did. */ -const log = async (c: Context, message: string): Promise => { +const log = async ( + c: Context, + message: string, + level: "error" | "warn" = "error", +): Promise => { const text = `[content-revalidate] ${message}`; try { - await c.get("log")?.error(text); + await c.get("log")?.[level](text); } catch { // The logger writes to the database, so it can fail for the same reason the // request did. The console is the only place left. From 45eff7eac7006ba48869e9460bdcccb280d2ee3e Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 7 Oct 2026 06:48:36 +0000 Subject: [PATCH 07/25] =?UTF-8?q?fix(api):=20=F0=9F=90=9B=20read=20NODE=5F?= =?UTF-8?q?MAILER=5FUSER=20for=20the=20nodemailer=20adapter?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The dev API config read `NOD_EMAILER_USER`, so SMTP auth never got its user while every other variable and the docs use `NODE_MAILER_*`. Also drop the `console.log` of submitted values from the admin test view. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01BcTiLrsGK6BGwvs4uieRMB --- apps/api/src/vitnode.api.config.ts | 2 +- packages/vitnode/src/views/admin/views/core/test.tsx | 4 +--- 2 files changed, 2 insertions(+), 4 deletions(-) diff --git a/apps/api/src/vitnode.api.config.ts b/apps/api/src/vitnode.api.config.ts index efeb874750..1304f9d0ae 100644 --- a/apps/api/src/vitnode.api.config.ts +++ b/apps/api/src/vitnode.api.config.ts @@ -74,7 +74,7 @@ export const vitNodeApiConfig = buildApiConfig({ from: process.env.NODE_MAILER_FROM, host: process.env.NODE_MAILER_HOST, password: process.env.NODE_MAILER_PASSWORD, - user: process.env.NOD_EMAILER_USER, + user: process.env.NODE_MAILER_USER, }), logo: { text: "VitNode Email Test", diff --git a/packages/vitnode/src/views/admin/views/core/test.tsx b/packages/vitnode/src/views/admin/views/core/test.tsx index 4e543b970a..915353c8bd 100644 --- a/packages/vitnode/src/views/admin/views/core/test.tsx +++ b/packages/vitnode/src/views/admin/views/core/test.tsx @@ -280,9 +280,7 @@ export const TestView = () => { }, ]} formSchema={formSchema} - onSubmit={async values => { - // oxlint-disable-next-line no-console - console.log("Form submitted", values); + onSubmit={async () => { await new Promise(resolve => setTimeout(resolve, 3000)); }} /> From 29687aed48bd70584b82ee478f89f5b1c718e556 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 7 Oct 2026 06:54:15 +0000 Subject: [PATCH 08/25] =?UTF-8?q?chore(deps):=20=E2=AC=86=EF=B8=8F=20patch?= =?UTF-8?q?=20next,=20seroval,=20esbuild=20and=20http-cache-semantics=20ad?= =?UTF-8?q?visories?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `pnpm-workspace.yaml` overrides, each scoped to the vulnerable range: - next 16.3.3 -> 16.3.8 (GHSA-vcvr-r3jv-pc5j), pinned by @react-email/ui - seroval/seroval-plugins 1.5.6 -> 1.6.8 (GHSA-p6vx-979v-rg4c, GHSA-jp82-f5mq-hwhp). solid-js 1.9.15 asks for `~1.5.4`; 1.9.16 asks for `~1.6.8` and calls seroval the same way, but is still inside `minimumReleaseAge`, so seroval is overridden directly for now - esbuild 0.27.7 -> 0.28.2 (GHSA-g7r4-m6w7-qqqr) - http-cache-semantics 4.2.0 -> 4.3.0 (GHSA-ch52-4w7c-c8xp, dev only) braces (GHSA-vfj7-8cjw-p6xm) stays: 3.0.3 is the newest release on npm. Add Dependabot for npm and GitHub Actions, weekly, minor/patch grouped. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01BcTiLrsGK6BGwvs4uieRMB --- .github/dependabot.yml | 36 +++ pnpm-lock.yaml | 631 ++++++----------------------------------- pnpm-workspace.yaml | 16 ++ 3 files changed, 138 insertions(+), 545 deletions(-) create mode 100644 .github/dependabot.yml diff --git a/.github/dependabot.yml b/.github/dependabot.yml new file mode 100644 index 0000000000..44d29dd5ca --- /dev/null +++ b/.github/dependabot.yml @@ -0,0 +1,36 @@ +version: 2 + +updates: + # One pnpm workspace: the root lockfile covers apps/*, packages/* and plugins/*. + - package-ecosystem: npm + directory: / + schedule: + interval: weekly + day: monday + # Matches pnpm's `minimumReleaseAge`: a release has to survive a few days + # before it lands here. Security updates are not delayed. + cooldown: + default-days: 3 + open-pull-requests-limit: 10 + commit-message: + prefix: chore(deps) + groups: + npm-minor-patch: + update-types: + - minor + - patch + + - package-ecosystem: github-actions + directories: + - / + - /.github/actions/setup + schedule: + interval: weekly + day: monday + commit-message: + prefix: ci(deps) + groups: + github-actions: + update-types: + - minor + - patch diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index fd576c97e1..816aeff96d 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -4,6 +4,13 @@ settings: autoInstallPeers: true excludeLinksFromLockfile: false +overrides: + next@>=16.2.0 <16.3.6: ~16.3.8 + seroval@<1.6.3: ~1.6.8 + seroval-plugins@<1.6.3: ~1.6.8 + esbuild@>=0.27.3 <0.28.1: ^0.28.2 + http-cache-semantics@<4.2.1: ^4.3.0 + packageExtensionsChecksum: sha256-HsioH3GLCE7TIphF7op/QJHyEEDUh92g4zCUIbohtBY= importers: @@ -196,13 +203,13 @@ importers: version: 1.0.0-rc.4(@opentelemetry/api@1.9.1)(postgres@3.4.9)(zod@4.6.5) fumadocs-core: specifier: ^16.16.2 - version: 16.16.2(@mdx-js/mdx@3.1.1)(@tanstack/react-router@1.170.41(react-dom@19.3.0(react@19.3.0))(react@19.3.0))(@types/estree-jsx@1.0.5)(@types/hast@3.0.5)(@types/mdast@4.0.4)(@types/react@19.3.0)(lucide-react@1.52.0(@types/react@19.3.0)(react@19.3.0))(next@16.3.3(@babel/core@7.29.7)(@opentelemetry/api@1.9.1)(@playwright/test@1.61.1)(@types/node@26.6.4)(babel-plugin-react-compiler@1.0.0)(react-dom@19.3.0(react@19.3.0))(react@19.3.0))(react-dom@19.3.0(react@19.3.0))(react@19.3.0)(zod@4.6.5) + version: 16.16.2(@mdx-js/mdx@3.1.1)(@tanstack/react-router@1.170.41(react-dom@19.3.0(react@19.3.0))(react@19.3.0))(@types/estree-jsx@1.0.5)(@types/hast@3.0.5)(@types/mdast@4.0.4)(@types/react@19.3.0)(lucide-react@1.52.0(@types/react@19.3.0)(react@19.3.0))(next@16.3.8(@babel/core@7.29.7)(@opentelemetry/api@1.9.1)(@playwright/test@1.61.1)(@types/node@26.6.4)(babel-plugin-react-compiler@1.0.0)(react-dom@19.3.0(react@19.3.0))(react@19.3.0))(react-dom@19.3.0(react@19.3.0))(react@19.3.0)(zod@4.6.5) fumadocs-mdx: specifier: ^15.4.6 - version: 15.4.6(@types/mdast@4.0.4)(@types/mdx@2.0.14)(@types/react@19.3.0)(fumadocs-core@16.16.2(@mdx-js/mdx@3.1.1)(@tanstack/react-router@1.170.41(react-dom@19.3.0(react@19.3.0))(react@19.3.0))(@types/estree-jsx@1.0.5)(@types/hast@3.0.5)(@types/mdast@4.0.4)(@types/react@19.3.0)(lucide-react@1.52.0(@types/react@19.3.0)(react@19.3.0))(next@16.3.3(@babel/core@7.29.7)(@opentelemetry/api@1.9.1)(@playwright/test@1.61.1)(@types/node@26.6.4)(babel-plugin-react-compiler@1.0.0)(react-dom@19.3.0(react@19.3.0))(react@19.3.0))(react-dom@19.3.0(react@19.3.0))(react@19.3.0)(zod@4.6.5))(next@16.3.3(@babel/core@7.29.7)(@opentelemetry/api@1.9.1)(@playwright/test@1.61.1)(@types/node@26.6.4)(babel-plugin-react-compiler@1.0.0)(react-dom@19.3.0(react@19.3.0))(react@19.3.0))(react@19.3.0)(rolldown@1.2.13)(vite@8.3.3(@types/node@26.6.4)(esbuild@0.28.2)(jiti@2.7.0)(tsx@4.23.15)(yaml@2.9.1)) + version: 15.4.6(@types/mdast@4.0.4)(@types/mdx@2.0.14)(@types/react@19.3.0)(fumadocs-core@16.16.2(@mdx-js/mdx@3.1.1)(@tanstack/react-router@1.170.41(react-dom@19.3.0(react@19.3.0))(react@19.3.0))(@types/estree-jsx@1.0.5)(@types/hast@3.0.5)(@types/mdast@4.0.4)(@types/react@19.3.0)(lucide-react@1.52.0(@types/react@19.3.0)(react@19.3.0))(next@16.3.8(@babel/core@7.29.7)(@opentelemetry/api@1.9.1)(@playwright/test@1.61.1)(@types/node@26.6.4)(babel-plugin-react-compiler@1.0.0)(react-dom@19.3.0(react@19.3.0))(react@19.3.0))(react-dom@19.3.0(react@19.3.0))(react@19.3.0)(zod@4.6.5))(next@16.3.8(@babel/core@7.29.7)(@opentelemetry/api@1.9.1)(@playwright/test@1.61.1)(@types/node@26.6.4)(babel-plugin-react-compiler@1.0.0)(react-dom@19.3.0(react@19.3.0))(react@19.3.0))(react@19.3.0)(rolldown@1.2.13)(vite@8.3.3(@types/node@26.6.4)(esbuild@0.28.2)(jiti@2.7.0)(tsx@4.23.15)(yaml@2.9.1)) fumadocs-ui: specifier: ^16.16.2 - version: 16.16.2(@types/mdx@2.0.14)(@types/react-dom@19.3.0(@types/react@19.3.0))(@types/react@19.3.0)(fumadocs-core@16.16.2(@mdx-js/mdx@3.1.1)(@tanstack/react-router@1.170.41(react-dom@19.3.0(react@19.3.0))(react@19.3.0))(@types/estree-jsx@1.0.5)(@types/hast@3.0.5)(@types/mdast@4.0.4)(@types/react@19.3.0)(lucide-react@1.52.0(@types/react@19.3.0)(react@19.3.0))(next@16.3.3(@babel/core@7.29.7)(@opentelemetry/api@1.9.1)(@playwright/test@1.61.1)(@types/node@26.6.4)(babel-plugin-react-compiler@1.0.0)(react-dom@19.3.0(react@19.3.0))(react@19.3.0))(react-dom@19.3.0(react@19.3.0))(react@19.3.0)(zod@4.6.5))(next@16.3.3(@babel/core@7.29.7)(@opentelemetry/api@1.9.1)(@playwright/test@1.61.1)(@types/node@26.6.4)(babel-plugin-react-compiler@1.0.0)(react-dom@19.3.0(react@19.3.0))(react@19.3.0))(react-dom@19.3.0(react@19.3.0))(react@19.3.0)(tailwindcss@4.3.3) + version: 16.16.2(@types/mdx@2.0.14)(@types/react-dom@19.3.0(@types/react@19.3.0))(@types/react@19.3.0)(fumadocs-core@16.16.2(@mdx-js/mdx@3.1.1)(@tanstack/react-router@1.170.41(react-dom@19.3.0(react@19.3.0))(react@19.3.0))(@types/estree-jsx@1.0.5)(@types/hast@3.0.5)(@types/mdast@4.0.4)(@types/react@19.3.0)(lucide-react@1.52.0(@types/react@19.3.0)(react@19.3.0))(next@16.3.8(@babel/core@7.29.7)(@opentelemetry/api@1.9.1)(@playwright/test@1.61.1)(@types/node@26.6.4)(babel-plugin-react-compiler@1.0.0)(react-dom@19.3.0(react@19.3.0))(react@19.3.0))(react-dom@19.3.0(react@19.3.0))(react@19.3.0)(zod@4.6.5))(next@16.3.8(@babel/core@7.29.7)(@opentelemetry/api@1.9.1)(@playwright/test@1.61.1)(@types/node@26.6.4)(babel-plugin-react-compiler@1.0.0)(react-dom@19.3.0(react@19.3.0))(react@19.3.0))(react-dom@19.3.0(react@19.3.0))(react@19.3.0)(tailwindcss@4.3.3) hono: specifier: ^4.13.13 version: 4.13.13 @@ -688,7 +695,7 @@ importers: version: 4.14.2(react-dom@19.3.0(react@19.3.0))(react@19.3.0) react-scan: specifier: ^0.5.7 - version: 0.5.7(@opentelemetry/core@2.12.0(@opentelemetry/api@1.9.1))(esbuild@0.27.7)(eslint@10.11.0(jiti@2.7.0))(oxlint-tsgolint@7.0.2003)(react-dom@19.3.0(react@19.3.0))(react@19.3.0)(rolldown@1.2.12)(rollup@4.63.5)(vite@8.3.3(@types/node@26.6.4)(esbuild@0.27.7)(jiti@2.7.0)(tsx@4.23.15)(yaml@2.9.1)) + version: 0.5.7(@opentelemetry/core@2.12.0(@opentelemetry/api@1.9.1))(esbuild@0.28.2)(eslint@10.11.0(jiti@2.7.0))(oxlint-tsgolint@7.0.2003)(react-dom@19.3.0(react@19.3.0))(react@19.3.0)(rolldown@1.2.12)(rollup@4.63.5)(vite@8.3.3(@types/node@26.6.4)(esbuild@0.28.2)(jiti@2.7.0)(tsx@4.23.15)(yaml@2.9.1)) redis: specifier: ^6.3.0 version: 6.3.0(@opentelemetry/api@1.9.1) @@ -725,7 +732,7 @@ importers: version: 1.16.13(@swc/helpers@0.5.23) '@tanstack/react-form': specifier: ^1.33.5 - version: 1.33.5(@tanstack/react-start@1.168.60(crossws@0.4.12(srvx@1.0.5))(esbuild@0.27.7)(react-dom@19.3.0(react@19.3.0))(react@19.3.0)(rolldown@1.2.13)(rollup@4.63.5)(vite@8.3.3(@types/node@26.6.4)(esbuild@0.27.7)(jiti@2.7.0)(tsx@4.23.15)(yaml@2.9.1)))(react-dom@19.3.0(react@19.3.0))(react@19.3.0) + version: 1.33.5(@tanstack/react-start@1.168.60(crossws@0.4.12(srvx@1.0.5))(esbuild@0.28.2)(react-dom@19.3.0(react@19.3.0))(react@19.3.0)(rolldown@1.2.13)(rollup@4.63.5)(vite@8.3.3(@types/node@26.6.4)(esbuild@0.28.2)(jiti@2.7.0)(tsx@4.23.15)(yaml@2.9.1)))(react-dom@19.3.0(react@19.3.0))(react@19.3.0) '@tanstack/react-query': specifier: ^5.104.1 version: 5.104.1(react@19.3.0) @@ -734,7 +741,7 @@ importers: version: 1.170.41(react-dom@19.3.0(react@19.3.0))(react@19.3.0) '@tanstack/react-start': specifier: ^1.168.60 - version: 1.168.60(crossws@0.4.12(srvx@1.0.5))(esbuild@0.27.7)(react-dom@19.3.0(react@19.3.0))(react@19.3.0)(rolldown@1.2.13)(rollup@4.63.5)(vite@8.3.3(@types/node@26.6.4)(esbuild@0.27.7)(jiti@2.7.0)(tsx@4.23.15)(yaml@2.9.1)) + version: 1.168.60(crossws@0.4.12(srvx@1.0.5))(esbuild@0.28.2)(react-dom@19.3.0(react@19.3.0))(react@19.3.0)(rolldown@1.2.13)(rollup@4.63.5)(vite@8.3.3(@types/node@26.6.4)(esbuild@0.28.2)(jiti@2.7.0)(tsx@4.23.15)(yaml@2.9.1)) '@testing-library/dom': specifier: ^10.4.2 version: 10.4.2 @@ -758,7 +765,7 @@ importers: version: 2.16.2 '@vitejs/plugin-react': specifier: ^6.1.2 - version: 6.1.2(babel-plugin-react-compiler@1.0.0)(vite@8.3.3(@types/node@26.6.4)(esbuild@0.27.7)(jiti@2.7.0)(tsx@4.23.15)(yaml@2.9.1)) + version: 6.1.2(babel-plugin-react-compiler@1.0.0)(vite@8.3.3(@types/node@26.6.4)(esbuild@0.28.2)(jiti@2.7.0)(tsx@4.23.15)(yaml@2.9.1)) '@vitest/coverage-v8': specifier: ^5.0.3 version: 5.0.3(vitest@5.0.3) @@ -818,10 +825,10 @@ importers: version: 6.0.3 vite: specifier: ^8.3.3 - version: 8.3.3(@types/node@26.6.4)(esbuild@0.27.7)(jiti@2.7.0)(tsx@4.23.15)(yaml@2.9.1) + version: 8.3.3(@types/node@26.6.4)(esbuild@0.28.2)(jiti@2.7.0)(tsx@4.23.15)(yaml@2.9.1) vitest: specifier: ^5.0.3 - version: 5.0.3(@opentelemetry/api@1.9.1)(@types/node@26.6.4)(@vitest/coverage-v8@5.0.3)(jsdom@30.1.2)(vite@8.3.3(@types/node@26.6.4)(esbuild@0.27.7)(jiti@2.7.0)(tsx@4.23.15)(yaml@2.9.1)) + version: 5.0.3(@opentelemetry/api@1.9.1)(@types/node@26.6.4)(@vitest/coverage-v8@5.0.3)(jsdom@30.1.2)(vite@8.3.3(@types/node@26.6.4)(esbuild@0.28.2)(jiti@2.7.0)(tsx@4.23.15)(yaml@2.9.1)) zod: specifier: ^4.6.5 version: 4.6.5 @@ -1413,12 +1420,6 @@ packages: cpu: [ppc64] os: [aix] - '@esbuild/aix-ppc64@0.27.7': - resolution: {integrity: sha512-EKX3Qwmhz1eMdEJokhALr0YiD0lhQNwDqkPYyPhiSwKrh7/4KRjQc04sZ8db+5DVVnZ1LmbNDI1uAMPEUBnQPg==} - engines: {node: '>=18'} - cpu: [ppc64] - os: [aix] - '@esbuild/aix-ppc64@0.28.2': resolution: {integrity: sha512-XExcO+dvLKvVtNTibSTBej1NCAbaGhWn9Ww1ZPx80qsahhPFe/8jgWP0IchNe0F3HwkU7n8ejhH8bjonqht8mQ==} engines: {node: '>=18'} @@ -1431,12 +1432,6 @@ packages: cpu: [arm64] os: [android] - '@esbuild/android-arm64@0.27.7': - resolution: {integrity: sha512-62dPZHpIXzvChfvfLJow3q5dDtiNMkwiRzPylSCfriLvZeq0a1bWChrGx/BbUbPwOrsWKMn8idSllklzBy+dgQ==} - engines: {node: '>=18'} - cpu: [arm64] - os: [android] - '@esbuild/android-arm64@0.28.2': resolution: {integrity: sha512-5YfKeeI8qWfBZIX+u2xZC3Zlb3Os/gLS2sbEKM+I4ZOcsWmHS2WLysCcQZDAFRslDUU5Oiq44gf6PYN1vGwG5A==} engines: {node: '>=18'} @@ -1449,12 +1444,6 @@ packages: cpu: [arm] os: [android] - '@esbuild/android-arm@0.27.7': - resolution: {integrity: sha512-jbPXvB4Yj2yBV7HUfE2KHe4GJX51QplCN1pGbYjvsyCZbQmies29EoJbkEc+vYuU5o45AfQn37vZlyXy4YJ8RQ==} - engines: {node: '>=18'} - cpu: [arm] - os: [android] - '@esbuild/android-arm@0.28.2': resolution: {integrity: sha512-kXXoiPVVGQcnIYGOeaovwOURpniDBpSq4A03qkQ+BMQqtGG6HYap3xne9C1O1yo4TR3qxlCX5IqqmX6fFo2Lqg==} engines: {node: '>=18'} @@ -1467,12 +1456,6 @@ packages: cpu: [x64] os: [android] - '@esbuild/android-x64@0.27.7': - resolution: {integrity: sha512-x5VpMODneVDb70PYV2VQOmIUUiBtY3D3mPBG8NxVk5CogneYhkR7MmM3yR/uMdITLrC1ml/NV1rj4bMJuy9MCg==} - engines: {node: '>=18'} - cpu: [x64] - os: [android] - '@esbuild/android-x64@0.28.2': resolution: {integrity: sha512-O387ite7SzUyCcy3JQX4P4bLtEA7bLLkx+esve5JHnyYfNTxcVpXZo9jhdB0lTKN44gztELTdU7nS8Nr16Fs1Q==} engines: {node: '>=18'} @@ -1485,12 +1468,6 @@ packages: cpu: [arm64] os: [darwin] - '@esbuild/darwin-arm64@0.27.7': - resolution: {integrity: sha512-5lckdqeuBPlKUwvoCXIgI2D9/ABmPq3Rdp7IfL70393YgaASt7tbju3Ac+ePVi3KDH6N2RqePfHnXkaDtY9fkw==} - engines: {node: '>=18'} - cpu: [arm64] - os: [darwin] - '@esbuild/darwin-arm64@0.28.2': resolution: {integrity: sha512-n4KqkOQrraxHJcgjM1RvwbigfQKIKJVpM7xp+KsxiyUSrRdIXnt73VhrPAx0fV44hgfmIVKjxMN9J1t5jySVkw==} engines: {node: '>=18'} @@ -1503,12 +1480,6 @@ packages: cpu: [x64] os: [darwin] - '@esbuild/darwin-x64@0.27.7': - resolution: {integrity: sha512-rYnXrKcXuT7Z+WL5K980jVFdvVKhCHhUwid+dDYQpH+qu+TefcomiMAJpIiC2EM3Rjtq0sO3StMV/+3w3MyyqQ==} - engines: {node: '>=18'} - cpu: [x64] - os: [darwin] - '@esbuild/darwin-x64@0.28.2': resolution: {integrity: sha512-uq6suIWYP37qzGddBKPw5QEQPi6HiLGsO7UmkpfyaYNQ3D+rN6w6WfwH+nuqcGXWvawGwxOEroO4YGnFh95azw==} engines: {node: '>=18'} @@ -1521,12 +1492,6 @@ packages: cpu: [arm64] os: [freebsd] - '@esbuild/freebsd-arm64@0.27.7': - resolution: {integrity: sha512-B48PqeCsEgOtzME2GbNM2roU29AMTuOIN91dsMO30t+Ydis3z/3Ngoj5hhnsOSSwNzS+6JppqWsuhTp6E82l2w==} - engines: {node: '>=18'} - cpu: [arm64] - os: [freebsd] - '@esbuild/freebsd-arm64@0.28.2': resolution: {integrity: sha512-n+I0BTSRIoy+d6RPKnEVwql5UwBJolytvY4mAOIEJorKlqgPII8ix6slVVrfZ5Tnj7glIZvloylbB/EJPMWEXw==} engines: {node: '>=18'} @@ -1539,12 +1504,6 @@ packages: cpu: [x64] os: [freebsd] - '@esbuild/freebsd-x64@0.27.7': - resolution: {integrity: sha512-jOBDK5XEjA4m5IJK3bpAQF9/Lelu/Z9ZcdhTRLf4cajlB+8VEhFFRjWgfy3M1O4rO2GQ/b2dLwCUGpiF/eATNQ==} - engines: {node: '>=18'} - cpu: [x64] - os: [freebsd] - '@esbuild/freebsd-x64@0.28.2': resolution: {integrity: sha512-78XJTJkvPs0kz2w61301PJjXl4g7q3JqiYMZ/M/yVI73EHBrCRTgkhu9oqG7vPqq+a/yadEW8aD+agKlk5xrmg==} engines: {node: '>=18'} @@ -1557,12 +1516,6 @@ packages: cpu: [arm64] os: [linux] - '@esbuild/linux-arm64@0.27.7': - resolution: {integrity: sha512-RZPHBoxXuNnPQO9rvjh5jdkRmVizktkT7TCDkDmQ0W2SwHInKCAV95GRuvdSvA7w4VMwfCjUiPwDi0ZO6Nfe9A==} - engines: {node: '>=18'} - cpu: [arm64] - os: [linux] - '@esbuild/linux-arm64@0.28.2': resolution: {integrity: sha512-pW4AC0P3it8c7do9MVM4p51FzHzdM/TZrerurgRcHJ2WTa1VQ1CIq18xncfpBJw4ojkiZZrKW2yIBWBP92j6Ug==} engines: {node: '>=18'} @@ -1575,12 +1528,6 @@ packages: cpu: [arm] os: [linux] - '@esbuild/linux-arm@0.27.7': - resolution: {integrity: sha512-RkT/YXYBTSULo3+af8Ib0ykH8u2MBh57o7q/DAs3lTJlyVQkgQvlrPTnjIzzRPQyavxtPtfg0EopvDyIt0j1rA==} - engines: {node: '>=18'} - cpu: [arm] - os: [linux] - '@esbuild/linux-arm@0.28.2': resolution: {integrity: sha512-XlDnu2q5yoqems+xay6wSAcg9DDD7K9RLKZEBOMZm3ckNpJBvOX20tSfby8KfrrhINDyv9V2YVZKY/SpoGJI8w==} engines: {node: '>=18'} @@ -1593,12 +1540,6 @@ packages: cpu: [ia32] os: [linux] - '@esbuild/linux-ia32@0.27.7': - resolution: {integrity: sha512-GA48aKNkyQDbd3KtkplYWT102C5sn/EZTY4XROkxONgruHPU72l+gW+FfF8tf2cFjeHaRbWpOYa/uRBz/Xq1Pg==} - engines: {node: '>=18'} - cpu: [ia32] - os: [linux] - '@esbuild/linux-ia32@0.28.2': resolution: {integrity: sha512-CYbnj78HsIeA+DhgUKgFCfvNsTHFhMMrinUrMZpDXJXKN8T3XViTZ/+wtHeVxEWY8ewSzTFN+nRmSwO2tZaLUQ==} engines: {node: '>=18'} @@ -1611,12 +1552,6 @@ packages: cpu: [loong64] os: [linux] - '@esbuild/linux-loong64@0.27.7': - resolution: {integrity: sha512-a4POruNM2oWsD4WKvBSEKGIiWQF8fZOAsycHOt6JBpZ+JN2n2JH9WAv56SOyu9X5IqAjqSIPTaJkqN8F7XOQ5Q==} - engines: {node: '>=18'} - cpu: [loong64] - os: [linux] - '@esbuild/linux-loong64@0.28.2': resolution: {integrity: sha512-buwkd8nsph4R+ajRvw0qM5Hja/TXQow3ptzWO2EbG/cqcIkHloRrdlBtQlshyYGTNFvfkfJ5tpPLVkY4DtsPfQ==} engines: {node: '>=18'} @@ -1629,12 +1564,6 @@ packages: cpu: [mips64el] os: [linux] - '@esbuild/linux-mips64el@0.27.7': - resolution: {integrity: sha512-KabT5I6StirGfIz0FMgl1I+R1H73Gp0ofL9A3nG3i/cYFJzKHhouBV5VWK1CSgKvVaG4q1RNpCTR2LuTVB3fIw==} - engines: {node: '>=18'} - cpu: [mips64el] - os: [linux] - '@esbuild/linux-mips64el@0.28.2': resolution: {integrity: sha512-ZVykbDyk7519VwiNb9Lcj9m8XM6v5V9uKPvrEMkkEedVewf+0itkhahp4HDpgERXhwLRpWFypsGbG/J8s0QjJA==} engines: {node: '>=18'} @@ -1647,12 +1576,6 @@ packages: cpu: [ppc64] os: [linux] - '@esbuild/linux-ppc64@0.27.7': - resolution: {integrity: sha512-gRsL4x6wsGHGRqhtI+ifpN/vpOFTQtnbsupUF5R5YTAg+y/lKelYR1hXbnBdzDjGbMYjVJLJTd2OFmMewAgwlQ==} - engines: {node: '>=18'} - cpu: [ppc64] - os: [linux] - '@esbuild/linux-ppc64@0.28.2': resolution: {integrity: sha512-CAXl+Dtd9UUuJd8pKKdwh6MLm3MUMiqMPmhZ3tTSXPqfyQ3vDl6R5hZdZ/kYojK4ofXtdfSv1tFq8XzWx3heNQ==} engines: {node: '>=18'} @@ -1665,12 +1588,6 @@ packages: cpu: [riscv64] os: [linux] - '@esbuild/linux-riscv64@0.27.7': - resolution: {integrity: sha512-hL25LbxO1QOngGzu2U5xeXtxXcW+/GvMN3ejANqXkxZ/opySAZMrc+9LY/WyjAan41unrR3YrmtTsUpwT66InQ==} - engines: {node: '>=18'} - cpu: [riscv64] - os: [linux] - '@esbuild/linux-riscv64@0.28.2': resolution: {integrity: sha512-GeXCej4IQtU1B+QlDV8W/RRvbzI3O/Stss+/bCXv4lZls5WGRtu2a+3JkA3i4qIUlMXpcHebWpF8AkJhATowuA==} engines: {node: '>=18'} @@ -1683,12 +1600,6 @@ packages: cpu: [s390x] os: [linux] - '@esbuild/linux-s390x@0.27.7': - resolution: {integrity: sha512-2k8go8Ycu1Kb46vEelhu1vqEP+UeRVj2zY1pSuPdgvbd5ykAw82Lrro28vXUrRmzEsUV0NzCf54yARIK8r0fdw==} - engines: {node: '>=18'} - cpu: [s390x] - os: [linux] - '@esbuild/linux-s390x@0.28.2': resolution: {integrity: sha512-3H1weTYZPxt/WOhByszQZybS9w5lKzUn1FDMsgEChbHWQwHYQQRfBxgCcZvPhjHfKyJjIievvMmEUawJrdY9Dg==} engines: {node: '>=18'} @@ -1701,12 +1612,6 @@ packages: cpu: [x64] os: [linux] - '@esbuild/linux-x64@0.27.7': - resolution: {integrity: sha512-hzznmADPt+OmsYzw1EE33ccA+HPdIqiCRq7cQeL1Jlq2gb1+OyWBkMCrYGBJ+sxVzve2ZJEVeePbLM2iEIZSxA==} - engines: {node: '>=18'} - cpu: [x64] - os: [linux] - '@esbuild/linux-x64@0.28.2': resolution: {integrity: sha512-4xTZr1FUmSoQW4XIWmit3tzQrUTZM+N3P0XV8xROKYF50XfI7xeO90+1bZvNwxIufQ9hDQVRJH5YhgPVF8A/HQ==} engines: {node: '>=18'} @@ -1719,12 +1624,6 @@ packages: cpu: [arm64] os: [netbsd] - '@esbuild/netbsd-arm64@0.27.7': - resolution: {integrity: sha512-b6pqtrQdigZBwZxAn1UpazEisvwaIDvdbMbmrly7cDTMFnw/+3lVxxCTGOrkPVnsYIosJJXAsILG9XcQS+Yu6w==} - engines: {node: '>=18'} - cpu: [arm64] - os: [netbsd] - '@esbuild/netbsd-arm64@0.28.2': resolution: {integrity: sha512-sSATRjPeDBg3pdgHoQfoYBob11Kk1FGa9lui5RIHZCoCkJa9QKlvl3/vKz2usCmYYjs7ymJR/2Nnsqe+Hjt5nw==} engines: {node: '>=18'} @@ -1737,12 +1636,6 @@ packages: cpu: [x64] os: [netbsd] - '@esbuild/netbsd-x64@0.27.7': - resolution: {integrity: sha512-OfatkLojr6U+WN5EDYuoQhtM+1xco+/6FSzJJnuWiUw5eVcicbyK3dq5EeV/QHT1uy6GoDhGbFpprUiHUYggrw==} - engines: {node: '>=18'} - cpu: [x64] - os: [netbsd] - '@esbuild/netbsd-x64@0.28.2': resolution: {integrity: sha512-lqnzCV+mM0gIADaKihiCg6ifgfU2L3h5E33rNQBN1Y4MaVGnzryzmvvf7UHxprpQdE8hpqLolJ9Rl+SkIRDpyw==} engines: {node: '>=18'} @@ -1755,12 +1648,6 @@ packages: cpu: [arm64] os: [openbsd] - '@esbuild/openbsd-arm64@0.27.7': - resolution: {integrity: sha512-AFuojMQTxAz75Fo8idVcqoQWEHIXFRbOc1TrVcFSgCZtQfSdc1RXgB3tjOn/krRHENUB4j00bfGjyl2mJrU37A==} - engines: {node: '>=18'} - cpu: [arm64] - os: [openbsd] - '@esbuild/openbsd-arm64@0.28.2': resolution: {integrity: sha512-AL2qJILH7lNjrDmCQDvdxMfAUIv8KMNZOvrwAQ8i8//ntL9FflhOyMJ8OZSMBb8/AWXe3/5v5S20y3zCoZWKoQ==} engines: {node: '>=18'} @@ -1773,12 +1660,6 @@ packages: cpu: [x64] os: [openbsd] - '@esbuild/openbsd-x64@0.27.7': - resolution: {integrity: sha512-+A1NJmfM8WNDv5CLVQYJ5PshuRm/4cI6WMZRg1by1GwPIQPCTs1GLEUHwiiQGT5zDdyLiRM/l1G0Pv54gvtKIg==} - engines: {node: '>=18'} - cpu: [x64] - os: [openbsd] - '@esbuild/openbsd-x64@0.28.2': resolution: {integrity: sha512-QtiuPytchRyC4rwUKhexJdQKvDuZ6hWloi3igqPQNUJCS1/v9EiO3UTOXR6A3FoMo4fnAKbWJdqaIwhOzh8qEw==} engines: {node: '>=18'} @@ -1791,12 +1672,6 @@ packages: cpu: [arm64] os: [openharmony] - '@esbuild/openharmony-arm64@0.27.7': - resolution: {integrity: sha512-+KrvYb/C8zA9CU/g0sR6w2RBw7IGc5J2BPnc3dYc5VJxHCSF1yNMxTV5LQ7GuKteQXZtspjFbiuW5/dOj7H4Yw==} - engines: {node: '>=18'} - cpu: [arm64] - os: [openharmony] - '@esbuild/openharmony-arm64@0.28.2': resolution: {integrity: sha512-WkhYDmpTjLvGlScA1rwjRUmhl4k8oXR3cIbtqWmELgU/dFeHHlEllxDvdWcNJV9rbzCexB5vz8gtNewWLgCT7Q==} engines: {node: '>=18'} @@ -1809,12 +1684,6 @@ packages: cpu: [x64] os: [sunos] - '@esbuild/sunos-x64@0.27.7': - resolution: {integrity: sha512-ikktIhFBzQNt/QDyOL580ti9+5mL/YZeUPKU2ivGtGjdTYoqz6jObj6nOMfhASpS4GU4Q/Clh1QtxWAvcYKamA==} - engines: {node: '>=18'} - cpu: [x64] - os: [sunos] - '@esbuild/sunos-x64@0.28.2': resolution: {integrity: sha512-GPMSkTOtMnv2U2F8gxe4Io6qmVs+YKyp832Etqqxr0hFngmXQ3rzwytelm3GIn7T4VviRUlf3sOgBOiTdvaf7g==} engines: {node: '>=18'} @@ -1827,12 +1696,6 @@ packages: cpu: [arm64] os: [win32] - '@esbuild/win32-arm64@0.27.7': - resolution: {integrity: sha512-7yRhbHvPqSpRUV7Q20VuDwbjW5kIMwTHpptuUzV+AA46kiPze5Z7qgt6CLCK3pWFrHeNfDd1VKgyP4O+ng17CA==} - engines: {node: '>=18'} - cpu: [arm64] - os: [win32] - '@esbuild/win32-arm64@0.28.2': resolution: {integrity: sha512-PIhhEkE9uPBleRBrQEJpUn7MBnibZzbGzYWPmY3x+YoVg/95zbjB4CxPPOQ8l5tYYM4mMaCthF8/1DIfBQQyWQ==} engines: {node: '>=18'} @@ -1845,12 +1708,6 @@ packages: cpu: [ia32] os: [win32] - '@esbuild/win32-ia32@0.27.7': - resolution: {integrity: sha512-SmwKXe6VHIyZYbBLJrhOoCJRB/Z1tckzmgTLfFYOfpMAx63BJEaL9ExI8x7v0oAO3Zh6D/Oi1gVxEYr5oUCFhw==} - engines: {node: '>=18'} - cpu: [ia32] - os: [win32] - '@esbuild/win32-ia32@0.28.2': resolution: {integrity: sha512-YmJbfTlvU7Sdn9BB+4PRES4oB6pxgS37MAONj+hBr/cpXS1aBPKXxNnDbu+QCWPj0o9dgyxeq79g6c5P8KeuYA==} engines: {node: '>=18'} @@ -1863,12 +1720,6 @@ packages: cpu: [x64] os: [win32] - '@esbuild/win32-x64@0.27.7': - resolution: {integrity: sha512-56hiAJPhwQ1R4i+21FVF7V8kSD5zZTdHcVuRFMW0hn753vVfQN8xlx4uOPT4xoGH0Z/oVATuR82AiqSTDIpaHg==} - engines: {node: '>=18'} - cpu: [x64] - os: [win32] - '@esbuild/win32-x64@0.28.2': resolution: {integrity: sha512-5ebpxr3nWMzrL/rnUI755Jkuee0bHL/Gq0WTF9lvcpv73wAp5eu8MfBUgWK9bhWvZjj7yX8etf/8tI8Ney695g==} engines: {node: '>=18'} @@ -2590,57 +2441,57 @@ packages: '@neodrag/core': 3.0.0-next.11 solid-js: ^1.0.0 - '@next/env@16.3.3': - resolution: {integrity: sha512-U2eYQRwXj+dsqxV79zFqExDdatnNY/ZWc2nsJU1p/OgT7fd3dXwlF6OjYaFQCfMoeTA19PWq+wVmYgimVA+V+g==} + '@next/env@16.3.8': + resolution: {integrity: sha512-Al9zqHVV7TJv0eFuOU4U7Lvv74PTih4Ch63sk2xCIpSTkE3udFnaOcnzP2lQVymiL7yS9Cj2iClUXlR3EQ5sEw==} - '@next/swc-darwin-arm64@16.3.3': - resolution: {integrity: sha512-8Hiv32QJPwdV6KYJ8meR9SBA061tQqnIKTJDocvOXlEQqib0xMFpzArosuffFUUc0sslbh7QQ8a3Yey1QV8EIw==} + '@next/swc-darwin-arm64@16.3.8': + resolution: {integrity: sha512-2JPRMh2nmQG5CiL7cXGL9AGwnPWJQ//cTtAUCT+w511QHk79SYz3LGv/pc5X643B/WEO0rvu3Yww0hqwt3kgeA==} engines: {node: '>= 10'} cpu: [arm64] os: [darwin] - '@next/swc-darwin-x64@16.3.3': - resolution: {integrity: sha512-A1lgKgwVchRYmSe467zdwhxT9040dd8lH+o65sL5Jet8fjB4kegw/rDyPIpYVRb6jAqwXFOJpjIXJLxQKLiE3A==} + '@next/swc-darwin-x64@16.3.8': + resolution: {integrity: sha512-GZtCCOBKJ4leVIT/Th0llWKhD1ca92lzbQiS5R5ON9QkoiFnilFsebDae1JU2a3HWoKMEmEZWGs1AGLavVM72Q==} engines: {node: '>= 10'} cpu: [x64] os: [darwin] - '@next/swc-linux-arm64-gnu@16.3.3': - resolution: {integrity: sha512-bf0FIssMFueU2dm7vQEWWxk0c8UjKTdW0yzuh0sQsD8pf1+KCLDdaqhYZNMYGmXwEOiHAUzgBKudovIlcvvBjg==} + '@next/swc-linux-arm64-gnu@16.3.8': + resolution: {integrity: sha512-O659ygeQYqneJ1fBKMpFxIFqYkYswu8IAS1OCKK/4f3ZgJJm1dRz4fVJZRi/kLLWjnBKnebOePA4WNv+sV1pVA==} engines: {node: '>= 10'} cpu: [arm64] os: [linux] libc: [glibc] - '@next/swc-linux-arm64-musl@16.3.3': - resolution: {integrity: sha512-W7viwCk9JY/cAkdz/A273rd5bb3RgT/IHwR7Upv90tunjBWNtAAhGhoecHh+teRNRSinuAFmE+l7fwZ4YKkrXg==} + '@next/swc-linux-arm64-musl@16.3.8': + resolution: {integrity: sha512-dSjKSyWpzxoO1d3DIZZcP4XJcNaKeLmxQMFOiYl5vuBRMmweIqnAhty8tAmRsvTss779cK1FtYnDMj40e4TQlg==} engines: {node: '>= 10'} cpu: [arm64] os: [linux] libc: [musl] - '@next/swc-linux-x64-gnu@16.3.3': - resolution: {integrity: sha512-0W46zw1N3ODpI6n0GeivHvvob1pooozgZVqy65k0mh4/7vr+FbY9+WpHzNVXjHipJf/A3FDheBG19H1s5A25rA==} + '@next/swc-linux-x64-gnu@16.3.8': + resolution: {integrity: sha512-lbqOuz3RPRcv+o9msNsJw5x4+Y1ZwPTs6vmL6DCf7i0fZfvng/F59wyeDwqHIvV0mK//RBy/jJkZ+nCKsSMXjQ==} engines: {node: '>= 10'} cpu: [x64] os: [linux] libc: [glibc] - '@next/swc-linux-x64-musl@16.3.3': - resolution: {integrity: sha512-H4mBso8ZTMBPtdT0PN0pBx2ayTvQuTuvS6qT13d77yVFJXAPCxkyIhLTmdMaGTJs0krQYI/qpzdHijCeihXhbg==} + '@next/swc-linux-x64-musl@16.3.8': + resolution: {integrity: sha512-+316WswI8ScVgZeUd+1KGaXkHhaYQzCjvH/05TZSpJ8zBizb1a4G7DtO7F12jcBIqMOtsz9ji1t48fmKtzqsGA==} engines: {node: '>= 10'} cpu: [x64] os: [linux] libc: [musl] - '@next/swc-win32-arm64-msvc@16.3.3': - resolution: {integrity: sha512-cTMUJpcEGmeywofCUfhR+rSsoE33+rVPnPEYNTNdLNlsOeEg/vktOsKUSTb28vUGqD2jkm4Zaskcwn7OCI6FQg==} + '@next/swc-win32-arm64-msvc@16.3.8': + resolution: {integrity: sha512-ji0gd4kMYUxO+1fJBIbiBVRCjzG/lloiyCccnlebvb1ZJ5qXCPZqYg4Jl1DrrixnWNMKylzgpmMWx0yNDYXlzw==} engines: {node: '>= 10'} cpu: [arm64] os: [win32] - '@next/swc-win32-x64-msvc@16.3.3': - resolution: {integrity: sha512-2VR4cTBzHXaBjnGsuH6GyJjENzQOmHeAh11uY1iUhjm3j5dEUrVJuUj+VL78jaGi/Dik8xS76zEj18BsFhlVZQ==} + '@next/swc-win32-x64-msvc@16.3.8': + resolution: {integrity: sha512-WcTlaKt/TWkh5kUjdJcUmB1XgZ+1c6fz4Y9fDHL73YNSdGaUWjceeWrrlwF0nv19iABYWC4iAq1oX1w4Bn0vfg==} engines: {node: '>= 10'} cpu: [x64] os: [win32] @@ -6013,7 +5864,7 @@ packages: resolution: {integrity: sha512-3WrrOuZiyaaZPWiEt4G3+IffISVC9HYlWueJEBWED4ZH4aIAC2PnkdnuRrR94M+w6yGWn4AglWtJtBI8YqvgoA==} engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0} peerDependencies: - esbuild: '>=0.18' + esbuild: ^0.28.2 byte-counter@0.1.0: resolution: {integrity: sha512-jheRLVMeUKrDBjVw2O5+k4EvR4t9wtxHL+bo/LxfkxsVeuGMy3a5SEGgXdAFA4FSzTrU8rQXQIrsZ3oBq5a0pQ==} @@ -6811,11 +6662,6 @@ packages: engines: {node: '>=18'} hasBin: true - esbuild@0.27.7: - resolution: {integrity: sha512-IxpibTjyVnmrIQo5aqNpCgoACA/dTKLTlhMHihVHhdkxKyPO1uBBthumT0rdHmcsk9uMonIWS0m4FljWzILh3w==} - engines: {node: '>=18'} - hasBin: true - esbuild@0.28.2: resolution: {integrity: sha512-HKVLS8dvII+xoKW9kmqxbRKrnWEXfJJr/FZhhJmiqIB0e053QNYFqOBouTMO/k5sID4MvCiUCvv8b9M4h32wIA==} engines: {node: '>=18'} @@ -7201,7 +7047,7 @@ packages: flexsearch: '*' lucide-react: '*' meilisearch: '>=0.50.0 <1.0.0' - next: 16.x.x + next: ~16.3.8 react: ^19.2.0 react-dom: ^19.2.0 react-router: 7.x.x || 8.x.x @@ -7259,7 +7105,7 @@ packages: '@types/react': '*' fumadocs-core: ^16.15.3 mdast-util-directive: '*' - next: ^15.3.0 || ^16.0.0 + next: ~16.3.8 react: ^19.2.0 rolldown: '*' satteri: ^0.10.5 @@ -7292,7 +7138,7 @@ packages: '@types/mdx': '*' '@types/react': '*' fumadocs-core: 16.16.2 - next: 16.x.x + next: ~16.3.8 react: ^19.2.0 react-dom: ^19.2.0 takumi-js: '*' @@ -7504,8 +7350,8 @@ packages: htmlparser2@8.0.2: resolution: {integrity: sha512-GYdjWKDkbRLkZ5geuHs5NY1puJ+PXwP7+fHPRz06Eirsb9ugf6d8kkXav6ADhcODhFFPMIXyxkxSuMf3D6NCFA==} - http-cache-semantics@4.2.0: - resolution: {integrity: sha512-dTxcvPXqPvXBQpq5dUr6mEMJX4oIEFv6bwom3FDwKRDsuIjjJGANqhBuoAn9c1RQJIdAKav33ED65E2ys+87QQ==} + http-cache-semantics@4.3.0: + resolution: {integrity: sha512-M5t5LlJpS1UHMjvwRQVdFHvPISGeLAxNcrWuJkeGh0KxsqCHZ1O3NXZU/8x7cD0BDcGW8kapxMKTvwlqrNkHkA==} http-errors@2.0.1: resolution: {integrity: sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ==} @@ -8385,8 +8231,8 @@ packages: react: ^16.8 || ^17 || ^18 || ^19 || ^19.0.0-rc react-dom: ^16.8 || ^17 || ^18 || ^19 || ^19.0.0-rc - next@16.3.3: - resolution: {integrity: sha512-tuRTx1nQ/yVw83cwJBo9F+njGUgMn3UHQycreWHB8XsStvvAh1AthbI8/4IpKnFaF58F+iSiHejYOlMQ/eq83g==} + next@16.3.8: + resolution: {integrity: sha512-U7QEZaTini6wKrb8A8hqLLqYQyCetegKjCpJOyxk642vWoMoU1x5PyZCJFvgYgiptA8xc5j/9xYlZFO7w9Sjmw==} engines: {node: '>=20.9.0'} hasBin: true peerDependencies: @@ -8976,7 +8822,7 @@ packages: resolution: {integrity: sha512-KRlq734yN6q/f2CZmZi9CWHuiqSzoLhPFLtcJOL6XM4lR54myyFcY81pG9QOwj+eBC1hIHm5n+Ntbtqiilu8Rg==} hasBin: true peerDependencies: - esbuild: '>=0.18.0' + esbuild: ^0.28.2 react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 react-dom: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 peerDependenciesMeta: @@ -9206,7 +9052,7 @@ packages: resolution: {integrity: sha512-N7mWAMydj89EnYTHtRpS3LBrAz3J9O6oVrqpuXuvlAHfAUG8WEKqtbAd7SBiJNY5NfCAFhSdyAh7wI4Shjqy4g==} engines: {node: '>=10'} peerDependencies: - seroval: ^1.0 + seroval: ~1.6.8 seroval@1.6.8: resolution: {integrity: sha512-HlSgSAkTk4EqHcje1ptJjfZi1YDv5KbhVJ/d3P7T/nAXua2VmDu+AKDX5VTdFfZf48nDkWB2TKYt0DrCSa+3wg==} @@ -9752,7 +9598,7 @@ packages: '@rsbuild/core': '*' '@rspack/core': '*' bun-types-no-globals: '*' - esbuild: '*' + esbuild: ^0.28.2 rolldown: '*' rollup: '*' unloader: '*' @@ -9863,7 +9709,7 @@ packages: peerDependencies: '@types/node': ^20.19.0 || >=22.12.0 '@vitejs/devtools': ^0.7.1 - esbuild: ^0.27.0 || ^0.28.0 + esbuild: ^0.28.2 jiti: '>=1.21.0' less: ^4.0.0 sass: ^1.70.0 @@ -10725,234 +10571,156 @@ snapshots: '@esbuild/aix-ppc64@0.25.12': optional: true - '@esbuild/aix-ppc64@0.27.7': - optional: true - '@esbuild/aix-ppc64@0.28.2': optional: true '@esbuild/android-arm64@0.25.12': optional: true - '@esbuild/android-arm64@0.27.7': - optional: true - '@esbuild/android-arm64@0.28.2': optional: true '@esbuild/android-arm@0.25.12': optional: true - '@esbuild/android-arm@0.27.7': - optional: true - '@esbuild/android-arm@0.28.2': optional: true '@esbuild/android-x64@0.25.12': optional: true - '@esbuild/android-x64@0.27.7': - optional: true - '@esbuild/android-x64@0.28.2': optional: true '@esbuild/darwin-arm64@0.25.12': optional: true - '@esbuild/darwin-arm64@0.27.7': - optional: true - '@esbuild/darwin-arm64@0.28.2': optional: true '@esbuild/darwin-x64@0.25.12': optional: true - '@esbuild/darwin-x64@0.27.7': - optional: true - '@esbuild/darwin-x64@0.28.2': optional: true '@esbuild/freebsd-arm64@0.25.12': optional: true - '@esbuild/freebsd-arm64@0.27.7': - optional: true - '@esbuild/freebsd-arm64@0.28.2': optional: true '@esbuild/freebsd-x64@0.25.12': optional: true - '@esbuild/freebsd-x64@0.27.7': - optional: true - '@esbuild/freebsd-x64@0.28.2': optional: true '@esbuild/linux-arm64@0.25.12': optional: true - '@esbuild/linux-arm64@0.27.7': - optional: true - '@esbuild/linux-arm64@0.28.2': optional: true '@esbuild/linux-arm@0.25.12': optional: true - '@esbuild/linux-arm@0.27.7': - optional: true - '@esbuild/linux-arm@0.28.2': optional: true '@esbuild/linux-ia32@0.25.12': optional: true - '@esbuild/linux-ia32@0.27.7': - optional: true - '@esbuild/linux-ia32@0.28.2': optional: true '@esbuild/linux-loong64@0.25.12': optional: true - '@esbuild/linux-loong64@0.27.7': - optional: true - '@esbuild/linux-loong64@0.28.2': optional: true '@esbuild/linux-mips64el@0.25.12': optional: true - '@esbuild/linux-mips64el@0.27.7': - optional: true - '@esbuild/linux-mips64el@0.28.2': optional: true '@esbuild/linux-ppc64@0.25.12': optional: true - '@esbuild/linux-ppc64@0.27.7': - optional: true - '@esbuild/linux-ppc64@0.28.2': optional: true '@esbuild/linux-riscv64@0.25.12': optional: true - '@esbuild/linux-riscv64@0.27.7': - optional: true - '@esbuild/linux-riscv64@0.28.2': optional: true '@esbuild/linux-s390x@0.25.12': optional: true - '@esbuild/linux-s390x@0.27.7': - optional: true - '@esbuild/linux-s390x@0.28.2': optional: true '@esbuild/linux-x64@0.25.12': optional: true - '@esbuild/linux-x64@0.27.7': - optional: true - '@esbuild/linux-x64@0.28.2': optional: true '@esbuild/netbsd-arm64@0.25.12': optional: true - '@esbuild/netbsd-arm64@0.27.7': - optional: true - '@esbuild/netbsd-arm64@0.28.2': optional: true '@esbuild/netbsd-x64@0.25.12': optional: true - '@esbuild/netbsd-x64@0.27.7': - optional: true - '@esbuild/netbsd-x64@0.28.2': optional: true '@esbuild/openbsd-arm64@0.25.12': optional: true - '@esbuild/openbsd-arm64@0.27.7': - optional: true - '@esbuild/openbsd-arm64@0.28.2': optional: true '@esbuild/openbsd-x64@0.25.12': optional: true - '@esbuild/openbsd-x64@0.27.7': - optional: true - '@esbuild/openbsd-x64@0.28.2': optional: true '@esbuild/openharmony-arm64@0.25.12': optional: true - '@esbuild/openharmony-arm64@0.27.7': - optional: true - '@esbuild/openharmony-arm64@0.28.2': optional: true '@esbuild/sunos-x64@0.25.12': optional: true - '@esbuild/sunos-x64@0.27.7': - optional: true - '@esbuild/sunos-x64@0.28.2': optional: true '@esbuild/win32-arm64@0.25.12': optional: true - '@esbuild/win32-arm64@0.27.7': - optional: true - '@esbuild/win32-arm64@0.28.2': optional: true '@esbuild/win32-ia32@0.25.12': optional: true - '@esbuild/win32-ia32@0.27.7': - optional: true - '@esbuild/win32-ia32@0.28.2': optional: true '@esbuild/win32-x64@0.25.12': optional: true - '@esbuild/win32-x64@0.27.7': - optional: true - '@esbuild/win32-x64@0.28.2': optional: true @@ -11612,30 +11380,30 @@ snapshots: '@neodrag/core': 3.0.0-next.11 solid-js: 1.9.17 - '@next/env@16.3.3': {} + '@next/env@16.3.8': {} - '@next/swc-darwin-arm64@16.3.3': + '@next/swc-darwin-arm64@16.3.8': optional: true - '@next/swc-darwin-x64@16.3.3': + '@next/swc-darwin-x64@16.3.8': optional: true - '@next/swc-linux-arm64-gnu@16.3.3': + '@next/swc-linux-arm64-gnu@16.3.8': optional: true - '@next/swc-linux-arm64-musl@16.3.3': + '@next/swc-linux-arm64-musl@16.3.8': optional: true - '@next/swc-linux-x64-gnu@16.3.3': + '@next/swc-linux-x64-gnu@16.3.8': optional: true - '@next/swc-linux-x64-musl@16.3.3': + '@next/swc-linux-x64-musl@16.3.8': optional: true - '@next/swc-win32-arm64-msvc@16.3.3': + '@next/swc-win32-arm64-msvc@16.3.8': optional: true - '@next/swc-win32-x64-msvc@16.3.3': + '@next/swc-win32-x64-msvc@16.3.8': optional: true '@nodelib/fs.scandir@2.1.5': @@ -12672,7 +12440,7 @@ snapshots: dependencies: esbuild: 0.28.2 jiti: 2.6.1 - next: 16.3.3(@babel/core@7.29.7)(@opentelemetry/api@1.9.1)(@playwright/test@1.61.1)(@types/node@26.6.4)(babel-plugin-react-compiler@1.0.0)(react-dom@19.3.0(react@19.3.0))(react@19.3.0) + next: 16.3.8(@babel/core@7.29.7)(@opentelemetry/api@1.9.1)(@playwright/test@1.61.1)(@types/node@26.6.4)(babel-plugin-react-compiler@1.0.0)(react-dom@19.3.0(react@19.3.0))(react@19.3.0) transitivePeerDependencies: - '@babel/core' - '@opentelemetry/api' @@ -13450,16 +13218,6 @@ snapshots: - solid-js - utf-8-validate - '@tanstack/react-form@1.33.5(@tanstack/react-start@1.168.60(crossws@0.4.12(srvx@1.0.5))(esbuild@0.27.7)(react-dom@19.3.0(react@19.3.0))(react@19.3.0)(rolldown@1.2.13)(rollup@4.63.5)(vite@8.3.3(@types/node@26.6.4)(esbuild@0.27.7)(jiti@2.7.0)(tsx@4.23.15)(yaml@2.9.1)))(react-dom@19.3.0(react@19.3.0))(react@19.3.0)': - dependencies: - '@tanstack/form-core': 1.33.5 - '@tanstack/react-store': 0.11.2(react-dom@19.3.0(react@19.3.0))(react@19.3.0) - react: 19.3.0 - optionalDependencies: - '@tanstack/react-start': 1.168.60(crossws@0.4.12(srvx@1.0.5))(esbuild@0.27.7)(react-dom@19.3.0(react@19.3.0))(react@19.3.0)(rolldown@1.2.13)(rollup@4.63.5)(vite@8.3.3(@types/node@26.6.4)(esbuild@0.27.7)(jiti@2.7.0)(tsx@4.23.15)(yaml@2.9.1)) - transitivePeerDependencies: - - react-dom - '@tanstack/react-form@1.33.5(@tanstack/react-start@1.168.60(crossws@0.4.12(srvx@1.0.5))(esbuild@0.28.2)(react-dom@19.3.0(react@19.3.0))(react@19.3.0)(rolldown@1.2.13)(rollup@4.63.5)(vite@8.3.3(@types/node@26.6.4)(esbuild@0.28.2)(jiti@2.7.0)(tsx@4.23.15)(yaml@2.9.1)))(react-dom@19.3.0(react@19.3.0))(react@19.3.0)': dependencies: '@tanstack/form-core': 1.33.5 @@ -13523,32 +13281,6 @@ snapshots: react: 19.3.0 react-dom: 19.3.0(react@19.3.0) - '@tanstack/react-start-rsc@0.1.59(crossws@0.4.12(srvx@1.0.5))(esbuild@0.27.7)(react-dom@19.3.0(react@19.3.0))(react@19.3.0)(rolldown@1.2.13)(rollup@4.63.5)(vite@8.3.3(@types/node@26.6.4)(esbuild@0.27.7)(jiti@2.7.0)(tsx@4.23.15)(yaml@2.9.1))': - dependencies: - '@tanstack/react-router': 1.170.41(react-dom@19.3.0(react@19.3.0))(react@19.3.0) - '@tanstack/router-core': 1.171.34 - '@tanstack/router-utils': 1.162.3 - '@tanstack/start-client-core': 1.170.34 - '@tanstack/start-fn-stubs': 1.162.0 - '@tanstack/start-plugin-core': 1.171.49(@tanstack/react-router@1.170.41(react-dom@19.3.0(react@19.3.0))(react@19.3.0))(crossws@0.4.12(srvx@1.0.5))(esbuild@0.27.7)(rolldown@1.2.13)(rollup@4.63.5)(vite@8.3.3(@types/node@26.6.4)(esbuild@0.27.7)(jiti@2.7.0)(tsx@4.23.15)(yaml@2.9.1)) - '@tanstack/start-storage-context': 1.167.36 - pathe: 2.0.3 - react: 19.3.0 - react-dom: 19.3.0(react@19.3.0) - transitivePeerDependencies: - - '@farmfe/core' - - '@rsbuild/core' - - bun-types-no-globals - - crossws - - esbuild - - rolldown - - rollup - - supports-color - - unloader - - vite - - vite-plugin-solid - - webpack - '@tanstack/react-start-rsc@0.1.59(crossws@0.4.12(srvx@1.0.5))(esbuild@0.28.2)(react-dom@19.3.0(react@19.3.0))(react@19.3.0)(rolldown@1.2.13)(rollup@4.63.5)(vite@8.3.3(@types/node@26.6.4)(esbuild@0.28.2)(jiti@2.7.0)(tsx@4.23.15)(yaml@2.9.1))': dependencies: '@tanstack/react-router': 1.170.41(react-dom@19.3.0(react@19.3.0))(react@19.3.0) @@ -13585,35 +13317,6 @@ snapshots: transitivePeerDependencies: - crossws - '@tanstack/react-start@1.168.60(crossws@0.4.12(srvx@1.0.5))(esbuild@0.27.7)(react-dom@19.3.0(react@19.3.0))(react@19.3.0)(rolldown@1.2.13)(rollup@4.63.5)(vite@8.3.3(@types/node@26.6.4)(esbuild@0.27.7)(jiti@2.7.0)(tsx@4.23.15)(yaml@2.9.1))': - dependencies: - '@tanstack/react-router': 1.170.41(react-dom@19.3.0(react@19.3.0))(react@19.3.0) - '@tanstack/react-start-client': 1.168.39(react-dom@19.3.0(react@19.3.0))(react@19.3.0) - '@tanstack/react-start-rsc': 0.1.59(crossws@0.4.12(srvx@1.0.5))(esbuild@0.27.7)(react-dom@19.3.0(react@19.3.0))(react@19.3.0)(rolldown@1.2.13)(rollup@4.63.5)(vite@8.3.3(@types/node@26.6.4)(esbuild@0.27.7)(jiti@2.7.0)(tsx@4.23.15)(yaml@2.9.1)) - '@tanstack/react-start-server': 1.167.46(crossws@0.4.12(srvx@1.0.5))(react-dom@19.3.0(react@19.3.0))(react@19.3.0) - '@tanstack/router-utils': 1.162.3 - '@tanstack/start-client-core': 1.170.34 - '@tanstack/start-plugin-core': 1.171.49(@tanstack/react-router@1.170.41(react-dom@19.3.0(react@19.3.0))(react@19.3.0))(crossws@0.4.12(srvx@1.0.5))(esbuild@0.27.7)(rolldown@1.2.13)(rollup@4.63.5)(vite@8.3.3(@types/node@26.6.4)(esbuild@0.27.7)(jiti@2.7.0)(tsx@4.23.15)(yaml@2.9.1)) - '@tanstack/start-server-core': 1.169.39(crossws@0.4.12(srvx@1.0.5)) - pathe: 2.0.3 - react: 19.3.0 - react-dom: 19.3.0(react@19.3.0) - optionalDependencies: - vite: 8.3.3(@types/node@26.6.4)(esbuild@0.27.7)(jiti@2.7.0)(tsx@4.23.15)(yaml@2.9.1) - transitivePeerDependencies: - - '@farmfe/core' - - '@rspack/core' - - bun-types-no-globals - - crossws - - esbuild - - react-server-dom-rspack - - rolldown - - rollup - - supports-color - - unloader - - vite-plugin-solid - - webpack - '@tanstack/react-start@1.168.60(crossws@0.4.12(srvx@1.0.5))(esbuild@0.28.2)(react-dom@19.3.0(react@19.3.0))(react@19.3.0)(rolldown@1.2.13)(rollup@4.63.5)(vite@8.3.3(@types/node@26.6.4)(esbuild@0.28.2)(jiti@2.7.0)(tsx@4.23.15)(yaml@2.9.1))': dependencies: '@tanstack/react-router': 1.170.41(react-dom@19.3.0(react@19.3.0))(react@19.3.0) @@ -13692,30 +13395,6 @@ snapshots: transitivePeerDependencies: - supports-color - '@tanstack/router-plugin@1.168.42(@tanstack/react-router@1.170.41(react-dom@19.3.0(react@19.3.0))(react@19.3.0))(esbuild@0.27.7)(rolldown@1.2.13)(rollup@4.63.5)(vite@8.3.3(@types/node@26.6.4)(esbuild@0.27.7)(jiti@2.7.0)(tsx@4.23.15)(yaml@2.9.1))': - dependencies: - '@babel/core': 7.29.7 - '@babel/template': 7.29.7 - '@babel/types': 7.29.8 - '@tanstack/router-core': 1.171.34 - '@tanstack/router-generator': 1.167.40 - '@tanstack/router-utils': 1.162.3 - chokidar: 5.0.0 - unplugin: 3.4.0(esbuild@0.27.7)(rolldown@1.2.13)(rollup@4.63.5)(vite@8.3.3(@types/node@26.6.4)(esbuild@0.27.7)(jiti@2.7.0)(tsx@4.23.15)(yaml@2.9.1)) - zod: 4.6.5 - optionalDependencies: - '@tanstack/react-router': 1.170.41(react-dom@19.3.0(react@19.3.0))(react@19.3.0) - vite: 8.3.3(@types/node@26.6.4)(esbuild@0.27.7)(jiti@2.7.0)(tsx@4.23.15)(yaml@2.9.1) - transitivePeerDependencies: - - '@farmfe/core' - - '@rspack/core' - - bun-types-no-globals - - esbuild - - rolldown - - rollup - - supports-color - - unloader - '@tanstack/router-plugin@1.168.42(@tanstack/react-router@1.170.41(react-dom@19.3.0(react@19.3.0))(react@19.3.0))(esbuild@0.28.2)(rolldown@1.2.13)(rollup@4.63.5)(vite@8.3.3(@types/node@26.6.4)(esbuild@0.28.2)(jiti@2.7.0)(tsx@4.23.15)(yaml@2.9.1))': dependencies: '@babel/core': 7.29.7 @@ -13767,45 +13446,6 @@ snapshots: '@tanstack/start-fn-stubs@1.162.0': {} - '@tanstack/start-plugin-core@1.171.49(@tanstack/react-router@1.170.41(react-dom@19.3.0(react@19.3.0))(react@19.3.0))(crossws@0.4.12(srvx@1.0.5))(esbuild@0.27.7)(rolldown@1.2.13)(rollup@4.63.5)(vite@8.3.3(@types/node@26.6.4)(esbuild@0.27.7)(jiti@2.7.0)(tsx@4.23.15)(yaml@2.9.1))': - dependencies: - '@babel/code-frame': 7.29.7 - '@babel/core': 7.29.7 - '@babel/types': 7.29.8 - '@jridgewell/remapping': 2.3.5 - '@tanstack/router-core': 1.171.34 - '@tanstack/router-generator': 1.167.40 - '@tanstack/router-plugin': 1.168.42(@tanstack/react-router@1.170.41(react-dom@19.3.0(react@19.3.0))(react@19.3.0))(esbuild@0.27.7)(rolldown@1.2.13)(rollup@4.63.5)(vite@8.3.3(@types/node@26.6.4)(esbuild@0.27.7)(jiti@2.7.0)(tsx@4.23.15)(yaml@2.9.1)) - '@tanstack/router-utils': 1.162.3 - '@tanstack/start-server-core': 1.169.39(crossws@0.4.12(srvx@1.0.5)) - exsolve: 1.1.1 - lightningcss: 1.33.0 - pathe: 2.0.3 - picomatch: 4.0.7 - seroval: 1.6.8 - source-map: 0.7.6 - srvx: 0.11.22 - tinyglobby: 0.2.17 - ufo: 1.6.4 - vitefu: 1.1.3(vite@8.3.3(@types/node@26.6.4)(esbuild@0.27.7)(jiti@2.7.0)(tsx@4.23.15)(yaml@2.9.1)) - xmlbuilder2: 4.0.3 - zod: 4.6.5 - optionalDependencies: - vite: 8.3.3(@types/node@26.6.4)(esbuild@0.27.7)(jiti@2.7.0)(tsx@4.23.15)(yaml@2.9.1) - transitivePeerDependencies: - - '@farmfe/core' - - '@rspack/core' - - '@tanstack/react-router' - - bun-types-no-globals - - crossws - - esbuild - - rolldown - - rollup - - supports-color - - unloader - - vite-plugin-solid - - webpack - '@tanstack/start-plugin-core@1.171.49(@tanstack/react-router@1.170.41(react-dom@19.3.0(react@19.3.0))(react@19.3.0))(crossws@0.4.12(srvx@1.0.5))(esbuild@0.28.2)(rolldown@1.2.13)(rollup@4.63.5)(vite@8.3.3(@types/node@26.6.4)(esbuild@0.28.2)(jiti@2.7.0)(tsx@4.23.15)(yaml@2.9.1))': dependencies: '@babel/code-frame': 7.29.7 @@ -14391,13 +14031,6 @@ snapshots: '@vercel/oidc@3.2.0': {} - '@vitejs/plugin-react@6.1.2(babel-plugin-react-compiler@1.0.0)(vite@8.3.3(@types/node@26.6.4)(esbuild@0.27.7)(jiti@2.7.0)(tsx@4.23.15)(yaml@2.9.1))': - dependencies: - '@rolldown/pluginutils': 1.0.1 - vite: 8.3.3(@types/node@26.6.4)(esbuild@0.27.7)(jiti@2.7.0)(tsx@4.23.15)(yaml@2.9.1) - optionalDependencies: - babel-plugin-react-compiler: 1.0.0 - '@vitejs/plugin-react@6.1.2(babel-plugin-react-compiler@1.0.0)(vite@8.3.3(@types/node@26.6.4)(esbuild@0.28.2)(jiti@2.7.0)(tsx@4.23.15)(yaml@2.9.1))': dependencies: '@rolldown/pluginutils': 1.0.1 @@ -14423,15 +14056,6 @@ snapshots: dependencies: '@vitest/istanbul-lib-coverage': 1.0.2 - '@vitest/mocker@5.0.3(vite@8.3.3(@types/node@26.6.4)(esbuild@0.27.7)(jiti@2.7.0)(tsx@4.23.15)(yaml@2.9.1))': - dependencies: - '@jridgewell/trace-mapping': 0.3.31 - '@vitest/spy': 5.0.3 - estree-walker: 3.0.3 - magic-string: 1.4.2 - optionalDependencies: - vite: 8.3.3(@types/node@26.6.4)(esbuild@0.27.7)(jiti@2.7.0)(tsx@4.23.15)(yaml@2.9.1) - '@vitest/mocker@5.0.3(vite@8.3.3(@types/node@26.6.4)(esbuild@0.28.2)(jiti@2.7.0)(tsx@4.23.15)(yaml@2.9.1))': dependencies: '@jridgewell/trace-mapping': 0.3.31 @@ -14807,9 +14431,9 @@ snapshots: dependencies: run-applescript: 7.1.0 - bundle-require@5.1.0(esbuild@0.27.7): + bundle-require@5.1.0(esbuild@0.28.2): dependencies: - esbuild: 0.27.7 + esbuild: 0.28.2 load-tsconfig: 0.2.5 byte-counter@0.1.0: {} @@ -14824,7 +14448,7 @@ snapshots: dependencies: '@types/http-cache-semantics': 4.2.0 get-stream: 9.0.1 - http-cache-semantics: 4.2.0 + http-cache-semantics: 4.3.0 keyv: 5.6.0 mimic-response: 4.0.0 normalize-url: 8.1.1 @@ -15430,35 +15054,6 @@ snapshots: '@esbuild/win32-ia32': 0.25.12 '@esbuild/win32-x64': 0.25.12 - esbuild@0.27.7: - optionalDependencies: - '@esbuild/aix-ppc64': 0.27.7 - '@esbuild/android-arm': 0.27.7 - '@esbuild/android-arm64': 0.27.7 - '@esbuild/android-x64': 0.27.7 - '@esbuild/darwin-arm64': 0.27.7 - '@esbuild/darwin-x64': 0.27.7 - '@esbuild/freebsd-arm64': 0.27.7 - '@esbuild/freebsd-x64': 0.27.7 - '@esbuild/linux-arm': 0.27.7 - '@esbuild/linux-arm64': 0.27.7 - '@esbuild/linux-ia32': 0.27.7 - '@esbuild/linux-loong64': 0.27.7 - '@esbuild/linux-mips64el': 0.27.7 - '@esbuild/linux-ppc64': 0.27.7 - '@esbuild/linux-riscv64': 0.27.7 - '@esbuild/linux-s390x': 0.27.7 - '@esbuild/linux-x64': 0.27.7 - '@esbuild/netbsd-arm64': 0.27.7 - '@esbuild/netbsd-x64': 0.27.7 - '@esbuild/openbsd-arm64': 0.27.7 - '@esbuild/openbsd-x64': 0.27.7 - '@esbuild/openharmony-arm64': 0.27.7 - '@esbuild/sunos-x64': 0.27.7 - '@esbuild/win32-arm64': 0.27.7 - '@esbuild/win32-ia32': 0.27.7 - '@esbuild/win32-x64': 0.27.7 - esbuild@0.28.2: optionalDependencies: '@esbuild/aix-ppc64': 0.28.2 @@ -15978,7 +15573,7 @@ snapshots: fsevents@2.3.3: optional: true - fumadocs-core@16.16.2(@mdx-js/mdx@3.1.1)(@tanstack/react-router@1.170.41(react-dom@19.3.0(react@19.3.0))(react@19.3.0))(@types/estree-jsx@1.0.5)(@types/hast@3.0.5)(@types/mdast@4.0.4)(@types/react@19.3.0)(lucide-react@1.52.0(@types/react@19.3.0)(react@19.3.0))(next@16.3.3(@babel/core@7.29.7)(@opentelemetry/api@1.9.1)(@playwright/test@1.61.1)(@types/node@26.6.4)(babel-plugin-react-compiler@1.0.0)(react-dom@19.3.0(react@19.3.0))(react@19.3.0))(react-dom@19.3.0(react@19.3.0))(react@19.3.0)(zod@4.6.5): + fumadocs-core@16.16.2(@mdx-js/mdx@3.1.1)(@tanstack/react-router@1.170.41(react-dom@19.3.0(react@19.3.0))(react@19.3.0))(@types/estree-jsx@1.0.5)(@types/hast@3.0.5)(@types/mdast@4.0.4)(@types/react@19.3.0)(lucide-react@1.52.0(@types/react@19.3.0)(react@19.3.0))(next@16.3.8(@babel/core@7.29.7)(@opentelemetry/api@1.9.1)(@playwright/test@1.61.1)(@types/node@26.6.4)(babel-plugin-react-compiler@1.0.0)(react-dom@19.3.0(react@19.3.0))(react@19.3.0))(react-dom@19.3.0(react@19.3.0))(react@19.3.0)(zod@4.6.5): dependencies: '@fumari/image-size': 0.1.1 estree-util-value-to-estree: 3.5.0 @@ -16007,21 +15602,21 @@ snapshots: '@types/mdast': 4.0.4 '@types/react': 19.3.0 lucide-react: 1.52.0(@types/react@19.3.0)(react@19.3.0) - next: 16.3.3(@babel/core@7.29.7)(@opentelemetry/api@1.9.1)(@playwright/test@1.61.1)(@types/node@26.6.4)(babel-plugin-react-compiler@1.0.0)(react-dom@19.3.0(react@19.3.0))(react@19.3.0) + next: 16.3.8(@babel/core@7.29.7)(@opentelemetry/api@1.9.1)(@playwright/test@1.61.1)(@types/node@26.6.4)(babel-plugin-react-compiler@1.0.0)(react-dom@19.3.0(react@19.3.0))(react@19.3.0) react: 19.3.0 react-dom: 19.3.0(react@19.3.0) zod: 4.6.5 transitivePeerDependencies: - supports-color - fumadocs-mdx@15.4.6(@types/mdast@4.0.4)(@types/mdx@2.0.14)(@types/react@19.3.0)(fumadocs-core@16.16.2(@mdx-js/mdx@3.1.1)(@tanstack/react-router@1.170.41(react-dom@19.3.0(react@19.3.0))(react@19.3.0))(@types/estree-jsx@1.0.5)(@types/hast@3.0.5)(@types/mdast@4.0.4)(@types/react@19.3.0)(lucide-react@1.52.0(@types/react@19.3.0)(react@19.3.0))(next@16.3.3(@babel/core@7.29.7)(@opentelemetry/api@1.9.1)(@playwright/test@1.61.1)(@types/node@26.6.4)(babel-plugin-react-compiler@1.0.0)(react-dom@19.3.0(react@19.3.0))(react@19.3.0))(react-dom@19.3.0(react@19.3.0))(react@19.3.0)(zod@4.6.5))(next@16.3.3(@babel/core@7.29.7)(@opentelemetry/api@1.9.1)(@playwright/test@1.61.1)(@types/node@26.6.4)(babel-plugin-react-compiler@1.0.0)(react-dom@19.3.0(react@19.3.0))(react@19.3.0))(react@19.3.0)(rolldown@1.2.13)(vite@8.3.3(@types/node@26.6.4)(esbuild@0.28.2)(jiti@2.7.0)(tsx@4.23.15)(yaml@2.9.1)): + fumadocs-mdx@15.4.6(@types/mdast@4.0.4)(@types/mdx@2.0.14)(@types/react@19.3.0)(fumadocs-core@16.16.2(@mdx-js/mdx@3.1.1)(@tanstack/react-router@1.170.41(react-dom@19.3.0(react@19.3.0))(react@19.3.0))(@types/estree-jsx@1.0.5)(@types/hast@3.0.5)(@types/mdast@4.0.4)(@types/react@19.3.0)(lucide-react@1.52.0(@types/react@19.3.0)(react@19.3.0))(next@16.3.8(@babel/core@7.29.7)(@opentelemetry/api@1.9.1)(@playwright/test@1.61.1)(@types/node@26.6.4)(babel-plugin-react-compiler@1.0.0)(react-dom@19.3.0(react@19.3.0))(react@19.3.0))(react-dom@19.3.0(react@19.3.0))(react@19.3.0)(zod@4.6.5))(next@16.3.8(@babel/core@7.29.7)(@opentelemetry/api@1.9.1)(@playwright/test@1.61.1)(@types/node@26.6.4)(babel-plugin-react-compiler@1.0.0)(react-dom@19.3.0(react@19.3.0))(react@19.3.0))(react@19.3.0)(rolldown@1.2.13)(vite@8.3.3(@types/node@26.6.4)(esbuild@0.28.2)(jiti@2.7.0)(tsx@4.23.15)(yaml@2.9.1)): dependencies: '@mdx-js/mdx': 3.1.1 '@standard-schema/spec': 1.1.0 chokidar: 5.0.0 esbuild: 0.28.2 estree-util-value-to-estree: 3.5.0 - fumadocs-core: 16.16.2(@mdx-js/mdx@3.1.1)(@tanstack/react-router@1.170.41(react-dom@19.3.0(react@19.3.0))(react@19.3.0))(@types/estree-jsx@1.0.5)(@types/hast@3.0.5)(@types/mdast@4.0.4)(@types/react@19.3.0)(lucide-react@1.52.0(@types/react@19.3.0)(react@19.3.0))(next@16.3.3(@babel/core@7.29.7)(@opentelemetry/api@1.9.1)(@playwright/test@1.61.1)(@types/node@26.6.4)(babel-plugin-react-compiler@1.0.0)(react-dom@19.3.0(react@19.3.0))(react@19.3.0))(react-dom@19.3.0(react@19.3.0))(react@19.3.0)(zod@4.6.5) + fumadocs-core: 16.16.2(@mdx-js/mdx@3.1.1)(@tanstack/react-router@1.170.41(react-dom@19.3.0(react@19.3.0))(react@19.3.0))(@types/estree-jsx@1.0.5)(@types/hast@3.0.5)(@types/mdast@4.0.4)(@types/react@19.3.0)(lucide-react@1.52.0(@types/react@19.3.0)(react@19.3.0))(next@16.3.8(@babel/core@7.29.7)(@opentelemetry/api@1.9.1)(@playwright/test@1.61.1)(@types/node@26.6.4)(babel-plugin-react-compiler@1.0.0)(react-dom@19.3.0(react@19.3.0))(react@19.3.0))(react-dom@19.3.0(react@19.3.0))(react@19.3.0)(zod@4.6.5) github-slugger: 2.0.0 magic-string: 1.4.3 mdast-util-mdx: 3.0.0 @@ -16040,14 +15635,14 @@ snapshots: '@types/mdast': 4.0.4 '@types/mdx': 2.0.14 '@types/react': 19.3.0 - next: 16.3.3(@babel/core@7.29.7)(@opentelemetry/api@1.9.1)(@playwright/test@1.61.1)(@types/node@26.6.4)(babel-plugin-react-compiler@1.0.0)(react-dom@19.3.0(react@19.3.0))(react@19.3.0) + next: 16.3.8(@babel/core@7.29.7)(@opentelemetry/api@1.9.1)(@playwright/test@1.61.1)(@types/node@26.6.4)(babel-plugin-react-compiler@1.0.0)(react-dom@19.3.0(react@19.3.0))(react@19.3.0) react: 19.3.0 rolldown: 1.2.13 vite: 8.3.3(@types/node@26.6.4)(esbuild@0.28.2)(jiti@2.7.0)(tsx@4.23.15)(yaml@2.9.1) transitivePeerDependencies: - supports-color - fumadocs-ui@16.16.2(@types/mdx@2.0.14)(@types/react-dom@19.3.0(@types/react@19.3.0))(@types/react@19.3.0)(fumadocs-core@16.16.2(@mdx-js/mdx@3.1.1)(@tanstack/react-router@1.170.41(react-dom@19.3.0(react@19.3.0))(react@19.3.0))(@types/estree-jsx@1.0.5)(@types/hast@3.0.5)(@types/mdast@4.0.4)(@types/react@19.3.0)(lucide-react@1.52.0(@types/react@19.3.0)(react@19.3.0))(next@16.3.3(@babel/core@7.29.7)(@opentelemetry/api@1.9.1)(@playwright/test@1.61.1)(@types/node@26.6.4)(babel-plugin-react-compiler@1.0.0)(react-dom@19.3.0(react@19.3.0))(react@19.3.0))(react-dom@19.3.0(react@19.3.0))(react@19.3.0)(zod@4.6.5))(next@16.3.3(@babel/core@7.29.7)(@opentelemetry/api@1.9.1)(@playwright/test@1.61.1)(@types/node@26.6.4)(babel-plugin-react-compiler@1.0.0)(react-dom@19.3.0(react@19.3.0))(react@19.3.0))(react-dom@19.3.0(react@19.3.0))(react@19.3.0)(tailwindcss@4.3.3): + fumadocs-ui@16.16.2(@types/mdx@2.0.14)(@types/react-dom@19.3.0(@types/react@19.3.0))(@types/react@19.3.0)(fumadocs-core@16.16.2(@mdx-js/mdx@3.1.1)(@tanstack/react-router@1.170.41(react-dom@19.3.0(react@19.3.0))(react@19.3.0))(@types/estree-jsx@1.0.5)(@types/hast@3.0.5)(@types/mdast@4.0.4)(@types/react@19.3.0)(lucide-react@1.52.0(@types/react@19.3.0)(react@19.3.0))(next@16.3.8(@babel/core@7.29.7)(@opentelemetry/api@1.9.1)(@playwright/test@1.61.1)(@types/node@26.6.4)(babel-plugin-react-compiler@1.0.0)(react-dom@19.3.0(react@19.3.0))(react@19.3.0))(react-dom@19.3.0(react@19.3.0))(react@19.3.0)(zod@4.6.5))(next@16.3.8(@babel/core@7.29.7)(@opentelemetry/api@1.9.1)(@playwright/test@1.61.1)(@types/node@26.6.4)(babel-plugin-react-compiler@1.0.0)(react-dom@19.3.0(react@19.3.0))(react@19.3.0))(react-dom@19.3.0(react@19.3.0))(react@19.3.0)(tailwindcss@4.3.3): dependencies: '@fuma-translate/react': 1.0.2(@types/react@19.3.0)(react-dom@19.3.0(react@19.3.0))(react@19.3.0) '@fumadocs/tailwind': 0.1.2(tailwindcss@4.3.3) @@ -16063,7 +15658,7 @@ snapshots: '@radix-ui/react-tabs': 1.1.22(@types/react-dom@19.3.0(@types/react@19.3.0))(@types/react@19.3.0)(react-dom@19.3.0(react@19.3.0))(react@19.3.0) class-variance-authority: 0.7.1 cn: 0.4.0 - fumadocs-core: 16.16.2(@mdx-js/mdx@3.1.1)(@tanstack/react-router@1.170.41(react-dom@19.3.0(react@19.3.0))(react@19.3.0))(@types/estree-jsx@1.0.5)(@types/hast@3.0.5)(@types/mdast@4.0.4)(@types/react@19.3.0)(lucide-react@1.52.0(@types/react@19.3.0)(react@19.3.0))(next@16.3.3(@babel/core@7.29.7)(@opentelemetry/api@1.9.1)(@playwright/test@1.61.1)(@types/node@26.6.4)(babel-plugin-react-compiler@1.0.0)(react-dom@19.3.0(react@19.3.0))(react@19.3.0))(react-dom@19.3.0(react@19.3.0))(react@19.3.0)(zod@4.6.5) + fumadocs-core: 16.16.2(@mdx-js/mdx@3.1.1)(@tanstack/react-router@1.170.41(react-dom@19.3.0(react@19.3.0))(react@19.3.0))(@types/estree-jsx@1.0.5)(@types/hast@3.0.5)(@types/mdast@4.0.4)(@types/react@19.3.0)(lucide-react@1.52.0(@types/react@19.3.0)(react@19.3.0))(next@16.3.8(@babel/core@7.29.7)(@opentelemetry/api@1.9.1)(@playwright/test@1.61.1)(@types/node@26.6.4)(babel-plugin-react-compiler@1.0.0)(react-dom@19.3.0(react@19.3.0))(react@19.3.0))(react-dom@19.3.0(react@19.3.0))(react@19.3.0)(zod@4.6.5) lucide-react: 1.52.0(@types/react@19.3.0)(react@19.3.0) motion: 14.0.0(react-dom@19.3.0(react@19.3.0))(react@19.3.0) next-themes: 0.4.6(react-dom@19.3.0(react@19.3.0))(react@19.3.0) @@ -16077,7 +15672,7 @@ snapshots: optionalDependencies: '@types/mdx': 2.0.14 '@types/react': 19.3.0 - next: 16.3.3(@babel/core@7.29.7)(@opentelemetry/api@1.9.1)(@playwright/test@1.61.1)(@types/node@26.6.4)(babel-plugin-react-compiler@1.0.0)(react-dom@19.3.0(react@19.3.0))(react@19.3.0) + next: 16.3.8(@babel/core@7.29.7)(@opentelemetry/api@1.9.1)(@playwright/test@1.61.1)(@types/node@26.6.4)(babel-plugin-react-compiler@1.0.0)(react-dom@19.3.0(react@19.3.0))(react@19.3.0) transitivePeerDependencies: - '@types/react-dom' - tailwindcss @@ -16360,7 +15955,7 @@ snapshots: domutils: 3.2.2 entities: 4.5.0 - http-cache-semantics@4.2.0: {} + http-cache-semantics@4.3.0: {} http-errors@2.0.1: dependencies: @@ -17336,9 +16931,9 @@ snapshots: react: 19.3.0 react-dom: 19.3.0(react@19.3.0) - next@16.3.3(@babel/core@7.29.7)(@opentelemetry/api@1.9.1)(@playwright/test@1.61.1)(@types/node@26.6.4)(babel-plugin-react-compiler@1.0.0)(react-dom@19.3.0(react@19.3.0))(react@19.3.0): + next@16.3.8(@babel/core@7.29.7)(@opentelemetry/api@1.9.1)(@playwright/test@1.61.1)(@types/node@26.6.4)(babel-plugin-react-compiler@1.0.0)(react-dom@19.3.0(react@19.3.0))(react@19.3.0): dependencies: - '@next/env': 16.3.3 + '@next/env': 16.3.8 '@swc/helpers': 0.5.23 baseline-browser-mapping: 2.11.27 caniuse-lite: 1.0.30001815 @@ -17347,14 +16942,14 @@ snapshots: react-dom: 19.3.0(react@19.3.0) styled-jsx: 5.1.6(@babel/core@7.29.7)(react@19.3.0) optionalDependencies: - '@next/swc-darwin-arm64': 16.3.3 - '@next/swc-darwin-x64': 16.3.3 - '@next/swc-linux-arm64-gnu': 16.3.3 - '@next/swc-linux-arm64-musl': 16.3.3 - '@next/swc-linux-x64-gnu': 16.3.3 - '@next/swc-linux-x64-musl': 16.3.3 - '@next/swc-win32-arm64-msvc': 16.3.3 - '@next/swc-win32-x64-msvc': 16.3.3 + '@next/swc-darwin-arm64': 16.3.8 + '@next/swc-darwin-x64': 16.3.8 + '@next/swc-linux-arm64-gnu': 16.3.8 + '@next/swc-linux-arm64-musl': 16.3.8 + '@next/swc-linux-x64-gnu': 16.3.8 + '@next/swc-linux-x64-musl': 16.3.8 + '@next/swc-win32-arm64-msvc': 16.3.8 + '@next/swc-win32-x64-msvc': 16.3.8 '@opentelemetry/api': 1.9.1 '@playwright/test': 1.61.1 babel-plugin-react-compiler: 1.0.0 @@ -18083,7 +17678,7 @@ snapshots: react: 19.3.0 react-dom: 19.3.0(react@19.3.0) - react-scan@0.5.7(@opentelemetry/core@2.12.0(@opentelemetry/api@1.9.1))(esbuild@0.27.7)(eslint@10.11.0(jiti@2.7.0))(oxlint-tsgolint@7.0.2003)(react-dom@19.3.0(react@19.3.0))(react@19.3.0)(rolldown@1.2.12)(rollup@4.63.5)(vite@8.3.3(@types/node@26.6.4)(esbuild@0.27.7)(jiti@2.7.0)(tsx@4.23.15)(yaml@2.9.1)): + react-scan@0.5.7(@opentelemetry/core@2.12.0(@opentelemetry/api@1.9.1))(esbuild@0.28.2)(eslint@10.11.0(jiti@2.7.0))(oxlint-tsgolint@7.0.2003)(react-dom@19.3.0(react@19.3.0))(react@19.3.0)(rolldown@1.2.12)(rollup@4.63.5)(vite@8.3.3(@types/node@26.6.4)(esbuild@0.28.2)(jiti@2.7.0)(tsx@4.23.15)(yaml@2.9.1)): dependencies: '@babel/core': 7.29.7 '@babel/types': 7.29.8 @@ -18099,8 +17694,8 @@ snapshots: react-dom: 19.3.0(react@19.3.0) react-grab: 0.2.0(react@19.3.0) optionalDependencies: - esbuild: 0.27.7 - unplugin: 3.4.0(esbuild@0.27.7)(rolldown@1.2.12)(rollup@4.63.5)(vite@8.3.3(@types/node@26.6.4)(esbuild@0.27.7)(jiti@2.7.0)(tsx@4.23.15)(yaml@2.9.1)) + esbuild: 0.28.2 + unplugin: 3.4.0(esbuild@0.28.2)(rolldown@1.2.12)(rollup@4.63.5)(vite@8.3.3(@types/node@26.6.4)(esbuild@0.28.2)(jiti@2.7.0)(tsx@4.23.15)(yaml@2.9.1)) transitivePeerDependencies: - '@farmfe/core' - '@opentelemetry/core' @@ -18933,12 +18528,12 @@ snapshots: tsup@8.5.1(@swc/core@1.16.13(@swc/helpers@0.5.23))(jiti@2.7.0)(postcss@8.5.29)(tsx@4.23.15)(typescript@6.0.3)(yaml@2.9.1): dependencies: - bundle-require: 5.1.0(esbuild@0.27.7) + bundle-require: 5.1.0(esbuild@0.28.2) cac: 6.7.14 chokidar: 4.0.3 consola: 3.4.2 debug: 4.4.3 - esbuild: 0.27.7 + esbuild: 0.28.2 fix-dts-default-cjs-exports: 1.0.1 joycon: 3.1.1 picocolors: 1.1.1 @@ -19075,29 +18670,18 @@ snapshots: unpipe@1.0.0: {} - unplugin@3.4.0(esbuild@0.27.7)(rolldown@1.2.12)(rollup@4.63.5)(vite@8.3.3(@types/node@26.6.4)(esbuild@0.27.7)(jiti@2.7.0)(tsx@4.23.15)(yaml@2.9.1)): + unplugin@3.4.0(esbuild@0.28.2)(rolldown@1.2.12)(rollup@4.63.5)(vite@8.3.3(@types/node@26.6.4)(esbuild@0.28.2)(jiti@2.7.0)(tsx@4.23.15)(yaml@2.9.1)): dependencies: '@jridgewell/remapping': 2.3.5 picomatch: 4.0.7 webpack-virtual-modules: 0.6.2 optionalDependencies: - esbuild: 0.27.7 + esbuild: 0.28.2 rolldown: 1.2.12 rollup: 4.63.5 - vite: 8.3.3(@types/node@26.6.4)(esbuild@0.27.7)(jiti@2.7.0)(tsx@4.23.15)(yaml@2.9.1) + vite: 8.3.3(@types/node@26.6.4)(esbuild@0.28.2)(jiti@2.7.0)(tsx@4.23.15)(yaml@2.9.1) optional: true - unplugin@3.4.0(esbuild@0.27.7)(rolldown@1.2.13)(rollup@4.63.5)(vite@8.3.3(@types/node@26.6.4)(esbuild@0.27.7)(jiti@2.7.0)(tsx@4.23.15)(yaml@2.9.1)): - dependencies: - '@jridgewell/remapping': 2.3.5 - picomatch: 4.0.7 - webpack-virtual-modules: 0.6.2 - optionalDependencies: - esbuild: 0.27.7 - rolldown: 1.2.13 - rollup: 4.63.5 - vite: 8.3.3(@types/node@26.6.4)(esbuild@0.27.7)(jiti@2.7.0)(tsx@4.23.15)(yaml@2.9.1) - unplugin@3.4.0(esbuild@0.28.2)(rolldown@1.2.13)(rollup@4.63.5)(vite@8.3.3(@types/node@26.6.4)(esbuild@0.28.2)(jiti@2.7.0)(tsx@4.23.15)(yaml@2.9.1)): dependencies: '@jridgewell/remapping': 2.3.5 @@ -19177,21 +18761,6 @@ snapshots: '@types/unist': 3.0.3 vfile-message: 4.0.3 - vite@8.3.3(@types/node@26.6.4)(esbuild@0.27.7)(jiti@2.7.0)(tsx@4.23.15)(yaml@2.9.1): - dependencies: - lightningcss: 1.33.0 - picomatch: 4.0.7 - postcss: 8.5.29 - rolldown: 1.2.13 - tinyglobby: 0.2.17 - optionalDependencies: - '@types/node': 26.6.4 - esbuild: 0.27.7 - fsevents: 2.3.3 - jiti: 2.7.0 - tsx: 4.23.15 - yaml: 2.9.1 - vite@8.3.3(@types/node@26.6.4)(esbuild@0.28.2)(jiti@2.7.0)(tsx@4.23.15)(yaml@2.9.1): dependencies: lightningcss: 1.33.0 @@ -19207,38 +18776,10 @@ snapshots: tsx: 4.23.15 yaml: 2.9.1 - vitefu@1.1.3(vite@8.3.3(@types/node@26.6.4)(esbuild@0.27.7)(jiti@2.7.0)(tsx@4.23.15)(yaml@2.9.1)): - optionalDependencies: - vite: 8.3.3(@types/node@26.6.4)(esbuild@0.27.7)(jiti@2.7.0)(tsx@4.23.15)(yaml@2.9.1) - vitefu@1.1.3(vite@8.3.3(@types/node@26.6.4)(esbuild@0.28.2)(jiti@2.7.0)(tsx@4.23.15)(yaml@2.9.1)): optionalDependencies: vite: 8.3.3(@types/node@26.6.4)(esbuild@0.28.2)(jiti@2.7.0)(tsx@4.23.15)(yaml@2.9.1) - vitest@5.0.3(@opentelemetry/api@1.9.1)(@types/node@26.6.4)(@vitest/coverage-v8@5.0.3)(jsdom@30.1.2)(vite@8.3.3(@types/node@26.6.4)(esbuild@0.27.7)(jiti@2.7.0)(tsx@4.23.15)(yaml@2.9.1)): - dependencies: - '@types/chai': 5.2.3 - '@vitest/mocker': 5.0.3(vite@8.3.3(@types/node@26.6.4)(esbuild@0.27.7)(jiti@2.7.0)(tsx@4.23.15)(yaml@2.9.1)) - chai: 6.2.2 - es-module-lexer: 2.3.2 - expect-type: 1.4.0 - magic-string: 1.4.2 - obug: 2.2.1 - picomatch: 4.0.7 - std-env: 4.3.0 - tinybench: 6.2.0 - tinyexec: 1.3.1 - tinyglobby: 0.2.17 - vite: 8.3.3(@types/node@26.6.4)(esbuild@0.27.7)(jiti@2.7.0)(tsx@4.23.15)(yaml@2.9.1) - why-is-node-running: 3.2.1 - optionalDependencies: - '@opentelemetry/api': 1.9.1 - '@types/node': 26.6.4 - '@vitest/coverage-v8': 5.0.3(vitest@5.0.3) - jsdom: 30.1.2 - transitivePeerDependencies: - - msw - vitest@5.0.3(@opentelemetry/api@1.9.1)(@types/node@26.6.4)(@vitest/coverage-v8@5.0.3)(jsdom@30.1.2)(vite@8.3.3(@types/node@26.6.4)(esbuild@0.28.2)(jiti@2.7.0)(tsx@4.23.15)(yaml@2.9.1)): dependencies: '@types/chai': 5.2.3 diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index dc95d12c2c..e7ee1177d7 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -11,3 +11,19 @@ packageExtensions: '@tiptap/y-tiptap': dependencies: prosemirror-transform: 1.12.1 +# Security patches for transitive dependencies. Each selector only matches the +# vulnerable range, so an entry stops applying once its dependents move on and +# can then be deleted. +overrides: + # GHSA-vcvr-r3jv-pc5j. `@react-email/ui` pins `next@16.3.3`; fumadocs peers on it. + 'next@>=16.2.0 <16.3.6': ~16.3.8 + # GHSA-p6vx-979v-rg4c, GHSA-jp82-f5mq-hwhp. solid-js 1.9.15 asks for + # `seroval@~1.5.4`. 1.9.16 moves to `~1.6.8` with no change to how it calls + # seroval, but it is still inside `minimumReleaseAge`; drop these two entries + # once solid-js 1.9.16 resolves on its own. + 'seroval@<1.6.3': ~1.6.8 + 'seroval-plugins@<1.6.3': ~1.6.8 + # GHSA-g7r4-m6w7-qqqr (Windows dev server). tsup still asks for `^0.27.0`. + 'esbuild@>=0.27.3 <0.28.1': ^0.28.2 + # GHSA-ch52-4w7c-c8xp. Dev only: @swc/cli > got > cacheable-request (`^4.2.0`). + 'http-cache-semantics@<4.2.1': ^4.3.0 From c517ebecde93c921f2c326777628a7f9ad7c56dd Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 7 Oct 2026 07:22:31 +0000 Subject: [PATCH 09/25] =?UTF-8?q?feat(auth):=20=F0=9F=94=92=20enforce=20em?= =?UTF-8?q?ail=20verification=20and=20harden=20sign-in=20responses?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Email verification (was never sent nor enforced): - Sign-up on an install with an email adapter now emails a 24-hour, single-use confirmation link (32 random bytes, base64url; only the SHA-256 digest is stored in core_users_confirm_emails). - POST /users/verify-email spends the link atomically, marks the account verified, emits user.email.verified and does not start a session. - POST /users/verify-email/resend (captcha-gated, 5-minute cooldown) answers the same 201 for every address; 404 for everyone without an email adapter. - Password and public passkey sign-in answer 403 email_not_verified for an unconfirmed account, only after the credential is proven and only when an email adapter is configured. AdminCP sign-in is not gated. - Completing a password reset and the AdminCP "Verify Email" action also confirm the address; the clean cron drops expired links. - New /login/verify-email page (confirm button + resend form), sign-in form message with a resend link, react-email template, en/pl strings, docs and the built-in events catalogue. Session tokens no longer appear in JSON bodies: sign-in (public and AdminCP), the SSO callback and SSO link answer { id } only. Password reset no longer reveals account existence on installs without an email adapter: it answers 404 for every address before the lookup, instead of 201 for unknown and 500 for known addresses. Credential strings are bounded: email/name max 255, passwords max 1024 on sign-up, sign-in, reset, change-password and AdminCP create/password. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01BcTiLrsGK6BGwvs4uieRMB --- apps/web/content/docs/dev/advanced/auth.mdx | 3 +- .../docs/dev/events/built-in-events.mdx | 26 + .../working-with-users/email-verification.mdx | 89 ++++ .../docs/dev/working-with-users/meta.json | 2 +- apps/web/src/locales/@vitnode/core/pl.json | 50 +- .../src/api/models/email-verification.ts | 173 +++++++ packages/vitnode/src/api/models/events.ts | 4 + .../api/models/user/sign-in-with-passwords.ts | 7 +- .../admin/users/routes/create.route.ts | 10 +- .../admin/users/routes/password.route.ts | 7 +- .../admin/users/routes/verify-email.route.ts | 17 +- .../src/api/modules/cron/cron/clean.cron.ts | 13 +- .../api/modules/users/credential-limits.ts | 16 + .../modules/users/passkeys/passkeys.test.ts | 33 ++ .../routes/authentication-verify.route.ts | 22 +- .../src/api/modules/users/passkeys/schema.ts | 1 + .../users/routes/change-password.route.ts | 24 +- .../routes/credentials-hardening.test.ts | 251 ++++++++++ .../users/routes/email-verification.test.ts | 474 ++++++++++++++++++ .../users/routes/resend-verification.route.ts | 86 ++++ .../users/routes/reset-passowrd.route.ts | 30 +- .../api/modules/users/routes/sign-in.route.ts | 44 +- .../api/modules/users/routes/sign-up.route.ts | 29 +- .../users/routes/verify-email.route.ts | 61 +++ .../users/sso/routes/callback.route.ts | 8 +- .../modules/users/sso/routes/callback.test.ts | 3 +- .../modules/users/sso/routes/link.route.ts | 14 +- .../src/api/modules/users/users.module.ts | 4 + packages/vitnode/src/emails/verify-email.tsx | 149 ++++++ packages/vitnode/src/locales/api/en.json | 13 + packages/vitnode/src/locales/en.json | 39 +- .../vitnode/src/pages/login/verify-email.tsx | 35 ++ packages/vitnode/src/routes.tsx | 13 + packages/vitnode/src/tanstack/auth/actions.ts | 22 + .../vitnode/src/tanstack/auth/contract.ts | 104 +++- .../src/tanstack/auth/default-transport.ts | 21 + .../tanstack/auth/email-verification.test.ts | 182 +++++++ packages/vitnode/src/tanstack/auth/index.ts | 7 + packages/vitnode/src/tanstack/auth/screens.ts | 27 +- packages/vitnode/src/tanstack/auth/server.ts | 27 +- .../src/tanstack/auth/session-query.test.ts | 2 + .../auth/transport-operations.test.ts | 2 + .../src/tanstack/auth/transport-operations.ts | 49 +- .../src/tanstack/auth/transport.test.ts | 2 + .../vitnode/src/tanstack/auth/transport.ts | 8 + .../src/tanstack/auth/verify-email-route.ts | 34 ++ .../src/tanstack/auth/verify-email-screen.tsx | 47 ++ .../vitnode/src/tanstack/auth/verify-email.ts | 65 +++ packages/vitnode/src/views/auth/auth-link.ts | 1 + .../src/views/auth/sign-in/form/schema.ts | 6 +- .../sign-in/form/sign-in-form-content.tsx | 21 +- .../auth/sign-in/form/use-sign-in-form.ts | 5 + .../passkey/passkey-sign-in-button.tsx | 1 + .../auth/sign-up/email-confirmation-view.tsx | 22 +- .../confirm-email-content.test.tsx | 100 ++++ .../verify-email/confirm-email-content.tsx | 172 +++++++ .../resend-verification-form-content.tsx | 141 ++++++ .../src/views/auth/verify-email/schema.ts | 39 ++ .../verify-email/verify-email-content.tsx | 11 + 59 files changed, 2801 insertions(+), 67 deletions(-) create mode 100644 apps/web/content/docs/dev/working-with-users/email-verification.mdx create mode 100644 packages/vitnode/src/api/models/email-verification.ts create mode 100644 packages/vitnode/src/api/modules/users/credential-limits.ts create mode 100644 packages/vitnode/src/api/modules/users/routes/credentials-hardening.test.ts create mode 100644 packages/vitnode/src/api/modules/users/routes/email-verification.test.ts create mode 100644 packages/vitnode/src/api/modules/users/routes/resend-verification.route.ts create mode 100644 packages/vitnode/src/api/modules/users/routes/verify-email.route.ts create mode 100644 packages/vitnode/src/emails/verify-email.tsx create mode 100644 packages/vitnode/src/pages/login/verify-email.tsx create mode 100644 packages/vitnode/src/tanstack/auth/email-verification.test.ts create mode 100644 packages/vitnode/src/tanstack/auth/verify-email-route.ts create mode 100644 packages/vitnode/src/tanstack/auth/verify-email-screen.tsx create mode 100644 packages/vitnode/src/tanstack/auth/verify-email.ts create mode 100644 packages/vitnode/src/views/auth/verify-email/confirm-email-content.test.tsx create mode 100644 packages/vitnode/src/views/auth/verify-email/confirm-email-content.tsx create mode 100644 packages/vitnode/src/views/auth/verify-email/resend-verification-form-content.tsx create mode 100644 packages/vitnode/src/views/auth/verify-email/schema.ts create mode 100644 packages/vitnode/src/views/auth/verify-email/verify-email-content.tsx diff --git a/apps/web/content/docs/dev/advanced/auth.mdx b/apps/web/content/docs/dev/advanced/auth.mdx index 4c4ff15807..02b712a475 100644 --- a/apps/web/content/docs/dev/advanced/auth.mdx +++ b/apps/web/content/docs/dev/advanced/auth.mdx @@ -57,9 +57,10 @@ The AdminCP is the keys to the kingdom, so its session is deliberately short-liv ## Security Guarantees -- **SHA-256 Token Storage**: The raw token is stored only in the user's `HttpOnly` cookie. The database stores only its cryptographic hash. +- **SHA-256 Token Storage**: The raw token is stored only in the user's `HttpOnly` cookie. The database stores only its cryptographic hash. Sign-in, SSO and passkey answers carry the user id only - page scripts never see the token. - **Device Pinning**: Tokens are bound to a verified device ID. Tokens copied to another device without the matching device cookie are rejected. - **Fast 60-Second Caching**: Active sessions are cached in Redis for up to 60 seconds, eliminating database query overhead on repeated requests. +- **Confirmed Email Addresses**: With an email adapter, members confirm their address before their first sign-in. See [Email Verification](/docs/dev/working-with-users/email-verification). - **Canonical Email Identity**: Addresses are stored in their canonical form, so Gmail dot, `googlemail.com` and `+tag` variants cannot become a second account. See [One Mailbox, One Account](#one-mailbox-one-account). --- diff --git a/apps/web/content/docs/dev/events/built-in-events.mdx b/apps/web/content/docs/dev/events/built-in-events.mdx index a8f984a2e6..4f1f64b72a 100644 --- a/apps/web/content/docs/dev/events/built-in-events.mdx +++ b/apps/web/content/docs/dev/events/built-in-events.mdx @@ -47,6 +47,7 @@ the write it describes has committed. | `user.created` | `{ userId, email, name, emailVerified }` | A user row is inserted - public sign-up, AdminCP creation, or SSO sign-up | | `user.updated` | `{ userId, email, name }` | A user is edited in the AdminCP, or a member saves their own profile or time zone in **Settings → Overview** | | `user.deleted` | `{ userId, email }` | Never - the name is declared for plugins, core has no deletion flow | +| `user.email.verified` | `{ userId, email }` | An address is [confirmed](/docs/dev/working-with-users/email-verification) - by its link, a completed reset, or by staff | | `user.password.updated` | `{ userId }` | A password is set - by a member completing a reset, or by staff on the AdminCP user page | | `user.sessions.revoked` | `{ userId, deviceId, sessions }` | A user is signed out remotely - one device or all of them, by the member or by staff, or after a password change | | `user.sso.linked` | `{ userId, email, providerId }` | An SSO identity is linked to an existing account - at login with its password, or from **Settings → Connected accounts** | @@ -270,6 +271,31 @@ avatar that failed to download never counts. A name change also emits **A listener would** refresh a search index or a cached member card. + + + +Emitted once an unconfirmed address becomes confirmed: when a member clicks +the link from their sign-up email, when they complete a password reset (the +reset link went to the same mailbox), or when staff press **Verify Email** on +the AdminCP user page. A reset on an account that was already confirmed emits +nothing here. See +[Email Verification](/docs/dev/working-with-users/email-verification). + + + +**A listener would** send a welcome email or unlock a "verified member" badge. + diff --git a/apps/web/content/docs/dev/working-with-users/email-verification.mdx b/apps/web/content/docs/dev/working-with-users/email-verification.mdx new file mode 100644 index 0000000000..f82b5ac455 --- /dev/null +++ b/apps/web/content/docs/dev/working-with-users/email-verification.mdx @@ -0,0 +1,89 @@ +--- +title: Email Verification +description: How VitNode confirms a new member's email address before they can sign in, and the API routes and events behind it. +icon: MailCheck +--- + +import { TypeTable } from 'fumadocs-ui/components/type-table' + +When your install has an [email adapter](/docs/dev/email), every new member has to click a confirmation link before they can sign in. Without one, there is nobody to send the link, so accounts are confirmed the moment they are created and nothing changes. + +That stops the oldest trick in the book: signing up with somebody else's address and using the account straight away. + +## What a member sees + +1. They register at `/register`. Instead of being signed in, they get a "Check your email" card. +2. The email holds a link to `/login/verify-email?token=...`, valid for **24 hours**. +3. That page asks them to click **Confirm email address**. The page never confirms on load, because mail scanners and link previews open every URL in an email. +4. Once confirmed, they sign in with their password as usual. + +If they try to sign in first, the sign-in form says **Confirm your email first** and offers **Send a new confirmation link**, which opens the resend form with their address already filled in. + + + The "not confirmed" message only appears after the password has been checked. + A wrong password, an unknown address and an unconfirmed account with a wrong + password all get the same `403`, so the sign-in form can't be used to find out + which addresses are registered. + + +## The rules + +- **Password sign-in** answers `403 { "error": "email_not_verified" }` for an unconfirmed account. No session is created. +- **Passkey sign-in** follows the same rule, with the same error code. +- **AdminCP sign-in** at `/admin` is not blocked. Only staff get in, and staff are picked by an administrator, so an install that just added an email adapter can't lock its admins out. +- **SSO** sign-ups are confirmed already, since the provider vouches for the address. +- **Completing a password reset** also confirms the address - the reset link went to the same mailbox. +- **Staff** can confirm anyone by hand from the AdminCP user page. + + + Members who registered while an adapter was configured but before this feature + existed were never sent a link, so they are still unconfirmed. They can use + **Send a new confirmation link** on the sign-in form, reset their password, or + a staff member can confirm them from the AdminCP. + + +## API routes + +All routes live under `/api/@vitnode/core/users`. + +| Method | Path | Body | Answers | +| :----- | :--------------------- | :------------------------ | :---------------------------------------------------------- | +| `POST` | `/verify-email` | `{ token }` | `200 { email }`, or `400` for a wrong, used or expired link | +| `POST` | `/verify-email/resend` | `{ email }`, plus captcha | `201` for every address; `404` with no email adapter | + +`/verify-email/resend` answers the same `201` for an unknown address, a confirmed one and one waiting for confirmation, so it can't be used to check who has an account. It only sends when the account still needs confirming, and only once every five minutes. A new link replaces the old one. + +Call them through the [fetcher](/docs/dev/fetcher) like any other core route: + +```ts title="src/lib/confirm-email.ts" +import { fetcher } from '@vitnode/core/tanstack/fetcher' + +const response = await fetcher({ + plugin: '@vitnode/core', + method: 'post', + module: 'users', + path: '/verify-email', + args: { body: { token } }, +}) +``` + +Confirming a link does not sign anybody in. Whoever clicks it owns the mailbox, but not necessarily the account - if a stranger registered with your address, your click must not drop you into their session. + +## How links are stored + +Links live in `core_users_confirm_emails`, one row per account: + +- The token is 32 random bytes, base64url-encoded. Only its SHA-256 digest is stored, so a leaked database dump can't confirm anything. +- A link works once. Spending it deletes the row in the same statement that reads it, so two clicks racing each other can't both win. +- The hourly `clean` [cron job](/docs/dev/cron) deletes expired rows. + +## Events + +Confirming an address emits `user.email.verified` with `{ userId, email }` - from the link, from a completed password reset, or from staff in the AdminCP. See [built-in events](/docs/dev/events/built-in-events). + + diff --git a/apps/web/content/docs/dev/working-with-users/meta.json b/apps/web/content/docs/dev/working-with-users/meta.json index 925421425e..e79ec0b403 100644 --- a/apps/web/content/docs/dev/working-with-users/meta.json +++ b/apps/web/content/docs/dev/working-with-users/meta.json @@ -2,5 +2,5 @@ "title": "Working with Users", "description": "The user model, roles, and the staff permission system - how VitNode decides who somebody is and what they are allowed to do.", "icon": "Users", - "pages": ["users", "roles", "staff-permissions", "..."] + "pages": ["users", "email-verification", "roles", "staff-permissions", "..."] } diff --git a/apps/web/src/locales/@vitnode/core/pl.json b/apps/web/src/locales/@vitnode/core/pl.json index 5572360a7e..7ab3a5554c 100644 --- a/apps/web/src/locales/@vitnode/core/pl.json +++ b/apps/web/src/locales/@vitnode/core/pl.json @@ -1897,6 +1897,11 @@ "access_denied": { "desc": "Adres e-mail lub hasło są nieprawidłowe. Spróbuj ponownie i sprawdź, czy nie masz włączonego Caps Locka.", "title": "Nieprawidłowe dane logowania" + }, + "email_not_verified": { + "title": "Najpierw potwierdź swój e-mail", + "desc": "Hasło jest poprawne, ale adres e-mail tego konta nie został jeszcze potwierdzony. Kliknij link, który wysłaliśmy, i zaloguj się ponownie.", + "resend": "Wyślij nowy link potwierdzający" } }, "passkey": { @@ -1929,6 +1934,10 @@ "not_staff": { "title": "Brak dostępu do AdminCP", "desc": "Klucz dostępu jest prawidłowy, ale to konto nie może otworzyć AdminCP. Jeśli uważasz, że to pomyłka, skontaktuj się z administratorem." + }, + "email_not_verified": { + "title": "Najpierw potwierdź swój e-mail", + "desc": "Adres e-mail tego konta nie został jeszcze potwierdzony. Kliknij link, który wysłaliśmy, i spróbuj ponownie." } }, "unsupported_hint": "Ta przeglądarka nie obsługuje kluczy dostępu. Zaloguj się adresem e-mail i hasłem." @@ -1955,7 +1964,8 @@ "email_confirmation": { "check_spam": "Jeśli nie widzisz wiadomości w skrzynce odbiorczej, sprawdź folder ze spamem.", "desc": "Wysłaliśmy link potwierdzający na Twój adres e-mail", - "title": "Sprawdź swoją skrzynkę" + "title": "Sprawdź swoją skrzynkę", + "resend": "Nie dotarł? Wyślij nowy link." }, "newsletter": { "desc": "Otrzymuj najnowsze informacje i aktualizacje.", @@ -2028,6 +2038,44 @@ "connection": { "pending": "Kończymy z {provider}…" } + }, + "verify_email": { + "title": "Potwierdź swój e-mail", + "confirm": { + "desc": "Już prawie! Potwierdź, że to Twój adres e-mail, a Twoje konto będzie gotowe.", + "submit": "Potwierdź adres e-mail" + }, + "success": { + "title": "E-mail potwierdzony", + "desc": "Dzięki! Adres jest potwierdzony - możesz się teraz zalogować.", + "sign_in": "Przejdź do logowania" + }, + "invalid": { + "title": "Ten link już nie działa", + "desc": "Mógł wygasnąć lub został już użyty. Poproś o nowy, a od razu go wyślemy.", + "resend": "Wyślij nowy link" + }, + "resend": { + "title": "Wyślij ponownie e-mail z potwierdzeniem", + "desc": "Podaj adres e-mail, którego użyłeś przy rejestracji, a wyślemy Ci nowy link potwierdzający.", + "submit": "Wyślij link potwierdzający", + "confirmation": { + "title": "Sprawdź swoją skrzynkę", + "desc": "Jeśli ten adres należy do konta czekającego na potwierdzenie, nowy link jest już w drodze na:", + "check_spam": "Jeśli nie widzisz wiadomości w skrzynce odbiorczej, sprawdź folder ze spamem." + } + }, + "email": { + "subject": "Potwierdź swój adres e-mail", + "greeting": "Witaj {name}!", + "intro": "Dziękujemy za rejestrację! Potwierdź, że to Twój adres e-mail, aby dokończyć zakładanie konta.", + "button": "Potwierdź adres e-mail", + "instructions": "Kliknij powyższy przycisk, aby potwierdzić swój adres e-mail. Zalogujesz się, gdy tylko zostanie potwierdzony.", + "no_action": "Jeśli nie zakładałeś konta, możesz bezpiecznie zignorować tę wiadomość - nikt nie zaloguje się tym adresem, dopóki nie zostanie potwierdzony.", + "security_note": "Ze względów bezpieczeństwa: to żądanie zostało wysłane z adresu IP: {ip}", + "help": "Jeśli masz problem z powyższym przyciskiem, skopiuj i wklej poniższy adres URL do przeglądarki:", + "expire_time": "Ten link wygaśnie {date}" + } } }, "editor": { diff --git a/packages/vitnode/src/api/models/email-verification.ts b/packages/vitnode/src/api/models/email-verification.ts new file mode 100644 index 0000000000..85b451d31a --- /dev/null +++ b/packages/vitnode/src/api/models/email-verification.ts @@ -0,0 +1,173 @@ +import type { Context } from "hono"; + +import { and, eq, gt } from "drizzle-orm"; +import crypto from "node:crypto"; +import { createTranslator } from "use-intl"; + +import { core_users, core_users_confirm_emails } from "@/database/users"; +import VerifyEmailTemplate from "@/emails/verify-email"; +import { CONFIG } from "@/lib/config"; + +import { invalidateSessionCacheForUser } from "./session-revoke"; + +/** How long a confirmation link stays usable. */ +export const EMAIL_VERIFICATION_TTL_MS = 1000 * 60 * 60 * 24; + +/** How long a resend request is ignored after a link was issued. */ +export const EMAIL_VERIFICATION_RESEND_COOLDOWN_MS = 1000 * 60 * 5; + +/** The error body every refused sign-in of an unconfirmed account carries. */ +export const EMAIL_NOT_VERIFIED = "email_not_verified"; + +/** + * Whether this install asks members to confirm their address. + * + * Only an install that can send email can ask - without an adapter sign-up + * marks every account as confirmed, so there is nothing left to enforce. + */ +export const isEmailVerificationRequired = (c: Context): boolean => + Boolean(c.get("core").email?.adapter); + +/** `true` when this account must confirm its address before it signs in. */ +export const mustVerifyEmail = ( + c: Context, + user: { emailVerified: boolean }, +): boolean => isEmailVerificationRequired(c) && !user.emailVerified; + +export interface EmailVerificationRecipient { + email: string; + id: number; + language: string; + name: string; +} + +export class EmailVerificationModel { + constructor(c: Context) { + this.c = c; + } + + protected readonly c: Context; + + generateToken(): string { + return crypto.randomBytes(32).toString("base64url"); + } + + hashToken(token: string): string { + return crypto.createHash("sha256").update(token).digest("hex"); + } + + /** + * Replaces the account's confirmation link with a fresh one and emails it. + * + * Only the digest is stored - the raw token travels in the email and nowhere + * else, exactly like a password reset link. With `respectCooldown` a link + * issued in the last five minutes is left alone and nothing is sent, which is + * what keeps the public resend endpoint from becoming a mail cannon. + * + * @returns whether an email was sent. + */ + async send( + user: EmailVerificationRecipient, + { respectCooldown = false }: { respectCooldown?: boolean } = {}, + ): Promise { + const db = this.c.get("db"); + + if (respectCooldown) { + const issued = await db + .select({ createdAt: core_users_confirm_emails.createdAt }) + .from(core_users_confirm_emails) + .where(eq(core_users_confirm_emails.userId, user.id)); + const cooldownStart = Date.now() - EMAIL_VERIFICATION_RESEND_COOLDOWN_MS; + + if (issued.some(row => row.createdAt.getTime() > cooldownStart)) { + return false; + } + } + + const token = this.generateToken(); + const expiresAt = new Date(Date.now() + EMAIL_VERIFICATION_TTL_MS); + + await db.transaction(async tx => { + await tx + .delete(core_users_confirm_emails) + .where(eq(core_users_confirm_emails.userId, user.id)); + await tx.insert(core_users_confirm_emails).values({ + userId: user.id, + token: this.hashToken(token), + expiresAt, + ipAddress: this.c.get("ipAddress"), + }); + }); + + const verifyUrl = new URL( + `login/verify-email?token=${encodeURIComponent(token)}`, + CONFIG.web.href, + ); + + await this.c.get("email").send({ + user: { + id: user.id, + email: user.email, + language: user.language, + name: user.name, + }, + content: props => + VerifyEmailTemplate({ + ...props, + verifyUrl: verifyUrl.href, + expiryDate: expiresAt, + userIpAddress: this.c.get("ipAddress"), + }), + subject: ({ i18n }) => + createTranslator(i18n)("core.auth.verify_email.email.subject"), + }); + + return true; + } + + /** + * Spends a confirmation link and marks its account as confirmed. + * + * The row is deleted and read back in one statement, so two clicks racing + * each other cannot both succeed - the link is single-use by construction, + * not by a check that could be interleaved. + * + * @returns the confirmed account, or `null` for a wrong, spent or expired link. + */ + async verify(token: string): Promise { + const db = this.c.get("db"); + + const [spent] = await db + .delete(core_users_confirm_emails) + .where( + and( + eq(core_users_confirm_emails.token, this.hashToken(token)), + gt(core_users_confirm_emails.expiresAt, new Date()), + ), + ) + .returning({ userId: core_users_confirm_emails.userId }); + + if (!spent) return null; + + const [user] = await db + .update(core_users) + .set({ emailVerified: true }) + .where(eq(core_users.id, spent.userId)) + .returning({ email: core_users.email, id: core_users.id }); + + // Any link still out there for this account has nothing left to confirm. + await db + .delete(core_users_confirm_emails) + .where(eq(core_users_confirm_emails.userId, spent.userId)); + + if (!user) return null; + + await invalidateSessionCacheForUser(this.c, user.id); + await this.c.get("events").emit("user.email.verified", { + email: user.email, + userId: user.id, + }); + + return user; + } +} diff --git a/packages/vitnode/src/api/models/events.ts b/packages/vitnode/src/api/models/events.ts index 34475a4606..6c75ebb158 100644 --- a/packages/vitnode/src/api/models/events.ts +++ b/packages/vitnode/src/api/models/events.ts @@ -98,6 +98,10 @@ export interface VitNodeEvents { email: string; userId: number; }; + "user.email.verified": { + email: string; + userId: number; + }; "user.passkey.created": { passkeyId: number; userId: number; diff --git a/packages/vitnode/src/api/models/user/sign-in-with-passwords.ts b/packages/vitnode/src/api/models/user/sign-in-with-passwords.ts index 2e6579be47..f0698bd308 100644 --- a/packages/vitnode/src/api/models/user/sign-in-with-passwords.ts +++ b/packages/vitnode/src/api/models/user/sign-in-with-passwords.ts @@ -21,6 +21,7 @@ export const signInWithPassword = async ({ .select({ id: core_users.id, email: core_users.email, + emailVerified: core_users.emailVerified, password: core_users.password, }) .from(core_users) @@ -46,5 +47,9 @@ export const signInWithPassword = async ({ throw new HTTPException(403); } - return { id: user.id, email: user.email }; + // Whether the address is confirmed is only ever reported from here - after + // the password has been proven. Saying so any earlier would tell a stranger + // which addresses hold an unconfirmed account without them knowing a single + // password. + return { id: user.id, email: user.email, emailVerified: user.emailVerified }; }; diff --git a/packages/vitnode/src/api/modules/admin/users/routes/create.route.ts b/packages/vitnode/src/api/modules/admin/users/routes/create.route.ts index 0ea5110b7c..03a84e6e4e 100644 --- a/packages/vitnode/src/api/modules/admin/users/routes/create.route.ts +++ b/packages/vitnode/src/api/modules/admin/users/routes/create.route.ts @@ -3,22 +3,28 @@ import { z } from "@hono/zod-openapi"; import { buildRoute } from "@/api/lib/route"; import { PasswordModel } from "@/api/models/password"; import { UserModel } from "@/api/models/user"; +import { + USER_EMAIL_MAX_LENGTH, + USER_NAME_MAX_LENGTH, + USER_PASSWORD_MAX_LENGTH, +} from "@/api/modules/users/credential-limits"; import { CONFIG_PLUGIN } from "@/config"; const nameRegex = /^(?!.* {2})[\p{L}\p{N}._@ -]*$/u; export const zodCreateUserAdminSchema = z.object({ - email: z.email().toLowerCase().openapi({ + email: z.email().max(USER_EMAIL_MAX_LENGTH).toLowerCase().openapi({ example: "test@test.com", }), name: z .string() .openapi({ example: "test" }) .min(3) + .max(USER_NAME_MAX_LENGTH) .refine(val => nameRegex.test(val), { message: "Invalid name", }), - password: z.string().min(8).openapi({ + password: z.string().min(8).max(USER_PASSWORD_MAX_LENGTH).openapi({ example: "Test123!", }), }); diff --git a/packages/vitnode/src/api/modules/admin/users/routes/password.route.ts b/packages/vitnode/src/api/modules/admin/users/routes/password.route.ts index 54558d73b9..b185c68eb8 100644 --- a/packages/vitnode/src/api/modules/admin/users/routes/password.route.ts +++ b/packages/vitnode/src/api/modules/admin/users/routes/password.route.ts @@ -5,13 +5,18 @@ import { assertPasswordSignInEnabled } from "@/api/lib/password-sign-in"; import { buildRoute } from "@/api/lib/route"; import { PasswordModel } from "@/api/models/password"; import { revokeSessions } from "@/api/models/session-revoke"; +import { USER_PASSWORD_MAX_LENGTH } from "@/api/modules/users/credential-limits"; import { CONFIG_PLUGIN } from "@/config"; import { core_users } from "@/database/users"; import { assertCanEditAdminTarget } from "../lib/assert-edit-user-permission"; export const zodSetPasswordUserAdminSchema = z.object({ - password: z.string().min(8).openapi({ example: "Test123!" }), + password: z + .string() + .min(8) + .max(USER_PASSWORD_MAX_LENGTH) + .openapi({ example: "Test123!" }), }); export const setPasswordUserAdminRoute = buildRoute({ diff --git a/packages/vitnode/src/api/modules/admin/users/routes/verify-email.route.ts b/packages/vitnode/src/api/modules/admin/users/routes/verify-email.route.ts index a7f9b63cf1..c5739e4d52 100644 --- a/packages/vitnode/src/api/modules/admin/users/routes/verify-email.route.ts +++ b/packages/vitnode/src/api/modules/admin/users/routes/verify-email.route.ts @@ -2,8 +2,9 @@ import { z } from "@hono/zod-openapi"; import { eq } from "drizzle-orm"; import { buildRoute } from "@/api/lib/route"; +import { invalidateSessionCacheForUser } from "@/api/models/session-revoke"; import { CONFIG_PLUGIN } from "@/config"; -import { core_users } from "@/database/users"; +import { core_users, core_users_confirm_emails } from "@/database/users"; import { assertCanEditAdminTarget } from "../lib/assert-edit-user-permission"; @@ -56,7 +57,7 @@ export const verifyEmailUserAdminRoute = buildRoute({ const db = c.get("db"); const [user] = await db - .select({ id: core_users.id }) + .select({ emailVerified: core_users.emailVerified, id: core_users.id }) .from(core_users) .where(eq(core_users.id, userId)) .limit(1); @@ -72,10 +73,22 @@ export const verifyEmailUserAdminRoute = buildRoute({ .set({ emailVerified: true }) .where(eq(core_users.id, user.id)) .returning({ + email: core_users.email, name: core_users.name, emailVerified: core_users.emailVerified, }); + // A confirmation link still in the member's inbox has nothing left to do. + await db + .delete(core_users_confirm_emails) + .where(eq(core_users_confirm_emails.userId, user.id)); + if (!user.emailVerified) { + await invalidateSessionCacheForUser(c, user.id); + await c + .get("events") + .emit("user.email.verified", { email: updated.email, userId: user.id }); + } + return c.json( { name: updated.name, emailVerified: updated.emailVerified }, 200, diff --git a/packages/vitnode/src/api/modules/cron/cron/clean.cron.ts b/packages/vitnode/src/api/modules/cron/cron/clean.cron.ts index 89bd9ec46f..d42e93bfff 100644 --- a/packages/vitnode/src/api/modules/cron/cron/clean.cron.ts +++ b/packages/vitnode/src/api/modules/cron/cron/clean.cron.ts @@ -10,7 +10,10 @@ import { core_sessions, core_sessions_known_devices, } from "@/database/sessions"; -import { core_users_forgot_password } from "@/database/users"; +import { + core_users_confirm_emails, + core_users_forgot_password, +} from "@/database/users"; export const isOrphanedDevice = ( db: Pick, @@ -53,10 +56,10 @@ export const cleanCron = buildCron({ .delete(core_users_forgot_password) .where(lt(core_users_forgot_password.expiresAt, new Date())); - // // Delete expired email confirmation tokens - // await tx - // .delete(core_users_confirm_emails) - // .where(lt(core_users_confirm_emails.expiresAt, new Date())); + // Delete expired email confirmation tokens + await tx + .delete(core_users_confirm_emails) + .where(lt(core_users_confirm_emails.expiresAt, new Date())); }); }, }); diff --git a/packages/vitnode/src/api/modules/users/credential-limits.ts b/packages/vitnode/src/api/modules/users/credential-limits.ts new file mode 100644 index 0000000000..cc2f3eb7de --- /dev/null +++ b/packages/vitnode/src/api/modules/users/credential-limits.ts @@ -0,0 +1,16 @@ +/** + * The longest email address or name an account can have - the width of the + * `core_users` columns they are stored in. A longer value could never be saved, + * so it is refused by the schema instead of by the database. + */ +export const USER_EMAIL_MAX_LENGTH = 255; +export const USER_NAME_MAX_LENGTH = 255; + +/** + * The longest password any route accepts. + * + * Every password is run through scrypt, and the cost of that grows with the + * input. Without a cap an anonymous caller could post megabytes of "password" + * to the sign-in route and make the server derive a key over all of it. + */ +export const USER_PASSWORD_MAX_LENGTH = 1024; diff --git a/packages/vitnode/src/api/modules/users/passkeys/passkeys.test.ts b/packages/vitnode/src/api/modules/users/passkeys/passkeys.test.ts index f7ff877155..58826ccc08 100644 --- a/packages/vitnode/src/api/modules/users/passkeys/passkeys.test.ts +++ b/packages/vitnode/src/api/modules/users/passkeys/passkeys.test.ts @@ -94,6 +94,7 @@ const harness = ({ } = {}) => { let passkeys = initialPasskeys; let password = AUTHORIZATION.password; + let sendsEmail = false; const memory = createMemoryPasskeyStore(accounts); vi.spyOn(PasskeyModel.prototype, "store", "get").mockReturnValue( memory.store, @@ -130,6 +131,7 @@ const harness = ({ app.use("*", async (c, next) => { c.set("core", { authorization: { ...AUTHORIZATION, passkeys, password }, + email: sendsEmail ? { adapter: { sendEmail: vi.fn() } } : undefined, } as EnvVariablesVitNode["core"]); c.set("user", viewer as unknown as Context["var"]["user"]); c.set("db", memoryDb.db as unknown as Context["var"]["db"]); @@ -174,6 +176,15 @@ const harness = ({ disablePasswordSignIn: () => { password = { enabled: false }; }, + confirmEmail: async (user: Viewer) => { + await memoryDb.db + .update(core_users) + .set({ emailVerified: true }) + .where(eq(core_users.id, user.id)); + }, + sendEmail: () => { + sendsEmail = true; + }, grantStaff: async (user: Viewer) => { await memoryDb.db.insert(core_admin_permissions).values({ permissions: [], @@ -552,6 +563,28 @@ describe("passkey sign-in", () => { expect([...h.passkeys.values()][0]?.lastUsedAt).toBeInstanceOf(Date); }); + it("refuses an unconfirmed email on an install that sends email", async () => { + const { authenticator, h } = await registered(); + h.sendEmail(); + + const response = await signIn(h, authenticator); + + expect(response.status).toBe(403); + expect(await response.json()).toEqual({ error: "email_not_verified" }); + expect(h.createSession).not.toHaveBeenCalled(); + }); + + it("signs a confirmed account in on an install that sends email", async () => { + const { authenticator, h } = await registered(); + h.sendEmail(); + await h.confirmEmail(ALICE); + + const response = await signIn(h, authenticator); + + expect(response.status).toBe(201); + expect(h.createSession).toHaveBeenCalledWith(ALICE.id); + }); + it("never trusts a user id supplied by the browser", async () => { const { authenticator, h } = await registered(); const options = await startSignIn(h); diff --git a/packages/vitnode/src/api/modules/users/passkeys/routes/authentication-verify.route.ts b/packages/vitnode/src/api/modules/users/passkeys/routes/authentication-verify.route.ts index 278d8be687..9f06f0adeb 100644 --- a/packages/vitnode/src/api/modules/users/passkeys/routes/authentication-verify.route.ts +++ b/packages/vitnode/src/api/modules/users/passkeys/routes/authentication-verify.route.ts @@ -1,9 +1,15 @@ import { z } from "@hono/zod-openapi"; +import { eq } from "drizzle-orm"; import { buildRoute } from "@/api/lib/route"; -import { PasskeyModel } from "@/api/models/passkey"; +import { + EMAIL_NOT_VERIFIED, + mustVerifyEmail, +} from "@/api/models/email-verification"; +import { PasskeyError, PasskeyModel } from "@/api/models/passkey"; import { SessionModel } from "@/api/models/session"; import { CONFIG_PLUGIN } from "@/config"; +import { core_users } from "@/database/users"; import { passkeyFailure } from "../failure"; import { @@ -50,6 +56,20 @@ export const passkeyAuthenticationVerifyRoute = buildRoute({ response, "authentication", ); + // The same rule as a password sign-in: an install that sends + // confirmation emails does not open a session for an address nobody has + // confirmed yet. Checked after the passkey verified, so a stranger learns + // nothing from it. + const [user] = await c + .get("db") + .select({ emailVerified: core_users.emailVerified }) + .from(core_users) + .where(eq(core_users.id, userId)) + .limit(1); + if (user && mustVerifyEmail(c, user)) { + throw new PasskeyError(EMAIL_NOT_VERIFIED, 403); + } + await new SessionModel(c).createSessionByUserId(userId); return c.json({ id: userId }, 201); diff --git a/packages/vitnode/src/api/modules/users/passkeys/schema.ts b/packages/vitnode/src/api/modules/users/passkeys/schema.ts index 096423db6d..d08587b9dd 100644 --- a/packages/vitnode/src/api/modules/users/passkeys/schema.ts +++ b/packages/vitnode/src/api/modules/users/passkeys/schema.ts @@ -108,6 +108,7 @@ export const zodPasskeyErrorSchema = z.object({ error: z.enum([ "admin_session_required", "already_registered", + "email_not_verified", "invalid_challenge", "last_recovery_method", "not_found", diff --git a/packages/vitnode/src/api/modules/users/routes/change-password.route.ts b/packages/vitnode/src/api/modules/users/routes/change-password.route.ts index 86e50d6239..65a92b73ec 100644 --- a/packages/vitnode/src/api/modules/users/routes/change-password.route.ts +++ b/packages/vitnode/src/api/modules/users/routes/change-password.route.ts @@ -9,12 +9,14 @@ import { revokeSessions } from "@/api/models/session-revoke"; import { CONFIG_PLUGIN } from "@/config"; import { core_users, core_users_forgot_password } from "@/database/users"; +import { USER_PASSWORD_MAX_LENGTH } from "../credential-limits"; + export const zodChangePasswordSchema = z.object({ - password: z.string().min(8).openapi({ + password: z.string().min(8).max(USER_PASSWORD_MAX_LENGTH).openapi({ example: "Test123!", }), userId: z.number().openapi({ example: 123456 }), - token: z.string().openapi({ example: "abcdefg12345" }), + token: z.string().max(512).openapi({ example: "abcdefg12345" }), }); export const changePasswordRoute = buildRoute({ @@ -88,6 +90,24 @@ export const changePasswordRoute = buildRoute({ ]); await c.get("events").emit("user.password.updated", { userId }); + // The reset link went to the account's own address, so following it proves + // the mailbox just as a confirmation link would. Someone who registered + // before confirmation emails went out - or lost theirs - is not left locked + // out after choosing a new password. + const [confirmed] = await c + .get("db") + .update(core_users) + .set({ emailVerified: true }) + .where( + and(eq(core_users.id, userId), eq(core_users.emailVerified, false)), + ) + .returning({ email: core_users.email }); + if (confirmed) { + await c + .get("events") + .emit("user.email.verified", { email: confirmed.email, userId }); + } + // After the new password is in place, so a failure above cannot sign // somebody out without having changed anything. Whoever reset this password // is doing it because the old credential is not trusted any more, and every diff --git a/packages/vitnode/src/api/modules/users/routes/credentials-hardening.test.ts b/packages/vitnode/src/api/modules/users/routes/credentials-hardening.test.ts new file mode 100644 index 0000000000..34595c9a0a --- /dev/null +++ b/packages/vitnode/src/api/modules/users/routes/credentials-hardening.test.ts @@ -0,0 +1,251 @@ +// @vitest-environment node +import type { Context } from "hono"; + +import { OpenAPIHono } from "@hono/zod-openapi"; +import { afterEach, describe, expect, it, vi } from "vitest"; + +import type { EnvVariablesVitNode } from "@/api/middlewares/global.middleware"; + +import { PasswordModel } from "@/api/models/password"; +import { SessionModel } from "@/api/models/session"; +import { SessionAdminModel } from "@/api/models/session-admin"; +import { SSOModel } from "@/api/models/sso"; +import { core_roles } from "@/database/roles"; +import { core_users, core_users_forgot_password } from "@/database/users"; +import { createMemoryDb } from "@/tests/memory-db"; +import { SESSION_AUTHORIZATION } from "@/tests/sessions"; + +import { + USER_EMAIL_MAX_LENGTH, + USER_NAME_MAX_LENGTH, + USER_PASSWORD_MAX_LENGTH, +} from "../credential-limits"; +import { linkRoute } from "../sso/routes/link.route"; +import { changePasswordRoute } from "./change-password.route"; +import { resetPasswordRoute } from "./reset-passowrd.route"; +import { signInRoute } from "./sign-in.route"; +import { signUpRoute } from "./sign-up.route"; + +const PASSWORD = "Test123!"; +const STORED_PASSWORD = await new PasswordModel().encryptPassword(PASSWORD); +const MEMBER = { + avatarColor: "aaaaaa", + email: "test@test.com", + emailVerified: true, + id: 1, + ipAddress: "203.0.113.7", + language: "en", + name: "tester", + nameCode: "tester", + password: STORED_PASSWORD, + roleId: 2, +}; + +const harness = ({ withEmail = true }: { withEmail?: boolean } = {}) => { + const memory = createMemoryDb([ + [ + core_roles, + [ + { default: false, id: 1, root: true }, + { default: true, id: 2, root: false }, + ], + ], + [core_users, [MEMBER]], + [core_users_forgot_password, []], + ]); + const send = vi.fn(async () => Promise.resolve()); + vi.spyOn(SessionModel.prototype, "createSessionByUserId").mockResolvedValue({ + token: "session-token", + }); + vi.spyOn( + SessionAdminModel.prototype, + "createSessionByUserId", + ).mockResolvedValue({ token: "admin-token" }); + + const app = new OpenAPIHono(); + app.use("*", async (c, next) => { + c.set("core", { + authorization: SESSION_AUTHORIZATION, + email: withEmail ? { adapter: { sendEmail: vi.fn() } } : undefined, + } as unknown as EnvVariablesVitNode["core"]); + c.set("db", memory.db as unknown as Context["var"]["db"]); + c.set("email", { send } as unknown as Context["var"]["email"]); + c.set("events", { + emit: vi.fn(async () => Promise.resolve(undefined)), + } as unknown as Context["var"]["events"]); + c.set("ipAddress", "203.0.113.7"); + c.set("user", null as Context["var"]["user"]); + await next(); + }); + for (const built of [ + changePasswordRoute, + resetPasswordRoute, + signInRoute, + signUpRoute, + ]) { + app.openapi(built.route, built.handler); + } + const sso = new OpenAPIHono(); + sso.openapi(linkRoute.route, linkRoute.handler); + app.route("/sso", sso); + + const post = async (path: string, body: unknown) => + await app.request(path, { + body: JSON.stringify(body), + headers: { "content-type": "application/json" }, + method: "POST", + }); + + return { post, send }; +}; + +afterEach(() => { + vi.restoreAllMocks(); +}); + +describe("the session token stays in its HttpOnly cookie", () => { + it("is not in a member's sign-in answer", async () => { + const h = harness(); + + const response = await h.post("/sign_in", { + email: MEMBER.email, + password: PASSWORD, + }); + + expect(response.status).toBe(201); + expect(await response.json()).toEqual({ id: MEMBER.id }); + }); + + it("is not in an AdminCP sign-in answer", async () => { + const h = harness(); + + const response = await h.post("/sign_in", { + email: MEMBER.email, + isAdmin: true, + password: PASSWORD, + }); + + expect(await response.json()).toEqual({ id: MEMBER.id }); + }); + + it("is not in the answer to linking an SSO identity", async () => { + const h = harness(); + vi.spyOn(SSOModel.prototype, "link").mockResolvedValue({ + userId: MEMBER.id, + }); + + const response = await h.post("/sso/google/link", { + password: PASSWORD, + token: "t".repeat(32), + }); + + expect(response.status).toBe(201); + expect(await response.json()).toEqual({ id: MEMBER.id }); + }); +}); + +describe("a password reset request says nothing about the address", () => { + it("answers a registered and an unknown address the same way without email", async () => { + const h = harness({ withEmail: false }); + + const known = await h.post("/reset-password", { email: MEMBER.email }); + const unknown = await h.post("/reset-password", { + email: "nobody@example.com", + }); + + expect([known.status, unknown.status]).toEqual([404, 404]); + expect(await known.text()).toBe(await unknown.text()); + }); + + it("answers a registered and an unknown address the same way with email", async () => { + const h = harness(); + + const known = await h.post("/reset-password", { email: MEMBER.email }); + const unknown = await h.post("/reset-password", { + email: "nobody@example.com", + }); + + expect([known.status, unknown.status]).toEqual([201, 201]); + expect(await known.text()).toBe(await unknown.text()); + expect(h.send).toHaveBeenCalledOnce(); + }); +}); + +describe("credential length limits", () => { + const tooLongPassword = "A1!".padEnd(USER_PASSWORD_MAX_LENGTH + 1, "a"); + const tooLongEmail = `${"a".repeat(USER_EMAIL_MAX_LENGTH)}@example.com`; + + it("refuses an oversized sign-in password before hashing anything", async () => { + const h = harness(); + const verify = vi.spyOn(PasswordModel.prototype, "verifyPassword"); + + const response = await h.post("/sign_in", { + email: MEMBER.email, + password: tooLongPassword, + }); + + expect(response.status).toBe(400); + expect(verify).not.toHaveBeenCalled(); + }); + + it("still checks a password right at the limit", async () => { + const h = harness(); + + const response = await h.post("/sign_in", { + email: MEMBER.email, + password: "a".repeat(USER_PASSWORD_MAX_LENGTH), + }); + + expect(response.status).toBe(403); + }); + + it("refuses an oversized sign-in email", async () => { + const h = harness(); + + const response = await h.post("/sign_in", { + email: tooLongEmail, + password: PASSWORD, + }); + + expect(response.status).toBe(400); + }); + + it.each([ + ["name", { name: "n".repeat(USER_NAME_MAX_LENGTH + 1) }], + ["email", { email: tooLongEmail }], + ["password", { password: tooLongPassword }], + ])("refuses a sign-up with an oversized %s", async (_field, override) => { + const h = harness(); + const encrypt = vi.spyOn(PasswordModel.prototype, "encryptPassword"); + + const response = await h.post("/sign_up", { + email: "fresh@example.com", + name: "fresh", + password: PASSWORD, + ...override, + }); + + expect(response.status).toBe(400); + expect(encrypt).not.toHaveBeenCalled(); + }); + + it("refuses an oversized new password from a reset link", async () => { + const h = harness(); + + const response = await h.post("/change-password", { + password: tooLongPassword, + token: "a".repeat(32), + userId: MEMBER.id, + }); + + expect(response.status).toBe(400); + }); + + it("refuses an oversized address in a reset request", async () => { + const h = harness(); + + const response = await h.post("/reset-password", { email: tooLongEmail }); + + expect(response.status).toBe(400); + }); +}); diff --git a/packages/vitnode/src/api/modules/users/routes/email-verification.test.ts b/packages/vitnode/src/api/modules/users/routes/email-verification.test.ts new file mode 100644 index 0000000000..3076973529 --- /dev/null +++ b/packages/vitnode/src/api/modules/users/routes/email-verification.test.ts @@ -0,0 +1,474 @@ +// @vitest-environment node +import type { Context } from "hono"; + +import { OpenAPIHono } from "@hono/zod-openapi"; +import crypto from "node:crypto"; +import { afterEach, describe, expect, it, vi } from "vitest"; + +import type { EnvVariablesVitNode } from "@/api/middlewares/global.middleware"; + +import { + EMAIL_VERIFICATION_TTL_MS, + EmailVerificationModel, +} from "@/api/models/email-verification"; +import { ForgotPasswordTokenModel, PasswordModel } from "@/api/models/password"; +import { SessionModel } from "@/api/models/session"; +import { SessionAdminModel } from "@/api/models/session-admin"; +import { core_admin_sessions } from "@/database/admins"; +import { core_roles } from "@/database/roles"; +import { core_sessions } from "@/database/sessions"; +import { + core_users, + core_users_confirm_emails, + core_users_forgot_password, +} from "@/database/users"; +import { createTestCache } from "@/tests/cache"; +import { createMemoryDb } from "@/tests/memory-db"; +import { SESSION_AUTHORIZATION } from "@/tests/sessions"; + +import { changePasswordRoute } from "./change-password.route"; +import { resendVerificationRoute } from "./resend-verification.route"; +import { resetPasswordRoute } from "./reset-passowrd.route"; +import { signInRoute } from "./sign-in.route"; +import { signUpRoute } from "./sign-up.route"; +import { verifyEmailRoute } from "./verify-email.route"; + +const PASSWORD = "Test123!"; +const STORED_PASSWORD = await new PasswordModel().encryptPassword(PASSWORD); +const LINK_TOKEN = "confirm-token-confirm-token-confirm-token"; +const sha256 = (value: string) => + crypto.createHash("sha256").update(value).digest("hex"); + +interface MemberSeed { + email: string; + emailVerified: boolean; + id: number; +} + +const member = ({ email, emailVerified, id }: MemberSeed) => ({ + avatarColor: "aaaaaa", + email, + emailVerified, + id, + ipAddress: "203.0.113.7", + language: "en", + name: `member-${id}`, + nameCode: `member-${id}`, + password: STORED_PASSWORD, + roleId: 2, +}); + +const UNVERIFIED = { email: "new@example.com", emailVerified: false, id: 7 }; +const VERIFIED = { email: "old@example.com", emailVerified: true, id: 8 }; + +const harness = ({ + confirmations = [], + members = [UNVERIFIED, VERIFIED], + resets = [], + withEmail = true, +}: { + confirmations?: Record[]; + members?: MemberSeed[]; + resets?: Record[]; + withEmail?: boolean; +} = {}) => { + const memory = createMemoryDb([ + [ + core_roles, + [ + { default: false, id: 1, root: true }, + { default: true, id: 2, root: false }, + ], + ], + [core_users, members.map(member)], + [core_users_confirm_emails, confirmations], + [core_users_forgot_password, resets], + [core_sessions, []], + [core_admin_sessions, []], + ]); + const send = vi.fn(async () => Promise.resolve()); + const emit = vi.fn(async () => Promise.resolve(undefined)); + const logError = vi.fn(async () => Promise.resolve()); + const createSession = vi + .spyOn(SessionModel.prototype, "createSessionByUserId") + .mockResolvedValue({ token: "session-token" }); + const createAdminSession = vi + .spyOn(SessionAdminModel.prototype, "createSessionByUserId") + .mockResolvedValue({ token: "admin-token" }); + vi.spyOn(EmailVerificationModel.prototype, "generateToken").mockReturnValue( + LINK_TOKEN, + ); + + const app = new OpenAPIHono(); + app.use("*", async (c, next) => { + c.set("core", { + authorization: SESSION_AUTHORIZATION, + email: withEmail ? { adapter: { sendEmail: vi.fn() } } : undefined, + } as unknown as EnvVariablesVitNode["core"]); + c.set("cache", createTestCache()); + c.set("db", memory.db as unknown as Context["var"]["db"]); + c.set("email", { send } as unknown as Context["var"]["email"]); + c.set("events", { emit } as unknown as Context["var"]["events"]); + c.set("ipAddress", "203.0.113.7"); + c.set("log", { error: logError } as unknown as Context["var"]["log"]); + c.set("user", null as Context["var"]["user"]); + await next(); + }); + for (const built of [ + changePasswordRoute, + resendVerificationRoute, + resetPasswordRoute, + signInRoute, + signUpRoute, + verifyEmailRoute, + ]) { + app.openapi(built.route, built.handler); + } + + const post = async (path: string, body: unknown) => + await app.request(path, { + body: JSON.stringify(body), + headers: { "content-type": "application/json" }, + method: "POST", + }); + + const userRow = (id: number) => + memory.rows(core_users).find(row => row.id === id); + const confirmationRows = () => memory.rows(core_users_confirm_emails); + + return { + confirmationRows, + createAdminSession, + createSession, + emit, + logError, + post, + send, + userRow, + }; +}; + +const pendingLink = (overrides: Record = {}) => ({ + createdAt: new Date(Date.now() - 1000 * 60 * 60), + expiresAt: new Date(Date.now() + 1000 * 60 * 60), + id: 1, + ipAddress: "203.0.113.7", + token: sha256(LINK_TOKEN), + userId: UNVERIFIED.id, + ...overrides, +}); + +afterEach(() => { + vi.restoreAllMocks(); +}); + +describe("signing up on an install that sends email", () => { + const SIGN_UP = { + email: "fresh@example.com", + name: "fresh", + password: PASSWORD, + }; + + it("emails a confirmation link instead of signing the member in", async () => { + const h = harness({ members: [VERIFIED] }); + + const response = await h.post("/sign_up", SIGN_UP); + + expect(response.status).toBe(201); + expect(await response.json()).toMatchObject({ + email: SIGN_UP.email, + emailVerified: false, + }); + expect(h.createSession).not.toHaveBeenCalled(); + expect(h.send).toHaveBeenCalledOnce(); + expect(h.send).toHaveBeenCalledWith( + expect.objectContaining({ + user: expect.objectContaining({ email: SIGN_UP.email }), + }), + ); + }); + + it("stores only a digest of the link, valid for a day", async () => { + const h = harness({ members: [VERIFIED] }); + + await h.post("/sign_up", SIGN_UP); + + const [row] = h.confirmationRows(); + expect(row?.token).toBe(sha256(LINK_TOKEN)); + expect(row?.token).not.toBe(LINK_TOKEN); + const expiresIn = (row?.expiresAt as Date).getTime() - Date.now(); + expect(expiresIn).toBeGreaterThan(EMAIL_VERIFICATION_TTL_MS - 60_000); + expect(expiresIn).toBeLessThanOrEqual(EMAIL_VERIFICATION_TTL_MS); + }); + + it("still creates the account when the link could not be sent", async () => { + const h = harness({ members: [VERIFIED] }); + h.send.mockRejectedValueOnce(new Error("smtp down")); + + const response = await h.post("/sign_up", SIGN_UP); + + expect(response.status).toBe(201); + expect(h.logError).toHaveBeenCalledOnce(); + }); + + it("signs the member straight in when the install cannot send email", async () => { + const h = harness({ members: [VERIFIED], withEmail: false }); + + const response = await h.post("/sign_up", SIGN_UP); + + expect(await response.json()).toMatchObject({ emailVerified: true }); + expect(h.createSession).toHaveBeenCalledOnce(); + expect(h.send).not.toHaveBeenCalled(); + }); +}); + +describe("signing in before the address is confirmed", () => { + it("refuses the right password with email_not_verified", async () => { + const h = harness(); + + const response = await h.post("/sign_in", { + email: UNVERIFIED.email, + password: PASSWORD, + }); + + expect(response.status).toBe(403); + expect(await response.json()).toEqual({ error: "email_not_verified" }); + expect(h.createSession).not.toHaveBeenCalled(); + }); + + it("answers a wrong password exactly like an unknown address", async () => { + const h = harness(); + + const wrongPassword = await h.post("/sign_in", { + email: UNVERIFIED.email, + password: "Wrong123!", + }); + const unknown = await h.post("/sign_in", { + email: "nobody@example.com", + password: "Wrong123!", + }); + + expect(wrongPassword.status).toBe(403); + expect(unknown.status).toBe(403); + expect(await wrongPassword.text()).toBe(await unknown.text()); + }); + + it("proves the password before saying anything about the address", async () => { + const h = harness(); + const verify = vi.spyOn(PasswordModel.prototype, "verifyPassword"); + + await h.post("/sign_in", { email: UNVERIFIED.email, password: PASSWORD }); + + expect(verify).toHaveBeenCalledWith(PASSWORD, STORED_PASSWORD); + }); + + it("lets the same member in once the install no longer sends email", async () => { + const h = harness({ withEmail: false }); + + const response = await h.post("/sign_in", { + email: UNVERIFIED.email, + password: PASSWORD, + }); + + expect(response.status).toBe(201); + expect(h.createSession).toHaveBeenCalledWith(UNVERIFIED.id); + }); + + it("does not hold up the AdminCP sign-in, which only admits staff", async () => { + const h = harness(); + + const response = await h.post("/sign_in", { + email: UNVERIFIED.email, + isAdmin: true, + password: PASSWORD, + }); + + expect(response.status).toBe(201); + expect(h.createAdminSession).toHaveBeenCalledWith(UNVERIFIED.id); + }); +}); + +describe("following the confirmation link", () => { + it("confirms the address, spends the link and emits user.email.verified", async () => { + const h = harness({ confirmations: [pendingLink()] }); + + const response = await h.post("/verify-email", { token: LINK_TOKEN }); + + expect(response.status).toBe(200); + expect(await response.json()).toEqual({ email: UNVERIFIED.email }); + expect(h.userRow(UNVERIFIED.id)?.emailVerified).toBe(true); + expect(h.confirmationRows()).toEqual([]); + expect(h.emit).toHaveBeenCalledWith("user.email.verified", { + email: UNVERIFIED.email, + userId: UNVERIFIED.id, + }); + }); + + it("does not sign anybody in - the member signs in with their password next", async () => { + const h = harness({ confirmations: [pendingLink()] }); + + const confirmed = await h.post("/verify-email", { token: LINK_TOKEN }); + const signIn = await h.post("/sign_in", { + email: UNVERIFIED.email, + password: PASSWORD, + }); + + expect(confirmed.headers.getSetCookie()).toEqual([]); + expect(signIn.status).toBe(201); + expect(h.createSession).toHaveBeenCalledOnce(); + }); + + it("works exactly once", async () => { + const h = harness({ confirmations: [pendingLink()] }); + + const first = await h.post("/verify-email", { token: LINK_TOKEN }); + const second = await h.post("/verify-email", { token: LINK_TOKEN }); + + expect(first.status).toBe(200); + expect(second.status).toBe(400); + expect(h.emit).toHaveBeenCalledOnce(); + }); + + it("refuses an expired link and leaves the account unconfirmed", async () => { + const h = harness({ + confirmations: [pendingLink({ expiresAt: new Date(Date.now() - 1000) })], + }); + + const response = await h.post("/verify-email", { token: LINK_TOKEN }); + + expect(response.status).toBe(400); + expect(h.userRow(UNVERIFIED.id)?.emailVerified).toBe(false); + }); + + it("refuses a token that was never issued", async () => { + const h = harness({ confirmations: [pendingLink()] }); + + const response = await h.post("/verify-email", { + token: "never-issued-never-issued-never-issued", + }); + + expect(response.status).toBe(400); + expect(h.userRow(UNVERIFIED.id)?.emailVerified).toBe(false); + }); +}); + +describe("asking for a new confirmation link", () => { + it("answers the same 201 for an unknown, a confirmed and an unconfirmed address", async () => { + const h = harness(); + + const responses = await Promise.all( + ["nobody@example.com", VERIFIED.email, UNVERIFIED.email].map( + async email => await h.post("/verify-email/resend", { email }), + ), + ); + + expect(responses.map(response => response.status)).toEqual([201, 201, 201]); + const bodies = await Promise.all( + responses.map(async response => await response.text()), + ); + expect(new Set(bodies).size).toBe(1); + }); + + it("only sends to an account that still needs confirming", async () => { + const h = harness(); + + await h.post("/verify-email/resend", { email: "nobody@example.com" }); + await h.post("/verify-email/resend", { email: VERIFIED.email }); + await h.post("/verify-email/resend", { email: UNVERIFIED.email }); + + expect(h.send).toHaveBeenCalledOnce(); + expect(h.send).toHaveBeenCalledWith( + expect.objectContaining({ + user: expect.objectContaining({ id: UNVERIFIED.id }), + }), + ); + }); + + it("replaces the old link, so only the newest one confirms", async () => { + const h = harness({ + confirmations: [ + pendingLink({ token: sha256("an-older-link-an-older-link") }), + ], + }); + + await h.post("/verify-email/resend", { email: UNVERIFIED.email }); + + expect(h.confirmationRows().map(row => row.token)).toEqual([ + sha256(LINK_TOKEN), + ]); + expect( + (await h.post("/verify-email", { token: "an-older-link-an-older-link" })) + .status, + ).toBe(400); + }); + + it("sends nothing while a link from the last five minutes is still out", async () => { + const h = harness({ + confirmations: [pendingLink({ createdAt: new Date() })], + }); + + const response = await h.post("/verify-email/resend", { + email: UNVERIFIED.email, + }); + + expect(response.status).toBe(201); + expect(h.send).not.toHaveBeenCalled(); + }); + + it("answers 404 for every address on an install that cannot send email", async () => { + const h = harness({ withEmail: false }); + + const known = await h.post("/verify-email/resend", { + email: UNVERIFIED.email, + }); + const unknown = await h.post("/verify-email/resend", { + email: "nobody@example.com", + }); + + expect([known.status, unknown.status]).toEqual([404, 404]); + expect(await known.text()).toBe(await unknown.text()); + }); +}); + +describe("completing a password reset", () => { + const RESET_TOKEN = "reset-token-reset-token"; + const resetFor = (userId: number) => ({ + expiresAt: new Date(Date.now() + 1000 * 60 * 10), + id: userId, + ipAddress: "203.0.113.7", + token: new ForgotPasswordTokenModel().hashResetToken(RESET_TOKEN), + userId, + }); + const changePassword = (userId: number) => ({ + password: "NewPassword1!", + token: RESET_TOKEN, + userId, + }); + + it("also confirms the address, since the reset link went to the same mailbox", async () => { + const h = harness({ resets: [resetFor(UNVERIFIED.id)] }); + + const response = await h.post( + "/change-password", + changePassword(UNVERIFIED.id), + ); + + expect(response.status).toBe(201); + expect(h.userRow(UNVERIFIED.id)?.emailVerified).toBe(true); + expect(h.emit).toHaveBeenCalledWith("user.email.verified", { + email: UNVERIFIED.email, + userId: UNVERIFIED.id, + }); + }); + + it("emits nothing about the address when it was already confirmed", async () => { + const h = harness({ resets: [resetFor(VERIFIED.id)] }); + + await h.post("/change-password", changePassword(VERIFIED.id)); + + expect(h.emit).not.toHaveBeenCalledWith( + "user.email.verified", + expect.anything(), + ); + }); +}); diff --git a/packages/vitnode/src/api/modules/users/routes/resend-verification.route.ts b/packages/vitnode/src/api/modules/users/routes/resend-verification.route.ts new file mode 100644 index 0000000000..2d4db1170e --- /dev/null +++ b/packages/vitnode/src/api/modules/users/routes/resend-verification.route.ts @@ -0,0 +1,86 @@ +import { HTTPException } from "hono/http-exception"; +import { z } from "zod"; + +import { buildRoute } from "@/api/lib/route"; +import { matchesEmail, pickAccountForEmail } from "@/api/lib/user-email-lookup"; +import { + EmailVerificationModel, + isEmailVerificationRequired, +} from "@/api/models/email-verification"; +import { CONFIG_PLUGIN } from "@/config"; +import { core_users } from "@/database/users"; + +import { USER_EMAIL_MAX_LENGTH } from "../credential-limits"; + +export const resendVerificationRoute = buildRoute({ + pluginId: CONFIG_PLUGIN.pluginId, + route: { + method: "post", + description: + "Email a fresh confirmation link. Answers the same for every address, registered or not.", + path: "/verify-email/resend", + withCaptcha: true, + request: { + body: { + required: true, + content: { + "application/json": { + schema: z.object({ + email: z + .email() + .max(USER_EMAIL_MAX_LENGTH) + .toLowerCase() + .openapi({ + example: "test@test.com", + }), + }), + }, + }, + }, + }, + responses: { + 201: { + description: "Email sent, if there was anything to send", + }, + 404: { + description: + "This install cannot send email, so there is nothing to confirm", + }, + }, + }, + handler: async c => { + // Decided before the address is looked at, so the answer is the same for + // every caller - see the reset route for why that matters. + if (!isEmailVerificationRequired(c)) { + throw new HTTPException(404, { + message: "Email verification is not available", + }); + } + + const RESPONSE_TEXT = c.text("Email sent", 201); + const { email } = c.req.valid("json"); + const candidates = await c + .get("db") + .select({ + email: core_users.email, + emailVerified: core_users.emailVerified, + id: core_users.id, + language: core_users.language, + name: core_users.name, + }) + .from(core_users) + .where(matchesEmail(email)) + .limit(2); + const user = pickAccountForEmail(candidates, email); + + // No account, or nothing left to confirm: the same 201 as a real send, so + // this endpoint cannot be used to find out which addresses are registered. + if (!user || user.emailVerified) { + return RESPONSE_TEXT; + } + + await new EmailVerificationModel(c).send(user, { respectCooldown: true }); + + return RESPONSE_TEXT; + }, +}); diff --git a/packages/vitnode/src/api/modules/users/routes/reset-passowrd.route.ts b/packages/vitnode/src/api/modules/users/routes/reset-passowrd.route.ts index 93e66a5cf3..1feadfbfdc 100644 --- a/packages/vitnode/src/api/modules/users/routes/reset-passowrd.route.ts +++ b/packages/vitnode/src/api/modules/users/routes/reset-passowrd.route.ts @@ -1,4 +1,5 @@ import { eq } from "drizzle-orm"; +import { HTTPException } from "hono/http-exception"; import { createTranslator } from "use-intl"; import { z } from "zod"; @@ -11,6 +12,8 @@ import { core_users, core_users_forgot_password } from "@/database/users"; import ResetPasswordEmailTemplate from "@/emails/reset-password"; import { CONFIG } from "@/lib/config"; +import { USER_EMAIL_MAX_LENGTH } from "../credential-limits"; + export const resetPasswordRoute = buildRoute({ pluginId: CONFIG_PLUGIN.pluginId, route: { @@ -24,9 +27,13 @@ export const resetPasswordRoute = buildRoute({ content: { "application/json": { schema: z.object({ - email: z.email().toLowerCase().openapi({ - example: "test@test.com", - }), + email: z + .email() + .max(USER_EMAIL_MAX_LENGTH) + .toLowerCase() + .openapi({ + example: "test@test.com", + }), }), }, }, @@ -34,12 +41,27 @@ export const resetPasswordRoute = buildRoute({ }, responses: { 201: { - description: "Email sent", + description: "Email sent, if the address belongs to an account", + }, + 404: { + description: "This install cannot send email, so it offers no reset", }, }, }, handler: async c => { assertPasswordSignInEnabled(c); + + // Before the lookup, and the same for every address. It used to be left to + // the email model, which only ran for an address that had an account: an + // unknown one got the usual 201 and a known one a 500 "Email provider not + // found" - so on an install without email the response said outright + // whether an address was registered. + if (!c.get("core").email?.adapter) { + throw new HTTPException(404, { + message: "Password reset is not available", + }); + } + const RESPONSE_TEXT = c.text("Email sent", 201); const { email } = c.req.valid("json"); const candidates = await c diff --git a/packages/vitnode/src/api/modules/users/routes/sign-in.route.ts b/packages/vitnode/src/api/modules/users/routes/sign-in.route.ts index aeed1bee5e..18bed705bd 100644 --- a/packages/vitnode/src/api/modules/users/routes/sign-in.route.ts +++ b/packages/vitnode/src/api/modules/users/routes/sign-in.route.ts @@ -2,16 +2,25 @@ import { z } from "zod"; import { assertPasswordSignInEnabled } from "@/api/lib/password-sign-in"; import { buildRoute } from "@/api/lib/route"; +import { + EMAIL_NOT_VERIFIED, + mustVerifyEmail, +} from "@/api/models/email-verification"; import { SessionModel } from "@/api/models/session"; import { SessionAdminModel } from "@/api/models/session-admin"; import { UserModel } from "@/api/models/user"; import { CONFIG_PLUGIN } from "@/config"; +import { + USER_EMAIL_MAX_LENGTH, + USER_PASSWORD_MAX_LENGTH, +} from "../credential-limits"; + export const zodSignInSchema = z.object({ - email: z.email().toLowerCase().openapi({ + email: z.email().max(USER_EMAIL_MAX_LENGTH).toLowerCase().openapi({ example: "test@test.com", }), - password: z.string().openapi({ + password: z.string().max(USER_PASSWORD_MAX_LENGTH).openapi({ example: "Test123!", }), isAdmin: z.boolean().optional().openapi({ @@ -37,14 +46,22 @@ export const signInRoute = buildRoute({ }, responses: { 403: { - description: "Access Denied", + content: { + "application/json": { + schema: z.object({ error: z.literal(EMAIL_NOT_VERIFIED) }), + }, + }, + description: + "Access Denied. Wrong credentials answer with no body; a correct password for an account whose email is not confirmed yet answers `{ error: 'email_not_verified' }`.", }, 201: { content: { "application/json": { + // The session token travels in the HttpOnly cookie and nowhere + // else. Echoing it here handed it to every script on the page, + // which is the one thing HttpOnly exists to prevent. schema: z.object({ id: z.number(), - token: z.string(), }), }, }, @@ -62,14 +79,21 @@ export const signInRoute = buildRoute({ }); if (isAdmin) { - const { token } = await new SessionAdminModel(c).createSessionByUserId( - data.id, - ); + // The AdminCP admits staff only, and staff are chosen by an + // administrator - so it does not wait on the address being confirmed. + // An install that just started sending confirmation emails would + // otherwise lock every existing administrator out of the AdminCP. + await new SessionAdminModel(c).createSessionByUserId(data.id); + + return c.json({ id: data.id }, 201); + } - return c.json({ id: data.id, token }, 201); + if (mustVerifyEmail(c, data)) { + return c.json({ error: EMAIL_NOT_VERIFIED } as const, 403); } - const { token } = await new SessionModel(c).createSessionByUserId(data.id); - return c.json({ id: data.id, token }, 201); + await new SessionModel(c).createSessionByUserId(data.id); + + return c.json({ id: data.id }, 201); }, }); diff --git a/packages/vitnode/src/api/modules/users/routes/sign-up.route.ts b/packages/vitnode/src/api/modules/users/routes/sign-up.route.ts index 63363240c9..b48f2d7356 100644 --- a/packages/vitnode/src/api/modules/users/routes/sign-up.route.ts +++ b/packages/vitnode/src/api/modules/users/routes/sign-up.route.ts @@ -1,27 +1,35 @@ import { z } from "zod"; +import { describeError } from "@/api/lib/error-details"; import { assertPasswordSignInEnabled } from "@/api/lib/password-sign-in"; import { buildRoute } from "@/api/lib/route"; +import { EmailVerificationModel } from "@/api/models/email-verification"; import { PasswordModel } from "@/api/models/password"; import { UserModel } from "@/api/models/user"; import { CONFIG_PLUGIN } from "@/config"; import { SessionModel } from "../../../models/session"; +import { + USER_EMAIL_MAX_LENGTH, + USER_NAME_MAX_LENGTH, + USER_PASSWORD_MAX_LENGTH, +} from "../credential-limits"; const nameRegex = /^(?!.* {2})[\p{L}\p{N}._@ -]*$/u; export const zodSignUpSchema = z.object({ - email: z.email().toLowerCase().openapi({ + email: z.email().max(USER_EMAIL_MAX_LENGTH).toLowerCase().openapi({ example: "test@test.com", }), name: z .string() .openapi({ example: "test" }) .min(3) + .max(USER_NAME_MAX_LENGTH) .refine(val => nameRegex.test(val), { message: "Invalid name", }), - password: z.string().min(8).openapi({ + password: z.string().min(8).max(USER_PASSWORD_MAX_LENGTH).openapi({ example: "Test123!", }), newsletter: z.boolean().default(false).optional(), @@ -55,7 +63,8 @@ export const signUpRoute = buildRoute({ }), }, }, - description: "User created", + description: + "User created. With `emailVerified: false` no session was started and a confirmation link was emailed instead.", }, 400: { description: "Bad Request", @@ -78,7 +87,19 @@ export const signUpRoute = buildRoute({ if (data.emailVerified) { await new SessionModel(c).createSessionByUserId(data.id); } else { - // TODO: Send verification email + // The account exists either way. A link that failed to go out is not a + // reason to answer this sign-up with an error - the member would retry, + // hit "email already exists", and be stuck - so it is logged and the + // member can ask for another one from the sign-in page. + try { + await new EmailVerificationModel(c).send(data); + } catch (error) { + await c + .get("log") + .error( + `Could not send the email confirmation link to user ${data.id}: ${describeError(error)}`, + ); + } } return c.json( diff --git a/packages/vitnode/src/api/modules/users/routes/verify-email.route.ts b/packages/vitnode/src/api/modules/users/routes/verify-email.route.ts new file mode 100644 index 0000000000..eb44d2eb60 --- /dev/null +++ b/packages/vitnode/src/api/modules/users/routes/verify-email.route.ts @@ -0,0 +1,61 @@ +import { z } from "zod"; + +import { buildRoute } from "@/api/lib/route"; +import { EmailVerificationModel } from "@/api/models/email-verification"; +import { CONFIG_PLUGIN } from "@/config"; + +export const zodVerifyEmailSchema = z.object({ + token: z + .string() + .min(16) + .max(512) + .regex(/^[A-Za-z0-9_-]+$/) + .openapi({ example: "abcdefg12345abcdefg12345" }), +}); + +export const verifyEmailRoute = buildRoute({ + pluginId: CONFIG_PLUGIN.pluginId, + route: { + method: "post", + description: "Confirm an email address with the link sent at sign-up", + path: "/verify-email", + request: { + body: { + required: true, + content: { + "application/json": { + schema: zodVerifyEmailSchema, + }, + }, + }, + }, + responses: { + 200: { + content: { + "application/json": { + schema: z.object({ email: z.string() }), + }, + }, + description: "Email confirmed - the member can sign in now", + }, + 400: { + description: "The link is wrong, already used or expired", + }, + }, + }, + handler: async c => { + const { token } = c.req.valid("json"); + + // Deliberately no session here. Whoever holds the link owns the mailbox, + // not necessarily the password - an account somebody else registered with + // your address is confirmed by your click, but it must not sign *you* into + // a stranger's account. The member signs in with their own password next. + const user = await new EmailVerificationModel(c).verify(token); + + if (!user) { + return c.text("Invalid or expired token", 400); + } + + return c.json({ email: user.email }, 200); + }, +}); diff --git a/packages/vitnode/src/api/modules/users/sso/routes/callback.route.ts b/packages/vitnode/src/api/modules/users/sso/routes/callback.route.ts index 12b0e5cd7f..523a312f34 100644 --- a/packages/vitnode/src/api/modules/users/sso/routes/callback.route.ts +++ b/packages/vitnode/src/api/modules/users/sso/routes/callback.route.ts @@ -26,7 +26,6 @@ export const callbackRoute = buildRoute({ "application/json": { schema: z.object({ id: z.number(), - token: z.string(), }), }, }, @@ -63,10 +62,9 @@ export const callbackRoute = buildRoute({ ); } - const { token } = await new SessionModel(c).createSessionByUserId( - outcome.userId, - ); + // The session lives in the HttpOnly cookie only - see the sign-in route. + await new SessionModel(c).createSessionByUserId(outcome.userId); - return c.json({ id: outcome.userId, token }, 200); + return c.json({ id: outcome.userId }, 200); }, }); diff --git a/packages/vitnode/src/api/modules/users/sso/routes/callback.test.ts b/packages/vitnode/src/api/modules/users/sso/routes/callback.test.ts index 7fabfdc633..f33f16be50 100644 --- a/packages/vitnode/src/api/modules/users/sso/routes/callback.test.ts +++ b/packages/vitnode/src/api/modules/users/sso/routes/callback.test.ts @@ -133,7 +133,8 @@ describe("signing in through an SSO provider", () => { const response = await h.signInThroughGoogle(); expect(response.status).toBe(200); - expect(await response.json()).toEqual({ id: 7, token: "session-token" }); + // The session token lives in the HttpOnly cookie, never in the body. + expect(await response.json()).toEqual({ id: 7 }); expect(h.createSession).toHaveBeenCalledWith(7); expect(h.createAdminSession).not.toHaveBeenCalled(); expect(response.headers.getSetCookie().join(";")).not.toContain( diff --git a/packages/vitnode/src/api/modules/users/sso/routes/link.route.ts b/packages/vitnode/src/api/modules/users/sso/routes/link.route.ts index 3249b3b9a4..076bc873f7 100644 --- a/packages/vitnode/src/api/modules/users/sso/routes/link.route.ts +++ b/packages/vitnode/src/api/modules/users/sso/routes/link.route.ts @@ -6,8 +6,14 @@ import { SessionModel } from "@/api/models/session"; import { SSOModel } from "@/api/models/sso"; import { CONFIG_PLUGIN } from "@/config"; +import { USER_PASSWORD_MAX_LENGTH } from "../../credential-limits"; + export const zodSsoLinkSchema = z.object({ - password: z.string().min(1).max(1024).openapi({ example: "Test123!" }), + password: z + .string() + .min(1) + .max(USER_PASSWORD_MAX_LENGTH) + .openapi({ example: "Test123!" }), token: z.string().min(16).max(2048), }); @@ -37,7 +43,6 @@ export const linkRoute = buildRoute({ "application/json": { schema: z.object({ id: z.number(), - token: z.string(), }), }, }, @@ -63,8 +68,9 @@ export const linkRoute = buildRoute({ providerId, token, }); - const session = await new SessionModel(c).createSessionByUserId(userId); + // The session lives in the HttpOnly cookie only - see the sign-in route. + await new SessionModel(c).createSessionByUserId(userId); - return c.json({ id: userId, token: session.token }, 201); + return c.json({ id: userId }, 201); }, }); diff --git a/packages/vitnode/src/api/modules/users/users.module.ts b/packages/vitnode/src/api/modules/users/users.module.ts index a61576b3f4..b4d646263c 100644 --- a/packages/vitnode/src/api/modules/users/users.module.ts +++ b/packages/vitnode/src/api/modules/users/users.module.ts @@ -9,6 +9,7 @@ import { listDevicesRoute } from "./routes/devices.route"; import { mePolicyRoute } from "./routes/me-policy.route"; import { permissionsRoute } from "./routes/permissions.route"; import { profileRoute } from "./routes/profile.route"; +import { resendVerificationRoute } from "./routes/resend-verification.route"; import { resetPasswordRoute } from "./routes/reset-passowrd.route"; import { revokeDeviceRoute } from "./routes/revoke-device.route"; import { sessionRoute } from "./routes/session.route"; @@ -17,6 +18,7 @@ import { signOutRoute } from "./routes/sign-out.route"; import { signUpRoute } from "./routes/sign-up.route"; import { updateMyTimeZoneRoute } from "./routes/time-zone.route"; import { updateMeRoute } from "./routes/update-me.route"; +import { verifyEmailRoute } from "./routes/verify-email.route"; import { ssoUserModule } from "./sso/sso.module"; export const usersModule = buildModule({ @@ -28,6 +30,8 @@ export const usersModule = buildModule({ signOutRoute, signUpRoute, resetPasswordRoute, + verifyEmailRoute, + resendVerificationRoute, changePasswordRoute, permissionsRoute, listDevicesRoute, diff --git a/packages/vitnode/src/emails/verify-email.tsx b/packages/vitnode/src/emails/verify-email.tsx new file mode 100644 index 0000000000..0cc06e4417 --- /dev/null +++ b/packages/vitnode/src/emails/verify-email.tsx @@ -0,0 +1,149 @@ +import { Hr, Link, Section, Text } from "react-email"; +import { createTranslator } from "use-intl"; + +import DefaultTemplateEmail, { + type DefaultTemplateEmailProps, +} from "./default-template"; +import { EmailButton } from "./ui/button"; +import { + EmailCard, + EmailCardContent, + EmailCardDescription, + EmailCardFooter, + EmailCardHeader, + EmailCardTitle, +} from "./ui/card"; + +interface VerifyEmailTemplateProps extends DefaultTemplateEmailProps { + expiryDate: Date; + userIpAddress?: string; + verifyUrl: string; +} + +VerifyEmailTemplate.PreviewProps = { + ...DefaultTemplateEmail.PreviewProps, + verifyUrl: "https://example.com/login/verify-email?token=abc123", + expiryDate: new Date(Date.now() + 24 * 60 * 60 * 1000), + userIpAddress: "192.168.1.1", + user: { + id: 1, + name: "John Doe", + email: "john@example.com", + language: "en", + nameCode: "john-doe", + }, + i18n: { + ...DefaultTemplateEmail.PreviewProps.i18n, + messages: { + core: { + auth: { + verify_email: { + email: { + subject: "Confirm your email address", + greeting: "Hello {name}!", + intro: + "Thanks for signing up! Confirm that this is your email address to finish creating your account.", + button: "Confirm email address", + instructions: + "Click the button above to confirm your email address. You can sign in as soon as it's confirmed.", + no_action: + "If you didn't create an account, you can safely ignore this email - nobody can sign in with this address until it's confirmed.", + security_note: + "For your security, this account was created from IP address: {ip}", + help: "If you're having trouble with the button above, copy and paste the URL below into your web browser:", + expire_time: "This link will expire on {date}", + }, + }, + }, + }, + }, + }, +} satisfies VerifyEmailTemplateProps; + +export default function VerifyEmailTemplate({ + i18n, + verifyUrl, + expiryDate, + userIpAddress, + user, + ...props +}: VerifyEmailTemplateProps) { + const t = createTranslator(i18n); + + const userName = user?.name ?? "there"; + + return ( + + + + + {t("core.auth.verify_email.email.greeting", { name: userName })} + + + {t("core.auth.verify_email.email.intro")} + + + + +
+ + {t("core.auth.verify_email.email.button")} + +
+ + + {t("core.auth.verify_email.email.instructions")} + + + + {t("core.auth.verify_email.email.expire_time", { + date: expiryDate.toLocaleString(i18n.locale, { + year: "numeric", + month: "long", + day: "numeric", + hour: "2-digit", + minute: "2-digit", + timeZoneName: "short", + }), + })} + +
+ + +
+ + + {t("core.auth.verify_email.email.no_action")} + + + {userIpAddress && ( + + {t("core.auth.verify_email.email.security_note", { + ip: userIpAddress, + })} + + )} +
+
+ +
+ + {t("core.auth.verify_email.email.help")} + + + {verifyUrl} + +
+
+ ); +} diff --git a/packages/vitnode/src/locales/api/en.json b/packages/vitnode/src/locales/api/en.json index e9a84b46a9..c1ab67e067 100644 --- a/packages/vitnode/src/locales/api/en.json +++ b/packages/vitnode/src/locales/api/en.json @@ -13,6 +13,19 @@ "help": "If you're having trouble with the button above, copy and paste the URL below into your web browser:", "expire_time": "This link will expire on {date}" } + }, + "verify_email": { + "email": { + "subject": "Confirm your email address", + "greeting": "Hello {name}!", + "intro": "Thanks for signing up! Confirm that this is your email address to finish creating your account.", + "button": "Confirm email address", + "instructions": "Click the button above to confirm your email address. You can sign in as soon as it's confirmed.", + "no_action": "If you didn't create an account, you can safely ignore this email - nobody can sign in with this address until it's confirmed.", + "security_note": "For your security, this request was made from IP address: {ip}", + "help": "If you're having trouble with the button above, copy and paste the URL below into your web browser:", + "expire_time": "This link will expire on {date}" + } } }, "notifications": { diff --git a/packages/vitnode/src/locales/en.json b/packages/vitnode/src/locales/en.json index 0d631835e2..235b55fb25 100644 --- a/packages/vitnode/src/locales/en.json +++ b/packages/vitnode/src/locales/en.json @@ -833,7 +833,8 @@ "email_confirmation": { "title": "Check your email", "desc": "We've sent a confirmation link to your email address", - "check_spam": "If you don't see the email in your inbox, please check your spam folder." + "check_spam": "If you don't see the email in your inbox, please check your spam folder.", + "resend": "Didn't get it? Send a new link." }, "unavailable": { "title": "Registration is unavailable", @@ -856,6 +857,11 @@ "access_denied": { "title": "Invalid credentials", "desc": "The email address or password was incorrect. Please try again (make sure your caps lock is off)." + }, + "email_not_verified": { + "title": "Confirm your email first", + "desc": "Your password is right, but this account's email address isn't confirmed yet. Click the link we emailed you, then sign in again.", + "resend": "Send a new confirmation link" } }, "submit": "Login", @@ -890,6 +896,10 @@ "not_staff": { "title": "No AdminCP access", "desc": "Your passkey checks out, but this account can't open the AdminCP. Ask an administrator if you think that's a mistake." + }, + "email_not_verified": { + "title": "Confirm your email first", + "desc": "This account's email address isn't confirmed yet. Click the link we emailed you, then try again." } } }, @@ -1327,6 +1337,33 @@ "details": "See your AI features" } } + }, + "verify_email": { + "title": "Confirm your email", + "confirm": { + "desc": "Almost there! Confirm that this is your email address and your account is ready to go.", + "submit": "Confirm email address" + }, + "success": { + "title": "Email confirmed", + "desc": "Thanks! is confirmed - you can sign in now.", + "sign_in": "Go to sign in" + }, + "invalid": { + "title": "This link doesn't work anymore", + "desc": "It may have expired or already been used. Ask for a fresh one and we'll send it right over.", + "resend": "Send a new link" + }, + "resend": { + "title": "Resend confirmation email", + "desc": "Enter the email address you signed up with and we'll send you a new confirmation link.", + "submit": "Send confirmation link", + "confirmation": { + "title": "Check your email", + "desc": "If this address has an account waiting to be confirmed, a new link is on its way to:", + "check_spam": "If you don't see the email in your inbox, please check your spam folder." + } + } } }, "content": { diff --git a/packages/vitnode/src/pages/login/verify-email.tsx b/packages/vitnode/src/pages/login/verify-email.tsx new file mode 100644 index 0000000000..c955db48de --- /dev/null +++ b/packages/vitnode/src/pages/login/verify-email.tsx @@ -0,0 +1,35 @@ +import type { PluginRoutePageProps } from "@/routing"; +import type { VerifyEmailSearch } from "@/tanstack/auth/verify-email"; + +import { PasswordRecoveryNotFound } from "@/tanstack/auth/recovery-screen"; +import { loadVerifyEmailRoute } from "@/tanstack/auth/verify-email-route"; +import { + VerifyEmailBreadcrumb, + VerifyEmailRouteContent, +} from "@/tanstack/auth/verify-email-screen"; +import { ErrorActions } from "@/tanstack/layout/error-actions"; +import { defineRoute } from "@/tanstack/plugin-routes"; + +const VerifyEmailPage = ({ + search, +}: PluginRoutePageProps) => ( + +); + +export const route = defineRoute({ + load: async ({ context }) => { + await loadVerifyEmailRoute(context); + }, + head: ({ t }) => ({ + robots: "noindex, nofollow", + title: t("core.auth.verify_email.title"), + }), + + breadcrumb: VerifyEmailBreadcrumb, + + notFound: function EmailVerificationNotFoundScreen() { + return } />; + }, +}); + +export default VerifyEmailPage; diff --git a/packages/vitnode/src/routes.tsx b/packages/vitnode/src/routes.tsx index 5a2e23a263..ff3db15817 100644 --- a/packages/vitnode/src/routes.tsx +++ b/packages/vitnode/src/routes.tsx @@ -40,6 +40,10 @@ import { normalizeSsoCallbackSearch, } from "./tanstack/auth/route-search"; import { SSO_CALLBACK_NAMESPACES } from "./tanstack/auth/sso-route"; +import { + normalizeVerifyEmailSearch, + VERIFY_EMAIL_NAMESPACES, +} from "./tanstack/auth/verify-email"; import { MY_FILES_NAMESPACES } from "./tanstack/files/route"; import { normalizeMyFilesRouteSearch } from "./tanstack/files/route-search"; import { NOTIFICATIONS_NAMESPACES } from "./tanstack/notifications/namespaces"; @@ -120,6 +124,15 @@ export const routes = defineRoutes([ search: normalizePasswordResetSearch, }), + page("/login/verify-email", { + component: lazy(() => import("./pages/login/verify-email")), + // Declared rather than loaded inside `load`: the crumb renders one of + // these strings, and a breadcrumb is drawn outside the page. + messages: VERIFY_EMAIL_NAMESPACES, + pendingComponent: AuthPendingSkeleton, + search: normalizeVerifyEmailSearch, + }), + page("/login/sso/:providerId", { component: lazy(() => import("./pages/login/sso")), messages: SSO_CALLBACK_NAMESPACES, diff --git a/packages/vitnode/src/tanstack/auth/actions.ts b/packages/vitnode/src/tanstack/auth/actions.ts index 19ab4b00a4..73c603e24d 100644 --- a/packages/vitnode/src/tanstack/auth/actions.ts +++ b/packages/vitnode/src/tanstack/auth/actions.ts @@ -9,6 +9,10 @@ import type { SignUpSubmit } from "@/views/auth/sign-up/form/sign-up-form-conten import type { SSOSelectProvider } from "@/views/auth/sso/buttons/sso-buttons-content"; import type { SSOCallbackResult } from "@/views/auth/sso/callback/sso-callback-result"; import type { SSOLinkSubmit } from "@/views/auth/sso/link/use-sso-link-form"; +import type { + ConfirmEmailSubmit, + ResendVerificationSubmit, +} from "@/views/auth/verify-email/schema"; import { getPasskeyInBrowser } from "@/views/auth/passkeys/webauthn"; @@ -20,7 +24,9 @@ import { removeUserIdentityQueries } from "./queries"; import { anonymousSession, changePasswordFormResult, + confirmEmailResult, passwordResetFormResult, + resendVerificationFormResult, signInFormResult, signUpFormResult, ssoCallbackResult, @@ -218,3 +224,19 @@ export const changePasswordFromResetAction: ChangePasswordSubmit = changePasswordFormResult( await authTransport().changePasswordFromReset(values), ); + +/** + * Spends the confirmation link a sign-up emailed. No session comes back - the + * member signs in with their own password afterwards - so there is no identity + * boundary to cross and nothing cached to drop. + */ +export const confirmEmailAction = + (token: string): ConfirmEmailSubmit => + async () => + confirmEmailResult(await authTransport().verifyEmail({ token })); + +export const resendEmailVerificationAction: ResendVerificationSubmit = + async values => + resendVerificationFormResult( + await authTransport().resendEmailVerification(values), + ); diff --git a/packages/vitnode/src/tanstack/auth/contract.ts b/packages/vitnode/src/tanstack/auth/contract.ts index c76d617576..e06e055217 100644 --- a/packages/vitnode/src/tanstack/auth/contract.ts +++ b/packages/vitnode/src/tanstack/auth/contract.ts @@ -46,7 +46,10 @@ export const ssoCallbackInputSchema = z.object({ export type SsoCallbackInput = z.infer; export type SignInResult = - | { ok: false; reason: "access_denied" | "server_error" } + | { + ok: false; + reason: "access_denied" | "email_not_verified" | "server_error"; + } | { ok: true }; export type PasskeySignInOptions = z.infer< @@ -66,7 +69,14 @@ export type PasskeySignInStartResult = | { ok: true; options: PasskeySignInOptions }; export type PasskeySignInResult = - | { ok: false; reason: "access_denied" | "expired" | "server_error" } + | { + ok: false; + reason: + | "access_denied" + | "email_not_verified" + | "expired" + | "server_error"; + } | { ok: true }; export type AdminPasskeySignInResult = @@ -127,9 +137,24 @@ export const isUsableSessionStatus = (status: number): boolean => export const SESSION_UNAVAILABLE = "The session could not be read."; -export const signInResultFromStatus = (status: number): SignInResult => { +const emailNotVerifiedBodySchema = z.object({ + error: z.literal("email_not_verified"), +}); + +/** Whether a refused sign-in was refused only because the address is unconfirmed. */ +export const isEmailNotVerifiedBody = (body: unknown): boolean => + emailNotVerifiedBodySchema.safeParse(body).success; + +export const signInResultFromStatus = ( + status: number, + body?: unknown, +): SignInResult => { if (status === 201) return { ok: true }; - if (status === 403) return { ok: false, reason: "access_denied" }; + if (status === 403) { + return isEmailNotVerifiedBody(body) + ? { ok: false, reason: "email_not_verified" } + : { ok: false, reason: "access_denied" }; + } return { ok: false, reason: "server_error" }; }; @@ -146,9 +171,14 @@ export const passkeySignInStartResultFromStatus = ( return { ok: true, options: body }; }; -export const passkeySignInResultFromStatus = ( +/** What every passkey sign-in, public or AdminCP, can answer. */ +type PasskeyCeremonyResult = + | { ok: false; reason: "access_denied" | "expired" | "server_error" } + | { ok: true }; + +const passkeyCeremonyResultFromStatus = ( status: number, -): PasskeySignInResult => { +): PasskeyCeremonyResult => { if (status === 201) return { ok: true }; if (status === 400) return { ok: false, reason: "expired" }; if (status === 403) return { ok: false, reason: "access_denied" }; @@ -156,6 +186,14 @@ export const passkeySignInResultFromStatus = ( return { ok: false, reason: "server_error" }; }; +export const passkeySignInResultFromStatus = ( + status: number, + body?: unknown, +): PasskeySignInResult => + status === 403 && isEmailNotVerifiedBody(body) + ? { ok: false, reason: "email_not_verified" } + : passkeyCeremonyResultFromStatus(status); + const notStaffBodySchema = z.object({ error: z.literal("not_staff") }); export const adminPasskeySignInResultFromStatus = ( @@ -166,7 +204,7 @@ export const adminPasskeySignInResultFromStatus = ( return { ok: false, reason: "not_staff" }; } - return passkeySignInResultFromStatus(status); + return passkeyCeremonyResultFromStatus(status); }; export const signOutResultFromStatus = (status: number): SignOutResult => @@ -388,4 +426,56 @@ export const changePasswordResultFromStatus = ( return { ok: false, reason: "server_error" }; }; +/** The link a confirmation email carries - the same alphabet a reset token uses. */ +export const verifyEmailInputSchema = z.object({ + token: z + .string() + .min(16) + .max(512) + .regex(/^[A-Za-z0-9_-]+$/), +}); + +export type VerifyEmailInput = z.infer; + +export type VerifyEmailResult = + | { email: string; ok: true } + | { ok: false; reason: "invalid_token" | "rate_limited" | "server_error" }; + +const verifyEmailSuccessSchema = z.object({ email: z.string() }); + +export const verifyEmailResultFromStatus = ( + status: number, + body?: unknown, +): VerifyEmailResult => { + if (status === 200) { + const parsed = verifyEmailSuccessSchema.safeParse(body); + + return parsed.success + ? { email: parsed.data.email, ok: true } + : { ok: false, reason: "server_error" }; + } + if (status === 400) return { ok: false, reason: "invalid_token" }; + if (status === RATE_LIMIT_STATUS) + return { ok: false, reason: "rate_limited" }; + + return { ok: false, reason: "server_error" }; +}; + +/** A request for a fresh confirmation link: one address, and the captcha. */ +export const resendEmailVerificationInputSchema = + passwordResetRequestInputSchema; + +export type ResendEmailVerificationInput = z.infer< + typeof resendEmailVerificationInputSchema +>; + +/** + * Like a reset request, an accepted resend says nothing about the address - the + * API answers the same 201 whether or not there was anything to send. + */ +export type ResendEmailVerificationResult = PasswordResetRequestResult; + +export const resendEmailVerificationResultFromStatus = + passwordResetRequestResultFromStatus; + export { shouldSaveApiCookies } from "@/lib/fetcher/set-cookie"; diff --git a/packages/vitnode/src/tanstack/auth/default-transport.ts b/packages/vitnode/src/tanstack/auth/default-transport.ts index 9cc851ced7..b4ea55ac95 100644 --- a/packages/vitnode/src/tanstack/auth/default-transport.ts +++ b/packages/vitnode/src/tanstack/auth/default-transport.ts @@ -80,6 +80,16 @@ const operations = createAuthOperations({ path: "/reset-password", }), + resendEmailVerification: async ({ captchaToken, email }) => + await fetcher({ + plugin: CONFIG_PLUGIN.pluginId, + captchaToken, + args: { body: { email } }, + method: "post", + module: "users", + path: "/verify-email/resend", + }), + signIn: async data => await fetcher({ plugin: CONFIG_PLUGIN.pluginId, @@ -132,6 +142,15 @@ const operations = createAuthOperations({ module: "users/sso", path: "/{providerId}", }), + + verifyEmail: async data => + await fetcher({ + plugin: CONFIG_PLUGIN.pluginId, + args: { body: data }, + method: "post", + module: "users", + path: "/verify-email", + }), }); export const readSessionFromApi = operations.readSession; @@ -144,10 +163,12 @@ export const defaultAuthTransport = { linkSso: operations.linkSso, readSession: operations.readSession, requestPasswordReset: operations.requestPasswordReset, + resendEmailVerification: operations.resendEmailVerification, signIn: operations.signIn, signOut: operations.signOut, signUp: operations.signUp, startAdminPasskeySignIn: operations.startAdminPasskeySignIn, startPasskeySignIn: operations.startPasskeySignIn, startSso: operations.startSso, + verifyEmail: operations.verifyEmail, }; diff --git a/packages/vitnode/src/tanstack/auth/email-verification.test.ts b/packages/vitnode/src/tanstack/auth/email-verification.test.ts new file mode 100644 index 0000000000..74002b4020 --- /dev/null +++ b/packages/vitnode/src/tanstack/auth/email-verification.test.ts @@ -0,0 +1,182 @@ +import { describe, expect, it, vi } from "vitest"; + +import { signInFormOutcome } from "@/views/auth/sign-in/form/schema"; + +import type { AuthApiRequester } from "./transport-operations"; + +import { + adminPasskeySignInResultFromStatus, + passkeySignInResultFromStatus, + signInResultFromStatus, + verifyEmailResultFromStatus, +} from "./contract"; +import { confirmEmailResult, signInFormResult } from "./screens"; +import { createAuthOperations } from "./transport-operations"; +import { + emailVerificationAvailability, + normalizeVerifyEmailSearch, + verifyEmailMode, +} from "./verify-email"; + +const NOT_VERIFIED = { error: "email_not_verified" }; +const LINK = "confirm-token-confirm-token-confirm-token"; + +describe("a sign-in refused because the address is unconfirmed", () => { + it("is told apart from wrong credentials by its body", () => { + expect(signInResultFromStatus(403, NOT_VERIFIED)).toEqual({ + ok: false, + reason: "email_not_verified", + }); + expect(signInResultFromStatus(403, "Forbidden")).toEqual({ + ok: false, + reason: "access_denied", + }); + }); + + it("is shown in the form, where the resend link lives", () => { + const result = signInFormResult({ + ok: false, + reason: "email_not_verified", + }); + + expect(result).toEqual({ message: "email_not_verified" }); + expect(signInFormOutcome(result)).toEqual({ + error: "email_not_verified", + kind: "field", + }); + }); + + it("is told apart for a public passkey sign-in, but not the AdminCP one", () => { + expect(passkeySignInResultFromStatus(403, NOT_VERIFIED)).toEqual({ + ok: false, + reason: "email_not_verified", + }); + expect(adminPasskeySignInResultFromStatus(403, NOT_VERIFIED)).toEqual({ + ok: false, + reason: "access_denied", + }); + }); +}); + +const unreachable = () => { + throw new Error("not part of this suite"); +}; + +const requester = ( + overrides: Partial>, +): AuthApiRequester => ({ + changePasswordFromReset: unreachable, + completeSso: unreachable, + finishAdminPasskeySignIn: unreachable, + finishPasskeySignIn: unreachable, + linkSso: unreachable, + readSession: unreachable, + requestPasswordReset: unreachable, + resendEmailVerification: unreachable, + signIn: unreachable, + signOut: unreachable, + signUp: unreachable, + startAdminPasskeySignIn: unreachable, + startPasskeySignIn: unreachable, + startSso: unreachable, + verifyEmail: unreachable, + ...overrides, +}); + +const answer = (status: number, body: unknown) => ({ + json: async () => Promise.resolve(body), + status, +}); + +describe("the auth operations", () => { + it("read the refusal body of a password sign-in", async () => { + const operations = createAuthOperations( + requester({ + signIn: async () => Promise.resolve(answer(403, NOT_VERIFIED)), + }), + ); + + expect( + await operations.signIn({ email: "a@b.com", password: "x" }), + ).toEqual({ ok: false, reason: "email_not_verified" }); + }); + + it("send the link's token and read back the confirmed address", async () => { + const verifyEmail = vi.fn(async () => + Promise.resolve(answer(200, { email: "a@b.com" })), + ); + const operations = createAuthOperations(requester({ verifyEmail })); + + expect(await operations.verifyEmail({ token: LINK })).toEqual({ + email: "a@b.com", + ok: true, + }); + expect(verifyEmail).toHaveBeenCalledWith({ token: LINK }); + }); + + it("answer a resend the same way whatever the address", async () => { + const operations = createAuthOperations( + requester({ + resendEmailVerification: async () => Promise.resolve({ status: 201 }), + }), + ); + + expect( + await operations.resendEmailVerification({ + captchaToken: "", + email: "a@b.com", + }), + ).toEqual({ ok: true }); + }); +}); + +describe("confirming a link", () => { + it("maps the API's answers onto what the screen shows", () => { + expect(confirmEmailResult(verifyEmailResultFromStatus(400))).toEqual({ + kind: "invalid_token", + }); + expect(confirmEmailResult(verifyEmailResultFromStatus(500))).toEqual({ + kind: "error", + }); + expect( + confirmEmailResult( + verifyEmailResultFromStatus(200, { email: "a@b.com" }), + ), + ).toEqual({ email: "a@b.com", kind: "confirmed" }); + }); + + it("treats a 200 without the documented body as a failure", () => { + expect(verifyEmailResultFromStatus(200, {})).toEqual({ + ok: false, + reason: "server_error", + }); + }); +}); + +describe("the verify-email URL", () => { + it("offers the confirm button for a usable link", () => { + expect( + verifyEmailMode(normalizeVerifyEmailSearch({ token: LINK })), + ).toEqual({ mode: "confirm", token: LINK }); + }); + + it("offers the resend form without a link, or with one that cannot be valid", () => { + for (const token of [undefined, "", "short", `${LINK}"), + "cat.gif", + "image/gif", + ), + ); + + expect((error as HTTPException).status).toBe(400); + expect((error as HTTPException).message).toContain("not a GIF image"); + expect(upload).not.toHaveBeenCalled(); + }); + + it("leaves SVG to its own type", async () => { + const svg = Buffer.from( + '', + ); + const { ctx, upload } = makeCtx({ quality: 85 }); + + await new StorageModel(ctx).upload({ + file: fileFrom(svg, "logo.svg", "image/svg+xml"), + folder: "logos", + }); + + expect(upload.mock.calls[0][0].body.equals(svg)).toBe(true); + expect(upload.mock.calls[0][0].contentType).toBe("image/svg+xml"); + }); + }); + it("leaves non-image files untouched even when image config is set", async () => { const original = Buffer.from("just some text"); const { ctx, upload } = makeCtx({ quality: 40 }); diff --git a/packages/vitnode/src/api/models/storage.test.ts b/packages/vitnode/src/api/models/storage.test.ts index 70d8dff33b..3694fa3734 100644 --- a/packages/vitnode/src/api/models/storage.test.ts +++ b/packages/vitnode/src/api/models/storage.test.ts @@ -7,6 +7,11 @@ import { core_content_file_refs } from "@/database/content"; import { STORAGE_FILE_IN_USE, StorageModel } from "./storage"; +// The bytes have to open like a PNG for an `image/png` upload to be accepted. +const PNG_SIGNATURE = new Uint8Array([ + 0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a, +]); + const makeCtx = ( overrides: { admin?: unknown; storage?: unknown } = {}, ): { @@ -150,7 +155,7 @@ const makeDeleteCtx = ( describe("StorageModel.upload", () => { it("uploads under {year}/{month}/{folder} with a generated file name", async () => { const { ctx, insertValues, upload } = makeCtx(); - const file = new File(["hello"], "photo.png", { type: "image/png" }); + const file = new File([PNG_SIGNATURE], "photo.png", { type: "image/png" }); const result = await new StorageModel(ctx).upload({ file, @@ -180,7 +185,7 @@ describe("StorageModel.upload", () => { it("records the admin's user id when uploaded from an admin session", async () => { const { ctx, insertValues } = makeCtx({ admin: { user: { id: 42 } } }); - const file = new File(["hi"], "a.png", { type: "image/png" }); + const file = new File([PNG_SIGNATURE], "a.png", { type: "image/png" }); await new StorageModel(ctx).upload({ file, folder: "avatars" }); @@ -190,7 +195,7 @@ describe("StorageModel.upload", () => { it("uses an explicit userId over the detected session user", async () => { const { ctx, insertValues } = makeCtx({ admin: { user: { id: 42 } } }); - const file = new File(["hi"], "a.png", { type: "image/png" }); + const file = new File([PNG_SIGNATURE], "a.png", { type: "image/png" }); // Admin uploading on behalf of user 123 - explicit owner wins over admin (42). await new StorageModel(ctx).upload({ @@ -204,7 +209,7 @@ describe("StorageModel.upload", () => { it("throws when no storage provider is configured", async () => { const { ctx, upload } = makeCtx({ storage: undefined }); - const file = new File(["hi"], "a.png", { type: "image/png" }); + const file = new File([PNG_SIGNATURE], "a.png", { type: "image/png" }); await expect( new StorageModel(ctx).upload({ file, folder: "avatars" }), @@ -240,7 +245,7 @@ describe("StorageModel.upload", () => { it("uploads when the file passes size and type validation", async () => { const { ctx, upload } = makeCtx(); - const file = new File(["ok"], "photo.png", { type: "image/png" }); + const file = new File([PNG_SIGNATURE], "photo.png", { type: "image/png" }); await new StorageModel(ctx).upload({ file, diff --git a/packages/vitnode/src/api/models/storage.ts b/packages/vitnode/src/api/models/storage.ts index 52caca9b18..ca4621c474 100644 --- a/packages/vitnode/src/api/models/storage.ts +++ b/packages/vitnode/src/api/models/storage.ts @@ -6,6 +6,10 @@ import { createHash } from "node:crypto"; import type { StorageFileInUseBody } from "@/lib/files/in-use"; +import { + detectImageMimeType, + isSignedImageMimeType, +} from "@/api/lib/image-signature"; import { findUserImageHolders, releaseUserImageHolders, @@ -25,6 +29,14 @@ import { enqueueAltAfterUpload } from "../lib/ai/alt"; const DEFAULT_IMAGE_QUALITY = 85; +/** + * The most pixels sharp will decode, unless `storage.image.maxPixels` says + * otherwise. sharp's own default is ~268 million, and a 16383x16383 PNG of one + * colour compresses to under a megabyte while asking libwebp for gigabytes. + * 40 megapixels still fits an 8K frame (33 MP) with room to spare. + */ +export const DEFAULT_IMAGE_MAX_PIXELS = 40_000_000; + // Formats sharp can lossily re-encode. SVG/GIF are intentionally excluded to // preserve vectors and animation. const PROCESSABLE_IMAGE_MIME_TYPES = new Set([ @@ -141,6 +153,47 @@ const exceedsWebpLimit = ( const imageFormatName = (mimeType: string): string => (mimeType.split("/")[1] ?? mimeType).toUpperCase(); +const tooManyPixels = ({ + dimensions, + maxPixels, +}: { + dimensions: null | { height: number; width: number }; + maxPixels: number; +}): StorageImageUnprocessableError => { + const size = dimensions + ? `${dimensions.width}\u00d7${dimensions.height} pixels` + : "larger than allowed"; + + return new StorageImageUnprocessableError( + `This image is ${size}. The maximum is ${maxPixels.toLocaleString("en-US")} pixels in total - resize it and upload it again.`, + ); +}; + +/** + * The declared type is what the client said, and it is also what a storage + * provider will later serve the object as. For the raster formats that carry a + * signature, the bytes have to agree. + */ +const assertDeclaredImageType = (body: Buffer, mimeType: string): void => { + if (!isSignedImageMimeType(mimeType)) return; + + const declared = imageFormatName(mimeType); + if (body.length === 0) { + throw new HTTPException(400, { + message: `This ${declared} file is empty.`, + }); + } + + const detected = detectImageMimeType(body); + if (detected === mimeType) return; + + throw new HTTPException(400, { + message: detected + ? `This file was sent as ${declared}, but its contents are ${imageFormatName(detected)}. Save it as ${declared} or give it the right extension, then upload it again.` + : `This file was sent as ${declared}, but its contents are not a ${declared} image. It may be truncated, or another format saved under the wrong extension.`, + }); +}; + const reasonSuffix = (error: unknown): string => { const first = error instanceof Error @@ -229,6 +282,10 @@ export class StorageModel { const quality = image.quality ?? DEFAULT_IMAGE_QUALITY; const toWebp = image.webp !== false; + const maxPixels = image.maxPixels ?? DEFAULT_IMAGE_MAX_PIXELS; + // Enforced by libvips while it reads the header, so an oversized image is + // refused before a single pixel of it is decoded. + const sharpOptions = { limitInputPixels: maxPixels }; let sharp; try { @@ -250,8 +307,15 @@ export class StorageModel { // sent people off to re-export an image that was never corrupt. let metadata; try { - metadata = await sharp(body).metadata(); + metadata = await sharp(body, sharpOptions).metadata(); } catch (error) { + if ( + error instanceof Error && + /exceeds pixel limit/i.test(error.message) + ) { + throw tooManyPixels({ dimensions: null, maxPixels }); + } + throw new HTTPException(400, { message: `Could not read this ${imageFormatName(mimeType)} file${reasonSuffix(error)}. It may be truncated, or another format saved under the wrong extension.`, }); @@ -272,6 +336,10 @@ export class StorageModel { ? { width: metadata.width, height: metadata.height } : null; + if (dimensions && dimensions.width * dimensions.height > maxPixels) { + throw tooManyPixels({ dimensions, maxPixels }); + } + // A 2944x16384 PNG is one pixel too tall for WebP and an entirely valid PNG, // so it is stored as a PNG. Checked here rather than caught from the encoder // because the right answer is not to refuse the upload: `storage.image.webp` @@ -286,7 +354,9 @@ export class StorageModel { let output: Buffer; try { - output = await sharp(body).toFormat(targetFormat, { quality }).toBuffer(); + output = await sharp(body, sharpOptions) + .toFormat(targetFormat, { quality }) + .toBuffer(); } catch (error) { throw imageEncodeFailure({ dimensions, @@ -481,10 +551,10 @@ export class StorageModel { }); } - const processed = await this.processImage( - Buffer.from(await file.arrayBuffer()), - file.type, - ); + const body = Buffer.from(await file.arrayBuffer()); + assertDeclaredImageType(body, file.type); + + const processed = await this.processImage(body, file.type); // When a conversion changed the format, reflect the new extension in both // the stored key and the display name so downloads and previews are honest. diff --git a/packages/vitnode/src/vitnode.config.ts b/packages/vitnode/src/vitnode.config.ts index dca1cfb369..eaf5b242c3 100644 --- a/packages/vitnode/src/vitnode.config.ts +++ b/packages/vitnode/src/vitnode.config.ts @@ -137,6 +137,12 @@ export interface VitNodeApiConfig { adapter?: StorageApiPlugin; image?: { + /** + * Largest image, in total pixels (width x height), that will be decoded. + * Anything bigger is refused with a 400 before it is processed. + * Defaults to 40 million. + */ + maxPixels?: number; quality?: number; webp?: boolean; }; From 18b1a25b787f2ff1818dc0e159f844a42c533e50 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 7 Oct 2026 06:52:32 +0000 Subject: [PATCH 17/25] =?UTF-8?q?docs(storage):=20=F0=9F=94=92=20mount=20s?= =?UTF-8?q?torageStaticHeadersMiddleware=20in=20local=20upload=20snippets?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Both serveStatic examples for the Local adapter now put storageStaticHeadersMiddleware() in front of the mount, matching apps/api, so same-origin SVG/HTML uploads cannot run script on the site. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01BcTiLrsGK6BGwvs4uieRMB --- apps/web/content/docs/dev/storage/local.mdx | 17 +++++++++++++++-- 1 file changed, 15 insertions(+), 2 deletions(-) diff --git a/apps/web/content/docs/dev/storage/local.mdx b/apps/web/content/docs/dev/storage/local.mdx index a93115dd00..26d0d93072 100644 --- a/apps/web/content/docs/dev/storage/local.mdx +++ b/apps/web/content/docs/dev/storage/local.mdx @@ -73,14 +73,16 @@ derived from your `publicPath` instead of hardcoded in two places. ```ts tab="Standalone API" title="apps/api/src/index.ts" import { serveStatic } from '@hono/node-server/serve-static' +import { storageStaticHeadersMiddleware } from '@vitnode/core/api/middlewares/storage-static.middleware' import { mkdirSync } from 'node:fs' -// [!code ++:14] +// [!code ++:15] const staticStorage = vitNodeApiConfig.storage?.adapter?.static if (staticStorage) { mkdirSync(staticStorage.root, { recursive: true }) app.get( staticStorage.mountPath, + storageStaticHeadersMiddleware(), serveStatic({ root: staticStorage.root, rewriteRequestPath: (path) => @@ -96,17 +98,19 @@ VitNodeAPI({ app, vitNodeApiConfig }) ```ts tab="TanStack Start app" title="src/server/vitnode-api.server.ts" import { serveStatic } from '@hono/node-server/serve-static' +import { storageStaticHeadersMiddleware } from '@vitnode/core/api/middlewares/storage-static.middleware' import { mkdirSync } from 'node:fs' const createVitNodeApi = () => { const app = new OpenAPIHono().basePath('/api') - // [!code ++:14] + // [!code ++:15] const staticStorage = vitNodeApiConfig.storage?.adapter?.static if (staticStorage) { mkdirSync(staticStorage.root, { recursive: true }) app.get( staticStorage.mountPath, + storageStaticHeadersMiddleware(), serveStatic({ root: staticStorage.root, rewriteRequestPath: (path) => @@ -130,6 +134,15 @@ then skips the CORS, CSRF, rate-limiter and global middleware, which a static image has no use for. `mkdirSync` is only there so `serveStatic` does not warn about a missing root before the first upload. + + Local uploads are served from your own origin - the one your session cookie + lives on. Without this middleware, an uploaded SVG or HTML file opened + directly in the browser runs its script **as your site**, signed in as + whoever clicked the link. The middleware adds `Content-Security-Policy: + sandbox` and `X-Content-Type-Options: nosniff`, so stored files can still be + shown in an `` but never execute. It costs nothing - leave it in. + + The TanStack Start app is the same code in a different file, because `/api/*` on that app is one catch-all route handing the request to the very same Hono application - so the mount belongs inside it, not in the router. `serveStatic` From eba45fe05a6cb74fda3dd5514f6ab8f08babe667 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 7 Oct 2026 06:56:15 +0000 Subject: [PATCH 18/25] =?UTF-8?q?docs(storage):=20=F0=9F=93=9D=20format=20?= =?UTF-8?q?image=20optimization=20config=20snippet?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01BcTiLrsGK6BGwvs4uieRMB --- apps/web/content/docs/dev/storage/index.mdx | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/apps/web/content/docs/dev/storage/index.mdx b/apps/web/content/docs/dev/storage/index.mdx index 9028895e69..8f092c35c2 100644 --- a/apps/web/content/docs/dev/storage/index.mdx +++ b/apps/web/content/docs/dev/storage/index.mdx @@ -105,7 +105,8 @@ Turn on `storage.image` and VitNode re-encodes JPEG, PNG, AVIF, TIFF and WebP up export const vitNodeApiConfig = buildApiConfig({ storage: { adapter: LocalStorageAdapter(), - image: { // [!code ++:5] + // [!code ++:5] + image: { quality: 85, // 1-100, defaults to 85 webp: true, // set to false to keep the original format maxPixels: 40_000_000, // width x height, defaults to 40 million @@ -118,7 +119,7 @@ export const vitNodeApiConfig = buildApiConfig({ ### What gets checked on every upload -Whether or not `storage.image` is set, an upload declared as JPEG, PNG, WebP, GIF, AVIF or TIFF must actually *be* that format. The type a client sends is just a claim (a browser guesses it from the file name), so VitNode reads the file's first bytes and refuses a mismatch - say, an HTML page named `cat.gif` - with a `400`. SVG and non-image files have no fixed signature and are not affected; limit them with `allowedMimeTypes`. +Whether or not `storage.image` is set, an upload declared as JPEG, PNG, WebP, GIF, AVIF or TIFF must actually _be_ that format. The type a client sends is just a claim (a browser guesses it from the file name), so VitNode reads the file's first bytes and refuses a mismatch - say, an HTML page named `cat.gif` - with a `400`. SVG and non-image files have no fixed signature and are not affected; limit them with `allowedMimeTypes`. --- From 6b05d22a9a354f30ac28ece8430ddfbee92b4e17 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 7 Oct 2026 06:56:15 +0000 Subject: [PATCH 19/25] =?UTF-8?q?fix(start):=20=F0=9F=94=92=20send=20anti-?= =?UTF-8?q?clickjacking=20and=20hardening=20headers=20with=20HTML=20docume?= =?UTF-8?q?nts?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Rendered pages now carry `Content-Security-Policy: frame-ancestors 'self'`, `X-Frame-Options: SAMEORIGIN`, `X-Content-Type-Options: nosniff` and `Referrer-Policy: strict-origin-when-cross-origin`. A header a route set itself wins, and `createVitNodeStart({ securityHeaders })` adds, replaces or drops (`false`) headers install-wide. No script CSP and no HSTS by default. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01BcTiLrsGK6BGwvs4uieRMB --- apps/web/content/docs/dev/architecture.mdx | 30 +++--- apps/web/content/docs/dev/configuration.mdx | 38 +++++++- .../src/tanstack/start/create-start.ts | 12 ++- .../src/tanstack/start/document-headers.ts | 56 +++++++++++ packages/vitnode/src/tanstack/start/index.ts | 4 + .../src/tanstack/start/locale-middleware.ts | 7 +- .../src/tanstack/start/locale-request.test.ts | 95 +++++++++++++++++++ .../src/tanstack/start/locale-request.ts | 4 + 8 files changed, 223 insertions(+), 23 deletions(-) create mode 100644 packages/vitnode/src/tanstack/start/locale-request.test.ts diff --git a/apps/web/content/docs/dev/architecture.mdx b/apps/web/content/docs/dev/architecture.mdx index fd8e50d291..df99a0d4ca 100644 --- a/apps/web/content/docs/dev/architecture.mdx +++ b/apps/web/content/docs/dev/architecture.mdx @@ -32,15 +32,15 @@ VitNode separates concerns between two core layers: When a user visits a page (e.g. `/blog`): -| Phase | Runtime | Action | -| :------------------------ | :--------------------- | :------------------------------------------------------------------ | -| **1. Request** | Browser | Visitor navigates to `/blog` | -| **2. Routing** | Server (SSR) / Browser | TanStack Router matches route and executes `loader` | -| **3. Query Warming** | Server / Browser | `context.queryClient.query` executes isomorphic fetcher | -| **4. RPC Call** | Server / Browser | The universal `fetcher` calls the Hono endpoint by plugin id | -| **5. API Middleware** | Server (Hono) | Verifies session cookie, applies rate limits, injects `c.get(db)` | -| **6. Handler & Database** | Server (Hono) | Handler validates input and queries PostgreSQL via Drizzle | -| **7. Response** | Server / Browser | JSON data hydrates TanStack Query cache and paints component | +| Phase | Runtime | Action | +| :------------------------ | :--------------------- | :---------------------------------------------------------------- | +| **1. Request** | Browser | Visitor navigates to `/blog` | +| **2. Routing** | Server (SSR) / Browser | TanStack Router matches route and executes `loader` | +| **3. Query Warming** | Server / Browser | `context.queryClient.query` executes isomorphic fetcher | +| **4. RPC Call** | Server / Browser | The universal `fetcher` calls the Hono endpoint by plugin id | +| **5. API Middleware** | Server (Hono) | Verifies session cookie, applies rate limits, injects `c.get(db)` | +| **6. Handler & Database** | Server (Hono) | Handler validates input and queries PostgreSQL via Drizzle | +| **7. Response** | Server / Browser | JSON data hydrates TanStack Query cache and paints component | --- @@ -50,12 +50,12 @@ Before route matching, every request passes through the middleware `createVitNodeStart` installs - in this order, and an app cannot get in front of any of it: -| Order | Middleware | Applies to | -| :---- | :----------------- | :------------------------------------------------------------- | -| 1 | **CSRF** | Server function calls (`handlerType === 'serverFn'`) | -| 2 | **Locale** | Page requests: canonical `308` redirects and the locale cookie | -| 3 | **Document cache** | HTML responses: forced `Cache-Control: private, no-store` | -| 4 | Your own | Whatever `requestMiddleware` lists | +| Order | Middleware | Applies to | +| :---- | :----------------- | :------------------------------------------------------------------------------ | +| 1 | **CSRF** | Server function calls (`handlerType === 'serverFn'`) | +| 2 | **Locale** | Page requests: canonical `308` redirects and the locale cookie | +| 3 | **Document cache** | HTML responses: forced `Cache-Control: private, no-store` plus security headers | +| 4 | Your own | Whatever `requestMiddleware` lists | `/api/*` reaches the same middleware and passes through untouched - no redirect, no rewrite, no cache directive - so the Hono bridge sees the request exactly as diff --git a/apps/web/content/docs/dev/configuration.mdx b/apps/web/content/docs/dev/configuration.mdx index 52a3047ca3..cf620736db 100644 --- a/apps/web/content/docs/dev/configuration.mdx +++ b/apps/web/content/docs/dev/configuration.mdx @@ -209,11 +209,11 @@ export const startInstance = createVitNodeStart({ config: vitNodeConfig }) `createVitNodeStart` installs three things, and none of them is optional: -| Middleware | What it does | -| :------------ | :---------------------------------------------------------------------------------------- | -| **CSRF** | Rejects cross-site requests to server functions (`handlerType === 'serverFn'`) | -| **Locale** | Canonical `308` redirects, the remembered-locale cookie, and the document cache directive | -| **Documents** | Forces `Cache-Control: private, no-store` onto every HTML response | +| Middleware | What it does | +| :------------ | :----------------------------------------------------------------------------------------- | +| **CSRF** | Rejects cross-site requests to server functions (`handlerType === 'serverFn'`) | +| **Locale** | Canonical `308` redirects, the remembered-locale cookie, and the document cache directive | +| **Documents** | Forces `Cache-Control: private, no-store` and adds security headers to every HTML response | Start installs its own CSRF middleware *only* while an app declares no @@ -243,6 +243,34 @@ Only `text/html` responses are touched. Everything else keeps what it had. directive like any other page an operator reads. +### Security headers on every page + +Every HTML response also gets a small, conservative set of security headers: + +| Header | Value | Why | +| :------------------------ | :-------------------------------- | :-------------------------------------------------------- | +| `Content-Security-Policy` | `frame-ancestors 'self'` | Other sites cannot frame your pages (hello, clickjacking) | +| `X-Frame-Options` | `SAMEORIGIN` | The same, for browsers that predate `frame-ancestors` | +| `X-Content-Type-Options` | `nosniff` | The browser trusts the declared content type | +| `Referrer-Policy` | `strict-origin-when-cross-origin` | Paths and query strings stay out of other sites' logs | + +There is no script policy and no HSTS by default - a strict script CSP breaks +plugins in creative ways, and HSTS is a promise you should only make once your +domain is HTTPS for good. A header a route sets for itself always wins. To add, +change or drop one install-wide, pass `securityHeaders` (`false` removes a +default): + +```ts title="apps/web/src/start.ts" +export const startInstance = createVitNodeStart({ + config: vitNodeConfig, + // [!code ++:4] + securityHeaders: { + 'Strict-Transport-Security': 'max-age=31536000; includeSubDomains', + 'Referrer-Policy': 'no-referrer', + }, +}) +``` + ### Adding your own middleware For things your installation genuinely owns - a request id, a tracing span, a diff --git a/packages/vitnode/src/tanstack/start/create-start.ts b/packages/vitnode/src/tanstack/start/create-start.ts index 6b8d8855a1..651807bfe2 100644 --- a/packages/vitnode/src/tanstack/start/create-start.ts +++ b/packages/vitnode/src/tanstack/start/create-start.ts @@ -7,6 +7,9 @@ import type { VitNodeConfig } from "@/vitnode.config"; import { localeRoutingFromConfig } from "@/lib/i18n/locale-routing"; +import type { DocumentSecurityHeaders } from "./document-headers"; + +import { resolveDocumentSecurityHeaders } from "./document-headers"; import { createLocaleRequestMiddleware } from "./locale-middleware"; export interface VitNodeStartOptions< @@ -15,18 +18,25 @@ export interface VitNodeStartOptions< config: VitNodeConfig; requestMiddleware?: readonly AnyRequestMiddleware[]; + /** + * Extra or replacement headers for rendered HTML pages, laid over + * `DOCUMENT_SECURITY_HEADERS`. `false` drops a default. + */ + securityHeaders?: DocumentSecurityHeaders; } export const createVitNodeStart = ({ config, requestMiddleware = [], + securityHeaders, }: VitNodeStartOptions) => { const localeRouting = localeRoutingFromConfig(config.i18n); + const documentHeaders = resolveDocumentSecurityHeaders(securityHeaders); return createStart(() => ({ requestMiddleware: [ createCsrfMiddleware({ filter: ctx => ctx.handlerType === "serverFn" }), - createLocaleRequestMiddleware(localeRouting), + createLocaleRequestMiddleware(localeRouting, documentHeaders), ...requestMiddleware, ], })); diff --git a/packages/vitnode/src/tanstack/start/document-headers.ts b/packages/vitnode/src/tanstack/start/document-headers.ts index 0a694e20f7..a707d7ca39 100644 --- a/packages/vitnode/src/tanstack/start/document-headers.ts +++ b/packages/vitnode/src/tanstack/start/document-headers.ts @@ -1,5 +1,46 @@ export const DOCUMENT_CACHE_CONTROL = "private, no-store"; +/** + * Header name -> value, or `false` to drop one of the defaults. + */ +export type DocumentSecurityHeaders = Record; + +/** + * Sent with every rendered HTML page. Deliberately conservative: + * + * - `frame-ancestors 'self'` and its legacy twin `X-Frame-Options` stop another + * site from framing the AdminCP and clickjacking a signed-in admin; + * - `nosniff` keeps the browser from reinterpreting a response's type; + * - `strict-origin-when-cross-origin` keeps paths and query strings out of the + * `Referer` sent to other sites. + * + * No script policy - one strict enough to matter would break plugins and + * inline bootstrapping - and no HSTS, which only belongs on an origin that is + * HTTPS for good. Both can be added through `securityHeaders`. + */ +export const DOCUMENT_SECURITY_HEADERS: Readonly> = { + "content-security-policy": "frame-ancestors 'self'", + "referrer-policy": "strict-origin-when-cross-origin", + "x-content-type-options": "nosniff", + "x-frame-options": "SAMEORIGIN", +}; + +/** The defaults with an install's own `securityHeaders` laid over them. */ +export const resolveDocumentSecurityHeaders = ( + overrides: DocumentSecurityHeaders = {}, +): Record => { + const merged: DocumentSecurityHeaders = { ...DOCUMENT_SECURITY_HEADERS }; + for (const [name, value] of Object.entries(overrides)) { + merged[name.toLowerCase()] = value; + } + + return Object.fromEntries( + Object.entries(merged).filter( + (entry): entry is [string, string] => entry[1] !== false, + ), + ); +}; + const isRenderedDocument = (headers: Headers): boolean => (headers.get("content-type") ?? "").toLowerCase().startsWith("text/html"); @@ -9,6 +50,21 @@ export const applyDocumentCacheControl = (response: Response): void => { response.headers.set("cache-control", DOCUMENT_CACHE_CONTROL); }; +/** + * Adds the security headers to a rendered HTML page. A header the response + * already carries - one a route set for itself - is left as it is. + */ +export const applyDocumentSecurityHeaders = ( + response: Response, + headers: Record = DOCUMENT_SECURITY_HEADERS, +): void => { + if (!isRenderedDocument(response.headers)) return; + + for (const [name, value] of Object.entries(headers)) { + if (!response.headers.has(name)) response.headers.set(name, value); + } +}; + export const applyRedirectCacheControl = (response: Response): void => { if (!response.headers.has("set-cookie")) return; diff --git a/packages/vitnode/src/tanstack/start/index.ts b/packages/vitnode/src/tanstack/start/index.ts index c2c7715cb6..99d5e5df85 100644 --- a/packages/vitnode/src/tanstack/start/index.ts +++ b/packages/vitnode/src/tanstack/start/index.ts @@ -1,7 +1,11 @@ export type { VitNodeStartOptions } from "./create-start"; export { createVitNodeStart } from "./create-start"; +export type { DocumentSecurityHeaders } from "./document-headers"; export { applyDocumentCacheControl, + applyDocumentSecurityHeaders, applyRedirectCacheControl, DOCUMENT_CACHE_CONTROL, + DOCUMENT_SECURITY_HEADERS, + resolveDocumentSecurityHeaders, } from "./document-headers"; diff --git a/packages/vitnode/src/tanstack/start/locale-middleware.ts b/packages/vitnode/src/tanstack/start/locale-middleware.ts index f71b2880f5..8b49255bdb 100644 --- a/packages/vitnode/src/tanstack/start/locale-middleware.ts +++ b/packages/vitnode/src/tanstack/start/locale-middleware.ts @@ -4,7 +4,10 @@ import type { LocaleRouting } from "@/lib/i18n/locale-routing"; import { runLocaleRequest } from "./locale-request"; -export const createLocaleRequestMiddleware = (localeRouting: LocaleRouting) => +export const createLocaleRequestMiddleware = ( + localeRouting: LocaleRouting, + securityHeaders?: Record, +) => createMiddleware().server( - async ctx => await runLocaleRequest(ctx, localeRouting), + async ctx => await runLocaleRequest(ctx, localeRouting, securityHeaders), ); diff --git a/packages/vitnode/src/tanstack/start/locale-request.test.ts b/packages/vitnode/src/tanstack/start/locale-request.test.ts new file mode 100644 index 0000000000..14bc0b5053 --- /dev/null +++ b/packages/vitnode/src/tanstack/start/locale-request.test.ts @@ -0,0 +1,95 @@ +import { describe, expect, it } from "vitest"; + +import { createLocaleRouting } from "@/lib/i18n/locale-routing"; + +import { resolveDocumentSecurityHeaders } from "./document-headers"; +import { runLocaleRequest } from "./locale-request"; + +type Context = Parameters[0]; + +const localeRouting = createLocaleRouting({ + defaultLocale: "en", + locales: ["en", "pl"], +}); + +const render = async ( + path: string, + { + headers = { "content-type": "text/html; charset=utf-8" }, + securityHeaders, + }: { + headers?: Record; + securityHeaders?: Record; + } = {}, +): Promise => { + const response = new Response("", { headers }); + const next = (async () => + await Promise.resolve({ response })) as unknown as Context["next"]; + + const result = await runLocaleRequest( + { + handlerType: "router", + next, + request: new Request(`https://site.example${path}`), + }, + localeRouting, + securityHeaders, + ); + + return result instanceof Response + ? result.headers + : (result as { response: Response }).response.headers; +}; + +describe("document security headers", () => { + it("sends the anti-clickjacking and hardening headers with a page", async () => { + const headers = await render("/admin/core"); + + expect(headers.get("x-frame-options")).toBe("SAMEORIGIN"); + expect(headers.get("content-security-policy")).toBe( + "frame-ancestors 'self'", + ); + expect(headers.get("x-content-type-options")).toBe("nosniff"); + expect(headers.get("referrer-policy")).toBe( + "strict-origin-when-cross-origin", + ); + expect(headers.get("cache-control")).toBe("private, no-store"); + expect(headers.has("strict-transport-security")).toBe(false); + }); + + it("leaves a response that is not HTML alone", async () => { + const headers = await render("/sitemap.xml", { + headers: { "content-type": "application/xml" }, + }); + + expect(headers.has("x-frame-options")).toBe(false); + expect(headers.has("content-security-policy")).toBe(false); + }); + + it("does not overwrite a header the route set itself", async () => { + const headers = await render("/discover", { + headers: { + "content-security-policy": "frame-ancestors 'none'", + "content-type": "text/html", + }, + }); + + expect(headers.get("content-security-policy")).toBe( + "frame-ancestors 'none'", + ); + expect(headers.get("x-frame-options")).toBe("SAMEORIGIN"); + }); + + it("applies an install's overrides", async () => { + const headers = await render("/discover", { + securityHeaders: resolveDocumentSecurityHeaders({ + "Strict-Transport-Security": "max-age=31536000", + "X-Frame-Options": false, + }), + }); + + expect(headers.get("strict-transport-security")).toBe("max-age=31536000"); + expect(headers.has("x-frame-options")).toBe(false); + expect(headers.get("x-content-type-options")).toBe("nosniff"); + }); +}); diff --git a/packages/vitnode/src/tanstack/start/locale-request.ts b/packages/vitnode/src/tanstack/start/locale-request.ts index 995348fb6f..d8b268530e 100644 --- a/packages/vitnode/src/tanstack/start/locale-request.ts +++ b/packages/vitnode/src/tanstack/start/locale-request.ts @@ -5,7 +5,9 @@ import type { LocaleRouting } from "@/lib/i18n/locale-routing"; import { handleLocaleRequest } from "../i18n/request"; import { applyDocumentCacheControl, + applyDocumentSecurityHeaders, applyRedirectCacheControl, + DOCUMENT_SECURITY_HEADERS, } from "./document-headers"; type LocaleRequestContext = Pick< @@ -16,6 +18,7 @@ type LocaleRequestContext = Pick< export const runLocaleRequest = async ( { handlerType, next, request }: LocaleRequestContext, localeRouting: LocaleRouting, + securityHeaders: Record = DOCUMENT_SECURITY_HEADERS, ) => { if (handlerType !== "router") return await next(); @@ -29,6 +32,7 @@ export const runLocaleRequest = async ( const result = await next(); if (setCookie) result.response.headers.append("set-cookie", setCookie); applyDocumentCacheControl(result.response); + applyDocumentSecurityHeaders(result.response, securityHeaders); return result; }; From 2742d3e5b3d9dc3c61dc142d4dc33db05436cadc Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 7 Oct 2026 06:59:27 +0000 Subject: [PATCH 20/25] =?UTF-8?q?fix(search):=20=F0=9F=94=92=20validate=20?= =?UTF-8?q?and=20clamp=20the=20Elasticsearch=20result=20cursor?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `Number(params.cursor)` went straight into `from`, so `?cursor=abc` (NaN) or an offset past `max_result_window` came back as a 500 and an error log row. The Postgres adapter's safe-integer check and MAX_SEARCH_OFFSET clamp now live in `api/lib/search-offset` and both adapters use it; Elasticsearch clamps to `MAX_SEARCH_OFFSET - size` so `from + size` stays in the window. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01BcTiLrsGK6BGwvs4uieRMB --- packages/elasticsearch/src/index.ts | 9 +++++- .../src/api/adapters/search/postgres.ts | 19 ++--------- .../vitnode/src/api/lib/search-offset.test.ts | 32 +++++++++++++++++++ packages/vitnode/src/api/lib/search-offset.ts | 27 ++++++++++++++++ 4 files changed, 69 insertions(+), 18 deletions(-) create mode 100644 packages/vitnode/src/api/lib/search-offset.test.ts create mode 100644 packages/vitnode/src/api/lib/search-offset.ts diff --git a/packages/elasticsearch/src/index.ts b/packages/elasticsearch/src/index.ts index 370c0ec4ca..ea0e42a268 100644 --- a/packages/elasticsearch/src/index.ts +++ b/packages/elasticsearch/src/index.ts @@ -8,6 +8,10 @@ import type { } from "@vitnode/core/api/models/search"; import { Client, errors } from "@elastic/elasticsearch"; +import { + MAX_SEARCH_OFFSET, + parseSearchOffset, +} from "@vitnode/core/api/lib/search-offset"; const DEFAULT_INDEX = "vitnode"; const DEFAULT_SIZE = 20; @@ -400,7 +404,10 @@ export const ElasticsearchSearchAdapter = ( await ensureIndex(); const size = Math.min(params.first ?? DEFAULT_SIZE, MAX_SIZE); - const from = params.cursor ? Number(params.cursor) : 0; + // `from + size` past `max_result_window` (10,000 by default) is a 500 from + // Elasticsearch, and so is `from: NaN`. + const from = + parseSearchOffset(params.cursor, MAX_SEARCH_OFFSET - size) ?? 0; const res = await getClient().search({ index, diff --git a/packages/vitnode/src/api/adapters/search/postgres.ts b/packages/vitnode/src/api/adapters/search/postgres.ts index 92740cbc60..15530d7448 100644 --- a/packages/vitnode/src/api/adapters/search/postgres.ts +++ b/packages/vitnode/src/api/adapters/search/postgres.ts @@ -26,6 +26,7 @@ import type { SearchResult, } from "@/api/models/search"; +import { parseSearchOffset } from "@/api/lib/search-offset"; import { storageUrlOf } from "@/api/lib/storage-url"; import { core_files } from "@/database/files"; import { core_search_index, resolveSearchTextConfig } from "@/database/search"; @@ -123,16 +124,6 @@ const buildFilters = ( return conditions.length ? and(...conditions) : undefined; }; -/** - * The furthest into a relevance-ranked result set a caller may page. - * - * Relevance ordering has no stable key to seek on, so its cursor is an offset - - * and an offset is the one pagination shape whose cost grows with the page - * number. Nobody reaches page ten thousand of a search by reading; they reach it - * by editing the URL. - */ -const MAX_SEARCH_OFFSET = 10_000; - export const PostgresSearchAdapter = (): SearchProviderApiPlugin => ({ name: "postgres", // Two capabilities that are both true for the same reason: this provider's @@ -190,13 +181,7 @@ export const PostgresSearchAdapter = (): SearchProviderApiPlugin => ({ // query's `OFFSET`. `Number("abc")` is `NaN`, which Postgres rejects as a // 500 rather than a bad request, and an unbounded one is a full scan a // client can ask for by typing a big number into a URL. - const parsedCursor = params.cursor ? Number(params.cursor) : undefined; - const cursorValue = - parsedCursor !== undefined && - Number.isSafeInteger(parsedCursor) && - parsedCursor >= 0 - ? Math.min(parsedCursor, MAX_SEARCH_OFFSET) - : undefined; + const cursorValue = parseSearchOffset(params.cursor); const orderBy: SQL[] = []; let where = filters; diff --git a/packages/vitnode/src/api/lib/search-offset.test.ts b/packages/vitnode/src/api/lib/search-offset.test.ts new file mode 100644 index 0000000000..8bf8d84fa9 --- /dev/null +++ b/packages/vitnode/src/api/lib/search-offset.test.ts @@ -0,0 +1,32 @@ +import { describe, expect, it } from "vitest"; + +import { MAX_SEARCH_OFFSET, parseSearchOffset } from "./search-offset"; + +describe("parseSearchOffset", () => { + it("reads a plain offset", () => { + expect(parseSearchOffset("0")).toBe(0); + expect(parseSearchOffset("40")).toBe(40); + }); + + it("treats a missing cursor as the first page", () => { + expect(parseSearchOffset(undefined)).toBeUndefined(); + expect(parseSearchOffset("")).toBeUndefined(); + }); + + it.each(["abc", "-1", "1.5", "1e400", "NaN", "9007199254740993"])( + "rejects %s instead of passing it to the engine", + cursor => { + expect(parseSearchOffset(cursor)).toBeUndefined(); + }, + ); + + it("clamps a huge offset to the search window", () => { + expect(parseSearchOffset("99999999")).toBe(MAX_SEARCH_OFFSET); + }); + + it("clamps to a caller's own window", () => { + // Elasticsearch refuses `from + size` past 10,000, so it asks for less. + expect(parseSearchOffset("99999999", MAX_SEARCH_OFFSET - 20)).toBe(9980); + expect(parseSearchOffset("5", -10)).toBe(0); + }); +}); diff --git a/packages/vitnode/src/api/lib/search-offset.ts b/packages/vitnode/src/api/lib/search-offset.ts new file mode 100644 index 0000000000..55602b508a --- /dev/null +++ b/packages/vitnode/src/api/lib/search-offset.ts @@ -0,0 +1,27 @@ +/** + * The furthest into a relevance-ranked result set a caller may page. + * + * Relevance ordering has no stable key to seek on, so its cursor is an offset - + * and an offset is the one pagination shape whose cost grows with the page + * number. Nobody reaches page ten thousand of a search by reading; they reach it + * by editing the URL. It is also Elasticsearch's default `max_result_window`. + */ +export const MAX_SEARCH_OFFSET = 10_000; + +/** + * Reads a search cursor as an offset: a non-negative safe integer, clamped to + * `max`. Anything else - `"abc"`, `"-1"`, `"1e400"` - is `undefined`, which a + * caller treats as the first page. `Number("abc")` is `NaN`, and a search engine + * answers `NaN` (or an offset past its window) with a 500 rather than a 400. + */ +export const parseSearchOffset = ( + cursor: string | undefined, + max: number = MAX_SEARCH_OFFSET, +): number | undefined => { + if (!cursor) return undefined; + + const parsed = Number(cursor); + if (!Number.isSafeInteger(parsed) || parsed < 0) return undefined; + + return Math.min(parsed, Math.max(0, max)); +}; From 9607279a1f75f4219bd48b6db15193d753f8d0da Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 7 Oct 2026 07:03:46 +0000 Subject: [PATCH 21/25] =?UTF-8?q?fix(api):=20=F0=9F=94=92=20escape=20LIKE?= =?UTF-8?q?=20wildcards=20in=20paginated=20and=20role-name=20searches?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `withPagination`'s `search` and the AdminCP roles list passed the raw term into `ilike`, so `%` or `_` matched every row and turned a search box into a full-table pattern scan. `escapeLikePattern` moves from the Content Engine to `lib/api/like-pattern` (still re-exported from `@vitnode/core/content/server`) and every user-supplied `ilike` uses it. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01BcTiLrsGK6BGwvs4uieRMB --- .../src/api/lib/with-pagination.test.ts | 23 +++++++++++++++++++ .../vitnode/src/api/lib/with-pagination.ts | 6 ++++- .../modules/admin/roles/routes/list.route.ts | 6 ++++- packages/vitnode/src/content/server/index.ts | 2 +- .../vitnode/src/content/server/query.test.ts | 12 ---------- packages/vitnode/src/content/server/query.ts | 10 ++------ .../vitnode/src/lib/api/like-pattern.test.ts | 20 ++++++++++++++++ packages/vitnode/src/lib/api/like-pattern.ts | 10 ++++++++ 8 files changed, 66 insertions(+), 23 deletions(-) create mode 100644 packages/vitnode/src/lib/api/like-pattern.test.ts create mode 100644 packages/vitnode/src/lib/api/like-pattern.ts diff --git a/packages/vitnode/src/api/lib/with-pagination.test.ts b/packages/vitnode/src/api/lib/with-pagination.test.ts index 671b345535..5daed76776 100644 --- a/packages/vitnode/src/api/lib/with-pagination.test.ts +++ b/packages/vitnode/src/api/lib/with-pagination.test.ts @@ -180,6 +180,29 @@ describe("cursor predicate", () => { }); }); +describe("search", () => { + it("escapes LIKE wildcards in the term", async () => { + const seen = vi.fn< + (args: { offset: number; where: SQL | undefined }) => Promise<[]> + >(async () => await Promise.resolve([])); + + await withPagination({ + c: contextWithCount(0), + orderBy: { column: rows.id, order: "desc" }, + params: { query: { first: "10", search: " 100%_off " } }, + primaryCursor: rows.id, + query: seen, + search: [rows.name], + table: rows, + }); + + const where = seen.mock.calls[0][0].where; + expect(where && dialect.sqlToQuery(where).params).toEqual([ + "%100\\%\\_off%", + ]); + }); +}); + describe("numbered pages", () => { it("skips the pages before the one asked for", async () => { expect( diff --git a/packages/vitnode/src/api/lib/with-pagination.ts b/packages/vitnode/src/api/lib/with-pagination.ts index 9d30c550dd..599eff9d18 100644 --- a/packages/vitnode/src/api/lib/with-pagination.ts +++ b/packages/vitnode/src/api/lib/with-pagination.ts @@ -29,6 +29,8 @@ import { } from "drizzle-orm"; import { HTTPException } from "hono/http-exception"; +import { containsLikePattern } from "@/lib/api/like-pattern"; + import type { PaginationCursor } from "./pagination-cursor"; import { @@ -185,7 +187,9 @@ function buildSearchWhere( const trimmed = term?.trim(); if (!search?.length || !trimmed) return undefined; - return or(...search.map(column => ilike(column, `%${trimmed}%`))); + const pattern = containsLikePattern(trimmed); + + return or(...search.map(column => ilike(column, pattern))); } async function fetchTotalCount( diff --git a/packages/vitnode/src/api/modules/admin/roles/routes/list.route.ts b/packages/vitnode/src/api/modules/admin/roles/routes/list.route.ts index 5108f86ad6..edda5d6b5f 100644 --- a/packages/vitnode/src/api/modules/admin/roles/routes/list.route.ts +++ b/packages/vitnode/src/api/modules/admin/roles/routes/list.route.ts @@ -13,6 +13,7 @@ import { core_languages_words } from "@/database/languages"; import { core_moderators_permissions } from "@/database/moderators"; import { core_roles } from "@/database/roles"; import { core_users, core_users_secondary_roles } from "@/database/users"; +import { containsLikePattern } from "@/lib/api/like-pattern"; import { assertCanListRoles } from "../lib/assert-can-list-roles"; import { withRolesAdminListFields } from "./list-mapping"; @@ -103,7 +104,10 @@ export const listRolesAdminRoute = buildRoute({ eq(core_languages_words.tableName, "core_roles"), eq(core_languages_words.variable, "name"), eq(core_languages_words.pluginCode, "core"), - ilike(core_languages_words.value, `%${search}%`), + ilike( + core_languages_words.value, + containsLikePattern(search), + ), ), ), ) diff --git a/packages/vitnode/src/content/server/index.ts b/packages/vitnode/src/content/server/index.ts index 28e2db0176..83e4ed02c6 100644 --- a/packages/vitnode/src/content/server/index.ts +++ b/packages/vitnode/src/content/server/index.ts @@ -1,3 +1,4 @@ +export { escapeLikePattern } from "../../lib/api/like-pattern"; export { CONTENT_SYSTEM_ACTOR, resolveContentActor } from "./actor"; export { buildContentColumn, @@ -170,7 +171,6 @@ export { buildOrderColumn, buildSearchCondition, diffChangedFields, - escapeLikePattern, toColumnValues, } from "./query"; export { LABEL_PREFIX, resolveReferenceTargets, toLabel } from "./references"; diff --git a/packages/vitnode/src/content/server/query.test.ts b/packages/vitnode/src/content/server/query.test.ts index 8c1d634ec7..fd7c377825 100644 --- a/packages/vitnode/src/content/server/query.test.ts +++ b/packages/vitnode/src/content/server/query.test.ts @@ -18,7 +18,6 @@ import { buildOrderColumn, buildSearchCondition, diffChangedFields, - escapeLikePattern, toColumnValues, } from "./query"; import { contentTableColumns, createContentTable } from "./table"; @@ -91,17 +90,6 @@ const patternsIn = (condition: unknown): string[] => { return []; }; -describe("escapeLikePattern", () => { - it.each([ - ["100%", "100\\%"], - ["a_b", "a\\_b"], - ["back\\slash", "back\\\\slash"], - ["plain", "plain"], - ])("escapes %s", (input, expected) => { - expect(escapeLikePattern(input)).toBe(expected); - }); -}); - describe("buildSearchCondition", () => { it("returns nothing without a term or columns", () => { expect(buildSearchCondition([columns.title], undefined)).toBeUndefined(); diff --git a/packages/vitnode/src/content/server/query.ts b/packages/vitnode/src/content/server/query.ts index b88c71ef4a..02b06c7d6c 100644 --- a/packages/vitnode/src/content/server/query.ts +++ b/packages/vitnode/src/content/server/query.ts @@ -9,6 +9,7 @@ import type { ContentRelationFilter, } from "../types"; +import { containsLikePattern } from "../../lib/api/like-pattern"; import { CONTENT_FILTERABLE_FIELD_KINDS, CONTENT_PUBLICATION_STATUSES, @@ -28,13 +29,6 @@ import { splitContentFieldPath, } from "../paths"; -/** - * Escapes the `LIKE` wildcards so a search for "100%" matches the literal text - * rather than every row. Backslash is Postgres' default escape character. - */ -export const escapeLikePattern = (value: string): string => - value.replace(/[\\%_]/g, match => `\\${match}`); - export const buildSearchCondition = ( columns: readonly PgColumn[], term: string | undefined, @@ -42,7 +36,7 @@ export const buildSearchCondition = ( const trimmed = term?.trim(); if (!columns.length || !trimmed) return undefined; - const pattern = `%${escapeLikePattern(trimmed)}%`; + const pattern = containsLikePattern(trimmed); return or(...columns.map(column => ilike(column, pattern))); }; diff --git a/packages/vitnode/src/lib/api/like-pattern.test.ts b/packages/vitnode/src/lib/api/like-pattern.test.ts new file mode 100644 index 0000000000..08ae11f602 --- /dev/null +++ b/packages/vitnode/src/lib/api/like-pattern.test.ts @@ -0,0 +1,20 @@ +import { describe, expect, it } from "vitest"; + +import { containsLikePattern, escapeLikePattern } from "./like-pattern"; + +describe("escapeLikePattern", () => { + it.each([ + ["100%", "100\\%"], + ["a_b", "a\\_b"], + ["back\\slash", "back\\\\slash"], + ["plain", "plain"], + ])("escapes %s", (input, expected) => { + expect(escapeLikePattern(input)).toBe(expected); + }); +}); + +describe("containsLikePattern", () => { + it("wraps the escaped term in wildcards", () => { + expect(containsLikePattern("50%_off")).toBe("%50\\%\\_off%"); + }); +}); diff --git a/packages/vitnode/src/lib/api/like-pattern.ts b/packages/vitnode/src/lib/api/like-pattern.ts new file mode 100644 index 0000000000..b74701d201 --- /dev/null +++ b/packages/vitnode/src/lib/api/like-pattern.ts @@ -0,0 +1,10 @@ +/** + * Escapes the `LIKE` wildcards so a search for "100%" matches the literal text + * rather than every row. Backslash is Postgres' default escape character. + */ +export const escapeLikePattern = (value: string): string => + value.replace(/[\\%_]/g, match => `\\${match}`); + +/** `%term%` with the term's own wildcards escaped - a literal "contains". */ +export const containsLikePattern = (value: string): string => + `%${escapeLikePattern(value)}%`; From ca3f8449706572376ade50e4e256f73bf743cdd4 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 7 Oct 2026 07:06:27 +0000 Subject: [PATCH 22/25] =?UTF-8?q?fix(widgets):=20=F0=9F=94=92=20render=20H?= =?UTF-8?q?ero=20and=20Call=20to=20action=20links=20only=20for=20safe=20hr?= =?UTF-8?q?efs?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Both built-in widgets put a free-text field straight into `href`, so a `javascript:` URL saved in a page layout ran on click. They now apply the menu-item rule (`isValidNavigationHref`: a site path or an https URL) and leave the link out otherwise. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01BcTiLrsGK6BGwvs4uieRMB --- .../docs/dev/widgets/defining-a-block.mdx | 4 + packages/vitnode/src/blocks/built-in/cta.tsx | 16 ++-- packages/vitnode/src/blocks/built-in/hero.tsx | 6 +- .../src/blocks/built-in/links.test.tsx | 74 +++++++++++++++++++ 4 files changed, 93 insertions(+), 7 deletions(-) create mode 100644 packages/vitnode/src/blocks/built-in/links.test.tsx diff --git a/apps/web/content/docs/dev/widgets/defining-a-block.mdx b/apps/web/content/docs/dev/widgets/defining-a-block.mdx index 8fff51c89f..068f73f1de 100644 --- a/apps/web/content/docs/dev/widgets/defining-a-block.mdx +++ b/apps/web/content/docs/dev/widgets/defining-a-block.mdx @@ -77,6 +77,10 @@ interface WidgetComponentProps { `data` has already been through the widget's schema when the component runs, so a component never checks for missing fields. `variant` is the look this instance picked, and is `undefined` for a widget that offers none. + + The schema checks that a `field.text()` is a string, not that it is a URL you want people clicking. If your widget turns one into an `href`, check it with `isValidNavigationHref` from `@vitnode/core/lib/navigation` first - it allows a site path (`/discover`) or an `https://` URL and refuses `javascript:` and friends. Core's Hero and Call to action widgets simply leave the link out when it fails. + + ## Two looks, one widget A widget that should render the same content in more than one shape declares its shapes instead of growing a `style` field: diff --git a/packages/vitnode/src/blocks/built-in/cta.tsx b/packages/vitnode/src/blocks/built-in/cta.tsx index 2a4b3ad7e2..87c11cc324 100644 --- a/packages/vitnode/src/blocks/built-in/cta.tsx +++ b/packages/vitnode/src/blocks/built-in/cta.tsx @@ -3,6 +3,7 @@ import { MegaphoneIcon } from "lucide-react"; import type { BlockComponentProps, BlockData } from "../types"; import { field } from "../../content/fields"; +import { isValidNavigationHref } from "../../lib/navigation"; import { defineBlock } from "../define"; const ctaFields = { @@ -27,12 +28,15 @@ const Cta = ({ data }: BlockComponentProps) => ( ) : null} - - {data.label} - + {/* Same rule as a menu item: a site path or an https URL, never `javascript:`. */} + {isValidNavigationHref(data.href) ? ( + + {data.label} + + ) : null} ); diff --git a/packages/vitnode/src/blocks/built-in/hero.tsx b/packages/vitnode/src/blocks/built-in/hero.tsx index 7197c041c6..8f5a3c8d26 100644 --- a/packages/vitnode/src/blocks/built-in/hero.tsx +++ b/packages/vitnode/src/blocks/built-in/hero.tsx @@ -3,6 +3,7 @@ import { PanelTopIcon } from "lucide-react"; import type { BlockComponentProps, BlockData } from "../types"; import { field } from "../../content/fields"; +import { isValidNavigationHref } from "../../lib/navigation"; import { defineBlock } from "../define"; const heroFields = { @@ -42,7 +43,10 @@ const Hero = ({ data }: BlockComponentProps) => {

) : null} - {data.linkHref && data.linkLabel ? ( + {/* Same rule as a menu item: a site path or an https URL, never `javascript:`. */} + {data.linkHref && + data.linkLabel && + isValidNavigationHref(data.linkHref) ? ( + render( + , + ); + +const renderHero = (linkHref: string) => + render( + , + ); + +const UNSAFE_HREFS = [ + "javascript:alert(document.cookie)", + " javascript:alert(1)", + "data:text/html,", + "//evil.example", + "http://plain.example", +]; + +describe("built-in block links", () => { + it.each(["/discover", "https://vitnode.com/docs"])( + "renders %s as a link", + href => { + renderCta(href); + renderHero(href); + + expect( + screen.getByRole("link", { name: "Join us" }).getAttribute("href"), + ).toBe(href); + expect( + screen.getByRole("link", { name: "Read more" }).getAttribute("href"), + ).toBe(href); + }, + ); + + it.each(UNSAFE_HREFS)("drops the call-to-action link for %s", href => { + renderCta(href); + + expect(screen.getByRole("heading", { name: "Hi" })).toBeTruthy(); + expect(screen.queryByRole("link")).toBeNull(); + }); + + it.each(UNSAFE_HREFS)("drops the hero link for %s", href => { + renderHero(href); + + expect(screen.getByRole("heading", { name: "Welcome" })).toBeTruthy(); + expect(screen.queryByRole("link")).toBeNull(); + }); +}); From 807eeba28a08b9fe769558d13f215401fd33a5f2 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 7 Oct 2026 07:33:32 +0000 Subject: [PATCH 23/25] =?UTF-8?q?refactor(elasticsearch):=20=E2=99=BB?= =?UTF-8?q?=EF=B8=8F=20keep=20the=20search=20offset=20clamp=20local=20to?= =?UTF-8?q?=20the=20adapter?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit @vitnode/core is only a dev dependency of the adapter, so a value import from it is not guaranteed to resolve in an install. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01BcTiLrsGK6BGwvs4uieRMB --- AGENTS.md | 11 +++++++++++ packages/elasticsearch/src/index.ts | 20 ++++++++++++++++---- 2 files changed, 27 insertions(+), 4 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index fc796d0a66..73323238a9 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -123,3 +123,14 @@ npm i x - `vi.mock` is allowed only as a last resort, for a boundary with no seam (a `React.lazy` editor, a child process). Never use it for a module the test could drive for real. - Staff permissions: grant them with `grantStaffPermissions` from `@/tests/staff-permissions` on a `createTestCache()` from `@/tests/cache`, set as `c.set("cache", cache)`. Never mock `check-staff-permission`. Assert on the response (`200` with the permission, `403` without), not on which permission was asked for. - `use-intl` is mocked once for every test in `packages/vitnode/src/tests/setup.ts`: `t("key")` renders `namespace.key`. Don't mock it per file. + + + +# This is NOT the Turborepo you know + +Turborepo configuration, task behavior, and CLI commands can vary between installed versions and may differ from your training data. Resolve the `turbo` package from this file's directory or relevant workspace; in monorepos, it may not be visible from the repository root. For example, run `node -p "require.resolve('turbo/package.json')"` from a workspace that depends on `turbo`. + +Read `docs/README.md` inside that installed package first, then read the relevant pages from its `docs/` directory before changing Turborepo configuration or commands. Heed deprecation notices. These bundled docs match the installed package version and are available without network access. + +This block is written and re-added by `turbo` before repository-scoped commands when an AI agent is detected. In the Turborepo source repository, its template is defined in `crates/turborepo-cli/src/cli/agent_guidance.rs`. Removing the managed block while updates are enabled means a later qualifying invocation will add it again. Set `"agentGuidance": false` in the root `turbo.json` or `turbo.jsonc` to opt out; this does not remove an existing block. Keep the block committed with your work to avoid an uncommitted change on the next agent invocation. + diff --git a/packages/elasticsearch/src/index.ts b/packages/elasticsearch/src/index.ts index ea0e42a268..5d228daef1 100644 --- a/packages/elasticsearch/src/index.ts +++ b/packages/elasticsearch/src/index.ts @@ -8,12 +8,24 @@ import type { } from "@vitnode/core/api/models/search"; import { Client, errors } from "@elastic/elasticsearch"; -import { - MAX_SEARCH_OFFSET, - parseSearchOffset, -} from "@vitnode/core/api/lib/search-offset"; const DEFAULT_INDEX = "vitnode"; +// Elasticsearch's default `max_result_window`. Same clamp as core's +// `parseSearchOffset`, kept local because `@vitnode/core` is only a dev +// dependency of this package - type imports are erased, values are not. +const MAX_SEARCH_OFFSET = 10_000; + +const parseSearchOffset = ( + cursor: string | undefined, + max: number, +): number | undefined => { + if (!cursor) return undefined; + + const parsed = Number(cursor); + if (!Number.isSafeInteger(parsed) || parsed < 0) return undefined; + + return Math.min(parsed, Math.max(0, max)); +}; const DEFAULT_SIZE = 20; const MAX_SIZE = 100; From a35a6322208e9c9f9630b323654c3dda6bd81c15 Mon Sep 17 00:00:00 2001 From: aXenDeveloper Date: Fri, 9 Oct 2026 11:53:28 +0200 Subject: [PATCH 24/25] =?UTF-8?q?fix(rebase):=20=F0=9F=90=9B=20reconcile?= =?UTF-8?q?=20security=20hardening=20with=20canary's=20oxlint=20and=20AI?= =?UTF-8?q?=20routes?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Switch the verify-email route's throw directive to oxlint and run oxfmt on the two files it reformats. - Stub resendEmailVerification and verifyEmail in canary's main-shell transport test. - Read expiresAt without unsafe optional chaining in the email verification test. - Allowlist canary's AI admin routes in the staff-permission audit: assist, stream and estimate are checked by the AI runner (action grant plus authorize()), available and run feedback only touch the admin's own data, and translation sources check can_edit on the named content type. Co-Authored-By: Claude Opus 5.5 --- packages/vitnode/src/api/admin-staff-permission.test.ts | 7 +++++++ .../api/modules/users/routes/email-verification.test.ts | 3 ++- packages/vitnode/src/tanstack/auth/verify-email-route.ts | 2 +- packages/vitnode/src/tanstack/auth/verify-email.ts | 7 +++++-- packages/vitnode/src/tanstack/layout/main-shell.test.ts | 2 ++ packages/vitnode/src/views/auth/verify-email/schema.ts | 3 ++- 6 files changed, 19 insertions(+), 5 deletions(-) diff --git a/packages/vitnode/src/api/admin-staff-permission.test.ts b/packages/vitnode/src/api/admin-staff-permission.test.ts index a0b436e2e1..9b265c0dc7 100644 --- a/packages/vitnode/src/api/admin-staff-permission.test.ts +++ b/packages/vitnode/src/api/admin-staff-permission.test.ts @@ -25,6 +25,13 @@ const ALLOWLIST = [ "GET /admin/staff/entry/:type/:id", "PATCH /admin/staff/entry/:type/:id", "DELETE /admin/staff/entry/:type/:id", + "POST /admin/ai/assist", + "POST /admin/ai/assist/stream", + "POST /admin/ai/assist/estimate", + "GET /admin/ai/assist/available", + "POST /admin/ai/assist/runs/:id/feedback", + "GET /admin/ai/translation-sources", + "PUT /admin/ai/translation-sources", ]; const ok = { diff --git a/packages/vitnode/src/api/modules/users/routes/email-verification.test.ts b/packages/vitnode/src/api/modules/users/routes/email-verification.test.ts index 3076973529..8ad110a09b 100644 --- a/packages/vitnode/src/api/modules/users/routes/email-verification.test.ts +++ b/packages/vitnode/src/api/modules/users/routes/email-verification.test.ts @@ -196,7 +196,8 @@ describe("signing up on an install that sends email", () => { const [row] = h.confirmationRows(); expect(row?.token).toBe(sha256(LINK_TOKEN)); expect(row?.token).not.toBe(LINK_TOKEN); - const expiresIn = (row?.expiresAt as Date).getTime() - Date.now(); + expect(row?.expiresAt).toBeInstanceOf(Date); + const expiresIn = Number(row?.expiresAt) - Date.now(); expect(expiresIn).toBeGreaterThan(EMAIL_VERIFICATION_TTL_MS - 60_000); expect(expiresIn).toBeLessThanOrEqual(EMAIL_VERIFICATION_TTL_MS); }); diff --git a/packages/vitnode/src/tanstack/auth/verify-email-route.ts b/packages/vitnode/src/tanstack/auth/verify-email-route.ts index 103b9b6e33..a94d4e92ce 100644 --- a/packages/vitnode/src/tanstack/auth/verify-email-route.ts +++ b/packages/vitnode/src/tanstack/auth/verify-email-route.ts @@ -29,6 +29,6 @@ export const loadVerifyEmailRoute = async ({ if (availability === "unknown") throw new EmailVerificationUnknownError(); // TanStack Router's own control-flow signal, like `redirect()`. - // eslint-disable-next-line @typescript-eslint/only-throw-error + // oxlint-disable-next-line typescript/only-throw-error if (availability === "disabled") throw notFound(); }; diff --git a/packages/vitnode/src/tanstack/auth/verify-email.ts b/packages/vitnode/src/tanstack/auth/verify-email.ts index b9e491ff13..bdd9b568fd 100644 --- a/packages/vitnode/src/tanstack/auth/verify-email.ts +++ b/packages/vitnode/src/tanstack/auth/verify-email.ts @@ -18,7 +18,8 @@ export const normalizeVerifyEmailSearch = ( * visitor needing a new link rather than a button that is bound to fail. */ export type VerifyEmailMode = - { mode: "confirm"; token: string } | { mode: "resend" }; + | { mode: "confirm"; token: string } + | { mode: "resend" }; export const verifyEmailMode = (search: VerifyEmailSearch): VerifyEmailMode => { const parsed = verifyEmailInputSchema.safeParse({ token: search.token }); @@ -36,7 +37,9 @@ export const VERIFY_EMAIL_NAMESPACES = [ ] as const; export type EmailVerificationAvailability = - "available" | "disabled" | "unknown"; + | "available" + | "disabled" + | "unknown"; /** * Whether this install confirms addresses at all - only one that can send diff --git a/packages/vitnode/src/tanstack/layout/main-shell.test.ts b/packages/vitnode/src/tanstack/layout/main-shell.test.ts index e172579d95..322d4c32e7 100644 --- a/packages/vitnode/src/tanstack/layout/main-shell.test.ts +++ b/packages/vitnode/src/tanstack/layout/main-shell.test.ts @@ -31,12 +31,14 @@ setAuthTransport({ return await Promise.resolve({ user: null }); }, requestPasswordReset: unreachable, + resendEmailVerification: unreachable, signIn: unreachable, signOut: unreachable, signUp: unreachable, startAdminPasskeySignIn: unreachable, startPasskeySignIn: unreachable, startSso: unreachable, + verifyEmail: unreachable, }); const KNOWN_CONFIG = knownMiddlewareConfig({ diff --git a/packages/vitnode/src/views/auth/verify-email/schema.ts b/packages/vitnode/src/views/auth/verify-email/schema.ts index 9b170b3665..31472ca3d3 100644 --- a/packages/vitnode/src/views/auth/verify-email/schema.ts +++ b/packages/vitnode/src/views/auth/verify-email/schema.ts @@ -32,7 +32,8 @@ export interface ResendVerificationSubmitValues { * outcome here that could tell them apart. */ export type ResendVerificationMutationResult = - undefined | { message: "Internal Server Error" }; + | undefined + | { message: "Internal Server Error" }; export type ResendVerificationSubmit = ( values: ResendVerificationSubmitValues, From 1f8836dec56477971069bba66b80503dcb4f889f Mon Sep 17 00:00:00 2001 From: aXenDeveloper Date: Fri, 9 Oct 2026 12:38:43 +0200 Subject: [PATCH 25/25] =?UTF-8?q?refactor(emails):=20=E2=9C=A8=20Simplify?= =?UTF-8?q?=20email=20templates=20by=20introducing=20`ActionLinkEmail`=20c?= =?UTF-8?q?omponent?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Removed redundant code from `ResetPasswordEmailTemplate` and `VerifyEmailTemplate`. - Created a new `ActionLinkEmail` component to handle common email functionalities. - Updated email templates to utilize the new component for better maintainability. - Removed unnecessary comments and improved code clarity across various routes and services. --- .github/dependabot.yml | 3 - .gitignore | 2 - apps/api/.gitignore | 4 +- apps/web/.gitignore | 2 - apps/web/src/routes/api/$.ts | 3 - apps/web/src/server/api-bridge.ts | 1 - .../api-single-app/src/routes/api/$.ts | 3 - .../api-single-app/src/server/api-bridge.ts | 1 - .../api/.gitignore_template | 4 +- .../monorepo/.gitignore_template | 2 - .../root/.gitignore_template | 4 +- packages/elasticsearch/src/index.ts | 11 +- .../src/api/admin-staff-permission.test.ts | 12 -- packages/vitnode/src/api/config.ts | 5 - packages/vitnode/src/api/lib/client-ip.ts | 7 -- packages/vitnode/src/api/lib/error-details.ts | 10 -- .../vitnode/src/api/lib/image-signature.ts | 14 --- packages/vitnode/src/api/lib/redact.ts | 4 - packages/vitnode/src/api/lib/route.ts | 16 +-- .../vitnode/src/api/lib/search-offset.test.ts | 1 - packages/vitnode/src/api/lib/search-offset.ts | 14 --- .../middlewares/rate-limiter.middleware.ts | 14 +-- .../src/api/models/email-verification.ts | 30 ----- .../src/api/models/storage-image.test.ts | 8 -- .../vitnode/src/api/models/storage.test.ts | 1 - packages/vitnode/src/api/models/storage.ts | 13 --- .../api/models/user/sign-in-with-passwords.ts | 4 - .../admin/staff/lib/staff-entry-guards.ts | 3 - .../admin/users/routes/verify-email.route.ts | 1 - .../api/modules/users/credential-limits.ts | 12 -- .../routes/authentication-verify.route.ts | 4 - .../users/routes/change-password.route.ts | 4 - .../users/routes/resend-verification.route.ts | 4 - .../users/routes/reset-passowrd.route.ts | 5 - .../api/modules/users/routes/sign-in.route.ts | 7 -- .../api/modules/users/routes/sign-up.route.ts | 4 - .../users/routes/verify-email.route.ts | 4 - .../users/sso/routes/callback.route.ts | 1 - .../modules/users/sso/routes/callback.test.ts | 1 - .../modules/users/sso/routes/link.route.ts | 1 - packages/vitnode/src/blocks/built-in/cta.tsx | 1 - packages/vitnode/src/blocks/built-in/hero.tsx | 1 - .../src/content/server/editorial-service.ts | 2 - packages/vitnode/src/content/server/files.ts | 15 --- .../content/server/revalidate-bridge.test.ts | 1 - .../src/content/server/revalidate-bridge.ts | 13 --- .../vitnode/src/emails/reset-password.tsx | 98 +---------------- .../src/emails/ui/action-link-email.tsx | 103 ++++++++++++++++++ packages/vitnode/src/emails/verify-email.tsx | 98 +---------------- packages/vitnode/src/lib/api/like-pattern.ts | 5 - packages/vitnode/src/lib/fetcher/raw.ts | 4 - packages/vitnode/src/routes.tsx | 2 - packages/vitnode/src/tanstack/auth/actions.ts | 5 - .../vitnode/src/tanstack/auth/contract.ts | 8 -- .../src/tanstack/auth/verify-email-route.ts | 10 -- .../src/tanstack/auth/verify-email-screen.tsx | 2 - .../vitnode/src/tanstack/auth/verify-email.ts | 11 -- .../src/tanstack/start/create-start.ts | 4 - .../src/tanstack/start/document-headers.ts | 21 ---- .../sign-in/form/sign-in-form-content.tsx | 1 - .../auth/sign-in/form/use-sign-in-form.ts | 2 - .../verify-email/confirm-email-content.tsx | 7 -- .../src/views/auth/verify-email/schema.ts | 7 -- packages/vitnode/src/vitnode.config.ts | 9 -- .../src/api/admin-staff-permission.test.ts | 1 - .../src/api/admin-staff-permission.test.ts | 1 - pnpm-workspace.yaml | 10 -- 67 files changed, 127 insertions(+), 559 deletions(-) create mode 100644 packages/vitnode/src/emails/ui/action-link-email.tsx diff --git a/.github/dependabot.yml b/.github/dependabot.yml index 44d29dd5ca..f02fedb43b 100644 --- a/.github/dependabot.yml +++ b/.github/dependabot.yml @@ -1,14 +1,11 @@ version: 2 updates: - # One pnpm workspace: the root lockfile covers apps/*, packages/* and plugins/*. - package-ecosystem: npm directory: / schedule: interval: weekly day: monday - # Matches pnpm's `minimumReleaseAge`: a release has to survive a few days - # before it lands here. Security updates are not delayed. cooldown: default-days: 3 open-pull-requests-limit: 10 diff --git a/.gitignore b/.gitignore index b71733250b..49bffe2447 100644 --- a/.gitignore +++ b/.gitignore @@ -5,8 +5,6 @@ node_modules .turbo # Local env files -# Every mode Vite loads (`.env.production`, `.env.staging.local`, ...) - only -# the example file is committed. .env* !.env.example diff --git a/apps/api/.gitignore b/apps/api/.gitignore index adf39f7c7a..0b346262cc 100644 --- a/apps/api/.gitignore +++ b/apps/api/.gitignore @@ -19,8 +19,6 @@ yarn-debug.log* yarn-error.log* # Local env files -# Every mode Vite loads (`.env.production`, `.env.staging.local`, ...) - only -# the example file is committed. .env* !.env.example @@ -30,5 +28,5 @@ yarn-error.log* # Local storage adapter uploads /public/uploads -# VitNode - Postgres data of `docker-compose.yml` (mounted at ./docker/dev) +# VitNode /docker diff --git a/apps/web/.gitignore b/apps/web/.gitignore index 44f3a1aa47..97ddc898a5 100644 --- a/apps/web/.gitignore +++ b/apps/web/.gitignore @@ -3,8 +3,6 @@ node_modules dist dist-ssr *.local -# Every mode Vite loads (`.env.production`, `.env.staging.local`, ...) - only -# the example file is committed. .env* !.env.example .nitro diff --git a/apps/web/src/routes/api/$.ts b/apps/web/src/routes/api/$.ts index bb8d22ca6b..52108e7a68 100644 --- a/apps/web/src/routes/api/$.ts +++ b/apps/web/src/routes/api/$.ts @@ -3,9 +3,6 @@ import { getRequestIP } from '@tanstack/react-start/server' import { apiBridge } from '@/server/vitnode-api.server' -// `getRequestIP()` without `xForwardedFor` is the accepted socket, which a -// visitor cannot write. Without it every browser call shares one address and -// one rate-limit bucket. export const Route = createFileRoute('/api/$')({ server: { handlers: ({ createHandlers }) => diff --git a/apps/web/src/server/api-bridge.ts b/apps/web/src/server/api-bridge.ts index 016d4c52fe..b0faf5cbb2 100644 --- a/apps/web/src/server/api-bridge.ts +++ b/apps/web/src/server/api-bridge.ts @@ -1,4 +1,3 @@ -/** What the in-process API reads the visitor's address from - see `client-ip.ts`. */ export interface ApiBridgeEnv { clientAddress?: string } diff --git a/packages/create-vitnode-app/copy-of-vitnode-app/api-single-app/src/routes/api/$.ts b/packages/create-vitnode-app/copy-of-vitnode-app/api-single-app/src/routes/api/$.ts index f63a0ed83d..a8084b102d 100644 --- a/packages/create-vitnode-app/copy-of-vitnode-app/api-single-app/src/routes/api/$.ts +++ b/packages/create-vitnode-app/copy-of-vitnode-app/api-single-app/src/routes/api/$.ts @@ -3,9 +3,6 @@ import { getRequestIP } from "@tanstack/react-start/server"; import { apiBridge } from "@/server/vitnode-api.server"; -// `getRequestIP()` without `xForwardedFor` is the accepted socket, which a -// visitor cannot write. Without it every browser call shares one address and -// one rate-limit bucket. export const Route = createFileRoute("/api/$")({ server: { handlers: ({ createHandlers }) => diff --git a/packages/create-vitnode-app/copy-of-vitnode-app/api-single-app/src/server/api-bridge.ts b/packages/create-vitnode-app/copy-of-vitnode-app/api-single-app/src/server/api-bridge.ts index 06f7e51f58..82c42839ef 100644 --- a/packages/create-vitnode-app/copy-of-vitnode-app/api-single-app/src/server/api-bridge.ts +++ b/packages/create-vitnode-app/copy-of-vitnode-app/api-single-app/src/server/api-bridge.ts @@ -1,4 +1,3 @@ -/** What the in-process API reads the visitor's address from - see `client-ip.ts`. */ export interface ApiBridgeEnv { clientAddress?: string; } diff --git a/packages/create-vitnode-app/copy-of-vitnode-app/api/.gitignore_template b/packages/create-vitnode-app/copy-of-vitnode-app/api/.gitignore_template index adf39f7c7a..0b346262cc 100644 --- a/packages/create-vitnode-app/copy-of-vitnode-app/api/.gitignore_template +++ b/packages/create-vitnode-app/copy-of-vitnode-app/api/.gitignore_template @@ -19,8 +19,6 @@ yarn-debug.log* yarn-error.log* # Local env files -# Every mode Vite loads (`.env.production`, `.env.staging.local`, ...) - only -# the example file is committed. .env* !.env.example @@ -30,5 +28,5 @@ yarn-error.log* # Local storage adapter uploads /public/uploads -# VitNode - Postgres data of `docker-compose.yml` (mounted at ./docker/dev) +# VitNode /docker diff --git a/packages/create-vitnode-app/copy-of-vitnode-app/monorepo/.gitignore_template b/packages/create-vitnode-app/copy-of-vitnode-app/monorepo/.gitignore_template index 5a58ba01d4..d7477f58a7 100644 --- a/packages/create-vitnode-app/copy-of-vitnode-app/monorepo/.gitignore_template +++ b/packages/create-vitnode-app/copy-of-vitnode-app/monorepo/.gitignore_template @@ -6,8 +6,6 @@ node_modules .pnp.js # Local env files -# Every mode Vite loads (`.env.production`, `.env.staging.local`, ...) - only -# the example file is committed. .env* !.env.example diff --git a/packages/create-vitnode-app/copy-of-vitnode-app/root/.gitignore_template b/packages/create-vitnode-app/copy-of-vitnode-app/root/.gitignore_template index 44950ef91f..59c94b1263 100644 --- a/packages/create-vitnode-app/copy-of-vitnode-app/root/.gitignore_template +++ b/packages/create-vitnode-app/copy-of-vitnode-app/root/.gitignore_template @@ -27,8 +27,6 @@ yarn-debug.log* yarn-error.log* # Local env files -# Every mode Vite loads (`.env.production`, `.env.staging.local`, ...) - only -# the example file is committed. .env* !.env.example @@ -38,5 +36,5 @@ yarn-error.log* # Local storage adapter uploads /public/uploads -# VitNode - Postgres data of `docker-compose.yml` (mounted at ./docker/dev) +# VitNode /docker diff --git a/packages/elasticsearch/src/index.ts b/packages/elasticsearch/src/index.ts index 5d228daef1..b5344e280b 100644 --- a/packages/elasticsearch/src/index.ts +++ b/packages/elasticsearch/src/index.ts @@ -10,9 +10,8 @@ import type { import { Client, errors } from "@elastic/elasticsearch"; const DEFAULT_INDEX = "vitnode"; -// Elasticsearch's default `max_result_window`. Same clamp as core's -// `parseSearchOffset`, kept local because `@vitnode/core` is only a dev -// dependency of this package - type imports are erased, values are not. +const DEFAULT_SIZE = 20; +const MAX_SIZE = 100; const MAX_SEARCH_OFFSET = 10_000; const parseSearchOffset = ( @@ -24,10 +23,8 @@ const parseSearchOffset = ( const parsed = Number(cursor); if (!Number.isSafeInteger(parsed) || parsed < 0) return undefined; - return Math.min(parsed, Math.max(0, max)); + return Math.min(parsed, max); }; -const DEFAULT_SIZE = 20; -const MAX_SIZE = 100; interface RankingOptions { authorBoost?: { authorIds: number[]; weight: number }; @@ -416,8 +413,6 @@ export const ElasticsearchSearchAdapter = ( await ensureIndex(); const size = Math.min(params.first ?? DEFAULT_SIZE, MAX_SIZE); - // `from + size` past `max_result_window` (10,000 by default) is a 500 from - // Elasticsearch, and so is `from: NaN`. const from = parseSearchOffset(params.cursor, MAX_SEARCH_OFFSET - size) ?? 0; diff --git a/packages/vitnode/src/api/admin-staff-permission.test.ts b/packages/vitnode/src/api/admin-staff-permission.test.ts index 9b265c0dc7..1de1fd4df3 100644 --- a/packages/vitnode/src/api/admin-staff-permission.test.ts +++ b/packages/vitnode/src/api/admin-staff-permission.test.ts @@ -6,21 +6,10 @@ import { buildModule } from "./lib/module"; import { buildRoute, findAdminRoutesWithoutStaffPermission } from "./lib/route"; import { newBuildPluginApiCore } from "./plugin"; -/** - * Admin routes that check staff permissions somewhere other than - * `adminStaffPermission`. Every entry needs a reason. - */ const ALLOWLIST = [ - // The admin session itself - what the AdminCP reads before it knows anything - // about the caller's permissions. Signed-in admin only. "GET /admin/session", - // The static list of permissions every plugin declares, needed to render the - // staff editor. No records, signed-in admin only. "GET /admin/staff/permission-catalog", - // Handler-checked: any of several permissions may list roles. "GET /admin/roles/list", - // Handler-checked: the permission depends on the `{type}` path parameter - // (`staff_admins` or `staff_moderators`). "POST /admin/staff/entry/:type", "GET /admin/staff/entry/:type/:id", "PATCH /admin/staff/entry/:type/:id", @@ -77,7 +66,6 @@ describe("findAdminRoutesWithoutStaffPermission", () => { describe("core admin routes", () => { it("all require a staff permission, or are explicitly allowlisted", () => { - // Exact, so a stale allowlist entry fails as loudly as a new open route. expect( findAdminRoutesWithoutStaffPermission(newBuildPluginApiCore.hono), ).toEqual([...ALLOWLIST].sort()); diff --git a/packages/vitnode/src/api/config.ts b/packages/vitnode/src/api/config.ts index 6ac122d6af..fff75efde2 100644 --- a/packages/vitnode/src/api/config.ts +++ b/packages/vitnode/src/api/config.ts @@ -113,11 +113,6 @@ export function VitNodeAPI({ const webOrigin = CONFIG.web.origin; - // API responses are JSON, so the browser-hardening headers cost nothing. - // Left out on purpose: HSTS (with `includeSubDomains` it would pin every - // subdomain of a Single App's host - the proxy's call to make) and - // `Cross-Origin-Resource-Policy` (uploads served from the API's origin are - // embedded by the web origin in a split deployment). app.use( secureHeaders({ crossOriginResourcePolicy: false, diff --git a/packages/vitnode/src/api/lib/client-ip.ts b/packages/vitnode/src/api/lib/client-ip.ts index a8c4be0e19..75d4b7a2f2 100644 --- a/packages/vitnode/src/api/lib/client-ip.ts +++ b/packages/vitnode/src/api/lib/client-ip.ts @@ -56,10 +56,6 @@ const denoSocketAddress = ( return isRecord(remoteAddr) ? nonEmptyString(remoteAddr.hostname) : undefined; }; -/** - * The API mounted in-process by a host app (the Single App's `/api/*` route) - * has no socket of its own; the host hands over the one it accepted. - */ const hostSocketAddress = (env: Record): string | undefined => nonEmptyString(env.clientAddress); @@ -75,9 +71,6 @@ const socketAddress = (c: Context): string | undefined => { ); }; -// Behind `VITNODE_TRUSTED_PROXY_HOPS` proxies the socket is the closest proxy, -// so the visitor is that many hops from the right of `X-Forwarded-For`. -// Anything further left is client-written and never read. const proxiedAddress = (c: Context): string | undefined => { const address = resolveVisitorIp({ forwardedFor: c.req.header("x-forwarded-for"), diff --git a/packages/vitnode/src/api/lib/error-details.ts b/packages/vitnode/src/api/lib/error-details.ts index ad1a4c48a6..d8153bfb75 100644 --- a/packages/vitnode/src/api/lib/error-details.ts +++ b/packages/vitnode/src/api/lib/error-details.ts @@ -13,12 +13,7 @@ const DRIVER_FIELDS = [ const MAX_CAUSE_DEPTH = 5; -// drizzle-orm's `DrizzleQueryError` is `Failed query: \nparams: `, -// and the values are whatever was bound - addresses, password hashes, secrets. -// The SQL keeps its `$1` placeholders, so it stays. const QUERY_PARAMS = /^(Failed query: [\s\S]*?)\nparams: [\s\S]*$/; -// Postgres echoes the offending values in `detail`: -// `Key (email)=(bob@example.com) already exists.`, `Failing row contains (...)`. const DETAIL_FAILING_ROW = /Failing row contains \([\s\S]*\)/; const DETAIL_KEY_VALUES = /=\([\s\S]*\)/; @@ -66,11 +61,6 @@ const describeOne = (error: unknown): string => { return redactMessage(String(error)); }; -/** - * One line per error in the `cause` chain, for logs. Bound query parameters, - * values Postgres echoes back and credential-looking strings are redacted - the - * result ends up in `core_logs` and on the console. - */ export const describeError = (error: unknown): string => { const seen = new Set(); const parts: string[] = []; diff --git a/packages/vitnode/src/api/lib/image-signature.ts b/packages/vitnode/src/api/lib/image-signature.ts index ebc9c0ea9b..3030ed80ed 100644 --- a/packages/vitnode/src/api/lib/image-signature.ts +++ b/packages/vitnode/src/api/lib/image-signature.ts @@ -1,10 +1,3 @@ -/** - * Raster image types whose bytes are checked against the type the client - * declared. A browser fills `File.type` in from the file name, and any other - * client sends whatever it likes - so "image/png" on its own says nothing about - * what was uploaded. SVG and every non-image type are deliberately absent: they - * have no fixed signature, and they are not this check's business. - */ export const SIGNED_IMAGE_MIME_TYPES = [ "image/avif", "image/gif", @@ -32,18 +25,12 @@ const readUint32BE = (bytes: Uint8Array, offset: number): number => const AVIF_BRANDS = new Set(["avif", "avis"]); -/** - * An AVIF file opens with an ISO-BMFF `ftyp` box whose major or compatible - * brands include `avif` (still) or `avis` (sequence). A HEIC photo shares the - * box and differs only in its brands, which is why they are read at all. - */ const isAvif = (bytes: Uint8Array): boolean => { if (ascii(bytes, 4, 4) !== "ftyp") return false; const boxSize = Math.min(readUint32BE(bytes, 0), bytes.length, 256); if (AVIF_BRANDS.has(ascii(bytes, 8, 4))) return true; - // Compatible brands start after the major brand (4) and minor version (4). for (let offset = 16; offset + 4 <= boxSize; offset += 4) { if (AVIF_BRANDS.has(ascii(bytes, offset, 4))) return true; } @@ -51,7 +38,6 @@ const isAvif = (bytes: Uint8Array): boolean => { return false; }; -/** Reads the format from the file's leading bytes, or `null` if none matches. */ export const detectImageMimeType = ( bytes: Uint8Array, ): null | SignedImageMimeType => { diff --git a/packages/vitnode/src/api/lib/redact.ts b/packages/vitnode/src/api/lib/redact.ts index 6c5dbe5815..19db62c363 100644 --- a/packages/vitnode/src/api/lib/redact.ts +++ b/packages/vitnode/src/api/lib/redact.ts @@ -2,10 +2,6 @@ const EMAIL = /[^\s@<>"']+@[^\s@<>"']+\.[^\s@<>"']+/g; const CREDENTIAL_PAIR = /(key|token|secret|password|authorization)=[^\s&]+/gi; const BEARER = /Bearer\s+[A-Za-z0-9._~+/=-]+/g; -/** - * Masks the values that most often leak into an error message - addresses, - * `secret=...` pairs and bearer tokens - before it is logged or stored. - */ export const redactSecrets = (text: string): string => text .replace(EMAIL, "[email]") diff --git a/packages/vitnode/src/api/lib/route.ts b/packages/vitnode/src/api/lib/route.ts index dcb43debba..c2b16a03aa 100644 --- a/packages/vitnode/src/api/lib/route.ts +++ b/packages/vitnode/src/api/lib/route.ts @@ -16,8 +16,6 @@ export interface AdminStaffPermission { plugin?: string; } -// Every guard `buildRoute` installs, so a test can prove a mounted route is -// gated by looking at its handlers instead of driving it. const staffPermissionGuards = new WeakSet(); interface MountedRoute { @@ -31,18 +29,12 @@ const unwrapHandler = (handler: unknown): unknown => ? unwrapHandler(handler.__COMPOSED_HANDLER) : handler; -/** - * `"METHOD /path"` of every route under an `/admin/` segment of `hono` that has - * no `adminStaffPermission`. Routes in `allowlist` (same format) are skipped. - */ -export const findAdminRoutesWithoutStaffPermission = ( - hono: { routes: readonly MountedRoute[] }, - allowlist: readonly string[] = [], -): string[] => { +export const findAdminRoutesWithoutStaffPermission = (hono: { + routes: readonly MountedRoute[]; +}): string[] => { const guarded = new Map(); for (const { handler, method, path } of hono.routes) { - // `use()` middleware, e.g. the admin session check, is not a route. if (method === "ALL" || !/(^|\/)admin(\/|$)/.test(path)) continue; const label = `${method} ${path}`; @@ -54,7 +46,7 @@ export const findAdminRoutesWithoutStaffPermission = ( } return [...guarded] - .filter(([label, isGuarded]) => !isGuarded && !allowlist.includes(label)) + .filter(([, isGuarded]) => !isGuarded) .map(([label]) => label) .sort(); }; diff --git a/packages/vitnode/src/api/lib/search-offset.test.ts b/packages/vitnode/src/api/lib/search-offset.test.ts index 8bf8d84fa9..8f7961ba84 100644 --- a/packages/vitnode/src/api/lib/search-offset.test.ts +++ b/packages/vitnode/src/api/lib/search-offset.test.ts @@ -25,7 +25,6 @@ describe("parseSearchOffset", () => { }); it("clamps to a caller's own window", () => { - // Elasticsearch refuses `from + size` past 10,000, so it asks for less. expect(parseSearchOffset("99999999", MAX_SEARCH_OFFSET - 20)).toBe(9980); expect(parseSearchOffset("5", -10)).toBe(0); }); diff --git a/packages/vitnode/src/api/lib/search-offset.ts b/packages/vitnode/src/api/lib/search-offset.ts index 55602b508a..54ac00fe0c 100644 --- a/packages/vitnode/src/api/lib/search-offset.ts +++ b/packages/vitnode/src/api/lib/search-offset.ts @@ -1,19 +1,5 @@ -/** - * The furthest into a relevance-ranked result set a caller may page. - * - * Relevance ordering has no stable key to seek on, so its cursor is an offset - - * and an offset is the one pagination shape whose cost grows with the page - * number. Nobody reaches page ten thousand of a search by reading; they reach it - * by editing the URL. It is also Elasticsearch's default `max_result_window`. - */ export const MAX_SEARCH_OFFSET = 10_000; -/** - * Reads a search cursor as an offset: a non-negative safe integer, clamped to - * `max`. Anything else - `"abc"`, `"-1"`, `"1e400"` - is `undefined`, which a - * caller treats as the first page. `Number("abc")` is `NaN`, and a search engine - * answers `NaN` (or an offset past its window) with a 500 rather than a 400. - */ export const parseSearchOffset = ( cursor: string | undefined, max: number = MAX_SEARCH_OFFSET, diff --git a/packages/vitnode/src/api/middlewares/rate-limiter.middleware.ts b/packages/vitnode/src/api/middlewares/rate-limiter.middleware.ts index 1d175fc5eb..c495e0cd1f 100644 --- a/packages/vitnode/src/api/middlewares/rate-limiter.middleware.ts +++ b/packages/vitnode/src/api/middlewares/rate-limiter.middleware.ts @@ -43,11 +43,6 @@ const createRateLimiter = ({ }); }; -/** - * An IPv6 visitor is routinely handed a whole /64, so keying on the full address - * gives one attacker 2^64 fresh buckets. Count the network instead. IPv4-mapped - * addresses (`::ffff:203.0.113.7`) are the IPv4 visitor they wrap. - */ export const rateLimitKey = (ipAddress: string): string => { const mapped = /^::ffff:(\d{1,3}(?:\.\d{1,3}){3})$/i.exec(ipAddress); if (mapped) return mapped[1]; @@ -120,12 +115,7 @@ export const rateLimiterMiddleware = ( }; }; -/** - * Routes that test a credential or send an email to whoever is named in the - * body. The global budget is sized for browsing, which is ~115k password - * guesses a day per address; these get their own, much smaller one. - */ -export const AUTH_RATE_LIMITED_PATHS = [ +const AUTH_RATE_LIMITED_PATHS = [ "/@vitnode/core/users/sign_in", "/@vitnode/core/users/sign_up", "/@vitnode/core/users/reset-password", @@ -136,7 +126,7 @@ export const AUTH_RATE_LIMITED_PATHS = [ "/@vitnode/core/users/passkeys/admin-sign-in", ] as const; -export const isAuthRateLimitedRequest = (c: Context): boolean => +const isAuthRateLimitedRequest = (c: Context): boolean => c.req.method === "POST" && AUTH_RATE_LIMITED_PATHS.some(path => c.req.path.endsWith(path)); diff --git a/packages/vitnode/src/api/models/email-verification.ts b/packages/vitnode/src/api/models/email-verification.ts index 85b451d31a..3a040f10ce 100644 --- a/packages/vitnode/src/api/models/email-verification.ts +++ b/packages/vitnode/src/api/models/email-verification.ts @@ -10,25 +10,15 @@ import { CONFIG } from "@/lib/config"; import { invalidateSessionCacheForUser } from "./session-revoke"; -/** How long a confirmation link stays usable. */ export const EMAIL_VERIFICATION_TTL_MS = 1000 * 60 * 60 * 24; -/** How long a resend request is ignored after a link was issued. */ export const EMAIL_VERIFICATION_RESEND_COOLDOWN_MS = 1000 * 60 * 5; -/** The error body every refused sign-in of an unconfirmed account carries. */ export const EMAIL_NOT_VERIFIED = "email_not_verified"; -/** - * Whether this install asks members to confirm their address. - * - * Only an install that can send email can ask - without an adapter sign-up - * marks every account as confirmed, so there is nothing left to enforce. - */ export const isEmailVerificationRequired = (c: Context): boolean => Boolean(c.get("core").email?.adapter); -/** `true` when this account must confirm its address before it signs in. */ export const mustVerifyEmail = ( c: Context, user: { emailVerified: boolean }, @@ -56,16 +46,6 @@ export class EmailVerificationModel { return crypto.createHash("sha256").update(token).digest("hex"); } - /** - * Replaces the account's confirmation link with a fresh one and emails it. - * - * Only the digest is stored - the raw token travels in the email and nowhere - * else, exactly like a password reset link. With `respectCooldown` a link - * issued in the last five minutes is left alone and nothing is sent, which is - * what keeps the public resend endpoint from becoming a mail cannon. - * - * @returns whether an email was sent. - */ async send( user: EmailVerificationRecipient, { respectCooldown = false }: { respectCooldown?: boolean } = {}, @@ -125,15 +105,6 @@ export class EmailVerificationModel { return true; } - /** - * Spends a confirmation link and marks its account as confirmed. - * - * The row is deleted and read back in one statement, so two clicks racing - * each other cannot both succeed - the link is single-use by construction, - * not by a check that could be interleaved. - * - * @returns the confirmed account, or `null` for a wrong, spent or expired link. - */ async verify(token: string): Promise { const db = this.c.get("db"); @@ -155,7 +126,6 @@ export class EmailVerificationModel { .where(eq(core_users.id, spent.userId)) .returning({ email: core_users.email, id: core_users.id }); - // Any link still out there for this account has nothing left to confirm. await db .delete(core_users_confirm_emails) .where(eq(core_users_confirm_emails.userId, spent.userId)); diff --git a/packages/vitnode/src/api/models/storage-image.test.ts b/packages/vitnode/src/api/models/storage-image.test.ts index d84b71bfdb..29539a5a05 100644 --- a/packages/vitnode/src/api/models/storage-image.test.ts +++ b/packages/vitnode/src/api/models/storage-image.test.ts @@ -66,10 +66,6 @@ const makeJpeg = async (quality: number): Promise => const fileFrom = (buf: Buffer, name: string, type: string): File => new File([new Uint8Array(buf)], name, { type }); -/** - * A real 1x1 PNG whose IHDR is rewritten to claim `width` x `height`, with a - * valid CRC - the header an image bomb carries, without allocating the bomb. - */ const pngClaiming = async (width: number, height: number): Promise => { const png = Buffer.from( await sharp({ @@ -334,10 +330,6 @@ describe("StorageModel image optimization", () => { }); }); - /** - * sharp's own ceiling is ~268 million pixels. A single-colour 16383x16383 PNG - * is under a megabyte on the wire and around a gigabyte once libwebp has it. - */ describe("when the image has too many pixels", () => { it("refuses an image bomb with a 400 before decoding it", async () => { const { ctx, upload, insertValues } = makeCtx({ quality: 85 }); diff --git a/packages/vitnode/src/api/models/storage.test.ts b/packages/vitnode/src/api/models/storage.test.ts index 3694fa3734..e730ee753a 100644 --- a/packages/vitnode/src/api/models/storage.test.ts +++ b/packages/vitnode/src/api/models/storage.test.ts @@ -7,7 +7,6 @@ import { core_content_file_refs } from "@/database/content"; import { STORAGE_FILE_IN_USE, StorageModel } from "./storage"; -// The bytes have to open like a PNG for an `image/png` upload to be accepted. const PNG_SIGNATURE = new Uint8Array([ 0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a, ]); diff --git a/packages/vitnode/src/api/models/storage.ts b/packages/vitnode/src/api/models/storage.ts index ca4621c474..4368720ac1 100644 --- a/packages/vitnode/src/api/models/storage.ts +++ b/packages/vitnode/src/api/models/storage.ts @@ -29,12 +29,6 @@ import { enqueueAltAfterUpload } from "../lib/ai/alt"; const DEFAULT_IMAGE_QUALITY = 85; -/** - * The most pixels sharp will decode, unless `storage.image.maxPixels` says - * otherwise. sharp's own default is ~268 million, and a 16383x16383 PNG of one - * colour compresses to under a megabyte while asking libwebp for gigabytes. - * 40 megapixels still fits an 8K frame (33 MP) with room to spare. - */ export const DEFAULT_IMAGE_MAX_PIXELS = 40_000_000; // Formats sharp can lossily re-encode. SVG/GIF are intentionally excluded to @@ -169,11 +163,6 @@ const tooManyPixels = ({ ); }; -/** - * The declared type is what the client said, and it is also what a storage - * provider will later serve the object as. For the raster formats that carry a - * signature, the bytes have to agree. - */ const assertDeclaredImageType = (body: Buffer, mimeType: string): void => { if (!isSignedImageMimeType(mimeType)) return; @@ -283,8 +272,6 @@ export class StorageModel { const quality = image.quality ?? DEFAULT_IMAGE_QUALITY; const toWebp = image.webp !== false; const maxPixels = image.maxPixels ?? DEFAULT_IMAGE_MAX_PIXELS; - // Enforced by libvips while it reads the header, so an oversized image is - // refused before a single pixel of it is decoded. const sharpOptions = { limitInputPixels: maxPixels }; let sharp; diff --git a/packages/vitnode/src/api/models/user/sign-in-with-passwords.ts b/packages/vitnode/src/api/models/user/sign-in-with-passwords.ts index f0698bd308..7d3659ae18 100644 --- a/packages/vitnode/src/api/models/user/sign-in-with-passwords.ts +++ b/packages/vitnode/src/api/models/user/sign-in-with-passwords.ts @@ -47,9 +47,5 @@ export const signInWithPassword = async ({ throw new HTTPException(403); } - // Whether the address is confirmed is only ever reported from here - after - // the password has been proven. Saying so any earlier would tell a stranger - // which addresses hold an unconfirmed account without them knowing a single - // password. return { id: user.id, email: user.email, emailVerified: user.emailVerified }; }; diff --git a/packages/vitnode/src/api/modules/admin/staff/lib/staff-entry-guards.ts b/packages/vitnode/src/api/modules/admin/staff/lib/staff-entry-guards.ts index ff294ea386..56e2f76380 100644 --- a/packages/vitnode/src/api/modules/admin/staff/lib/staff-entry-guards.ts +++ b/packages/vitnode/src/api/modules/admin/staff/lib/staff-entry-guards.ts @@ -85,9 +85,6 @@ const exceedsCeiling = ( ); }; -// A staff entry may only be changed or removed by someone who holds everything -// it grants - otherwise a limited administrator could demote or delete a more -// privileged one. Root and unrestricted callers are exempt. export const assertCanManageStaffEntry = async ( c: Context, { type, entry }: { entry: StaffEntryGrant; type: PermissionStaffType }, diff --git a/packages/vitnode/src/api/modules/admin/users/routes/verify-email.route.ts b/packages/vitnode/src/api/modules/admin/users/routes/verify-email.route.ts index c5739e4d52..0f1d746b08 100644 --- a/packages/vitnode/src/api/modules/admin/users/routes/verify-email.route.ts +++ b/packages/vitnode/src/api/modules/admin/users/routes/verify-email.route.ts @@ -78,7 +78,6 @@ export const verifyEmailUserAdminRoute = buildRoute({ emailVerified: core_users.emailVerified, }); - // A confirmation link still in the member's inbox has nothing left to do. await db .delete(core_users_confirm_emails) .where(eq(core_users_confirm_emails.userId, user.id)); diff --git a/packages/vitnode/src/api/modules/users/credential-limits.ts b/packages/vitnode/src/api/modules/users/credential-limits.ts index cc2f3eb7de..88df5922ce 100644 --- a/packages/vitnode/src/api/modules/users/credential-limits.ts +++ b/packages/vitnode/src/api/modules/users/credential-limits.ts @@ -1,16 +1,4 @@ -/** - * The longest email address or name an account can have - the width of the - * `core_users` columns they are stored in. A longer value could never be saved, - * so it is refused by the schema instead of by the database. - */ export const USER_EMAIL_MAX_LENGTH = 255; export const USER_NAME_MAX_LENGTH = 255; -/** - * The longest password any route accepts. - * - * Every password is run through scrypt, and the cost of that grows with the - * input. Without a cap an anonymous caller could post megabytes of "password" - * to the sign-in route and make the server derive a key over all of it. - */ export const USER_PASSWORD_MAX_LENGTH = 1024; diff --git a/packages/vitnode/src/api/modules/users/passkeys/routes/authentication-verify.route.ts b/packages/vitnode/src/api/modules/users/passkeys/routes/authentication-verify.route.ts index 9f06f0adeb..5f776940b6 100644 --- a/packages/vitnode/src/api/modules/users/passkeys/routes/authentication-verify.route.ts +++ b/packages/vitnode/src/api/modules/users/passkeys/routes/authentication-verify.route.ts @@ -56,10 +56,6 @@ export const passkeyAuthenticationVerifyRoute = buildRoute({ response, "authentication", ); - // The same rule as a password sign-in: an install that sends - // confirmation emails does not open a session for an address nobody has - // confirmed yet. Checked after the passkey verified, so a stranger learns - // nothing from it. const [user] = await c .get("db") .select({ emailVerified: core_users.emailVerified }) diff --git a/packages/vitnode/src/api/modules/users/routes/change-password.route.ts b/packages/vitnode/src/api/modules/users/routes/change-password.route.ts index 65a92b73ec..7a5d6f283b 100644 --- a/packages/vitnode/src/api/modules/users/routes/change-password.route.ts +++ b/packages/vitnode/src/api/modules/users/routes/change-password.route.ts @@ -90,10 +90,6 @@ export const changePasswordRoute = buildRoute({ ]); await c.get("events").emit("user.password.updated", { userId }); - // The reset link went to the account's own address, so following it proves - // the mailbox just as a confirmation link would. Someone who registered - // before confirmation emails went out - or lost theirs - is not left locked - // out after choosing a new password. const [confirmed] = await c .get("db") .update(core_users) diff --git a/packages/vitnode/src/api/modules/users/routes/resend-verification.route.ts b/packages/vitnode/src/api/modules/users/routes/resend-verification.route.ts index 2d4db1170e..db3e0878c1 100644 --- a/packages/vitnode/src/api/modules/users/routes/resend-verification.route.ts +++ b/packages/vitnode/src/api/modules/users/routes/resend-verification.route.ts @@ -49,8 +49,6 @@ export const resendVerificationRoute = buildRoute({ }, }, handler: async c => { - // Decided before the address is looked at, so the answer is the same for - // every caller - see the reset route for why that matters. if (!isEmailVerificationRequired(c)) { throw new HTTPException(404, { message: "Email verification is not available", @@ -73,8 +71,6 @@ export const resendVerificationRoute = buildRoute({ .limit(2); const user = pickAccountForEmail(candidates, email); - // No account, or nothing left to confirm: the same 201 as a real send, so - // this endpoint cannot be used to find out which addresses are registered. if (!user || user.emailVerified) { return RESPONSE_TEXT; } diff --git a/packages/vitnode/src/api/modules/users/routes/reset-passowrd.route.ts b/packages/vitnode/src/api/modules/users/routes/reset-passowrd.route.ts index 1feadfbfdc..c3636fd021 100644 --- a/packages/vitnode/src/api/modules/users/routes/reset-passowrd.route.ts +++ b/packages/vitnode/src/api/modules/users/routes/reset-passowrd.route.ts @@ -51,11 +51,6 @@ export const resetPasswordRoute = buildRoute({ handler: async c => { assertPasswordSignInEnabled(c); - // Before the lookup, and the same for every address. It used to be left to - // the email model, which only ran for an address that had an account: an - // unknown one got the usual 201 and a known one a 500 "Email provider not - // found" - so on an install without email the response said outright - // whether an address was registered. if (!c.get("core").email?.adapter) { throw new HTTPException(404, { message: "Password reset is not available", diff --git a/packages/vitnode/src/api/modules/users/routes/sign-in.route.ts b/packages/vitnode/src/api/modules/users/routes/sign-in.route.ts index 18bed705bd..b07c19ccee 100644 --- a/packages/vitnode/src/api/modules/users/routes/sign-in.route.ts +++ b/packages/vitnode/src/api/modules/users/routes/sign-in.route.ts @@ -57,9 +57,6 @@ export const signInRoute = buildRoute({ 201: { content: { "application/json": { - // The session token travels in the HttpOnly cookie and nowhere - // else. Echoing it here handed it to every script on the page, - // which is the one thing HttpOnly exists to prevent. schema: z.object({ id: z.number(), }), @@ -79,10 +76,6 @@ export const signInRoute = buildRoute({ }); if (isAdmin) { - // The AdminCP admits staff only, and staff are chosen by an - // administrator - so it does not wait on the address being confirmed. - // An install that just started sending confirmation emails would - // otherwise lock every existing administrator out of the AdminCP. await new SessionAdminModel(c).createSessionByUserId(data.id); return c.json({ id: data.id }, 201); diff --git a/packages/vitnode/src/api/modules/users/routes/sign-up.route.ts b/packages/vitnode/src/api/modules/users/routes/sign-up.route.ts index b48f2d7356..051740dd8a 100644 --- a/packages/vitnode/src/api/modules/users/routes/sign-up.route.ts +++ b/packages/vitnode/src/api/modules/users/routes/sign-up.route.ts @@ -87,10 +87,6 @@ export const signUpRoute = buildRoute({ if (data.emailVerified) { await new SessionModel(c).createSessionByUserId(data.id); } else { - // The account exists either way. A link that failed to go out is not a - // reason to answer this sign-up with an error - the member would retry, - // hit "email already exists", and be stuck - so it is logged and the - // member can ask for another one from the sign-in page. try { await new EmailVerificationModel(c).send(data); } catch (error) { diff --git a/packages/vitnode/src/api/modules/users/routes/verify-email.route.ts b/packages/vitnode/src/api/modules/users/routes/verify-email.route.ts index eb44d2eb60..199845f0da 100644 --- a/packages/vitnode/src/api/modules/users/routes/verify-email.route.ts +++ b/packages/vitnode/src/api/modules/users/routes/verify-email.route.ts @@ -46,10 +46,6 @@ export const verifyEmailRoute = buildRoute({ handler: async c => { const { token } = c.req.valid("json"); - // Deliberately no session here. Whoever holds the link owns the mailbox, - // not necessarily the password - an account somebody else registered with - // your address is confirmed by your click, but it must not sign *you* into - // a stranger's account. The member signs in with their own password next. const user = await new EmailVerificationModel(c).verify(token); if (!user) { diff --git a/packages/vitnode/src/api/modules/users/sso/routes/callback.route.ts b/packages/vitnode/src/api/modules/users/sso/routes/callback.route.ts index 523a312f34..f5205f11e7 100644 --- a/packages/vitnode/src/api/modules/users/sso/routes/callback.route.ts +++ b/packages/vitnode/src/api/modules/users/sso/routes/callback.route.ts @@ -62,7 +62,6 @@ export const callbackRoute = buildRoute({ ); } - // The session lives in the HttpOnly cookie only - see the sign-in route. await new SessionModel(c).createSessionByUserId(outcome.userId); return c.json({ id: outcome.userId }, 200); diff --git a/packages/vitnode/src/api/modules/users/sso/routes/callback.test.ts b/packages/vitnode/src/api/modules/users/sso/routes/callback.test.ts index f33f16be50..be40138b14 100644 --- a/packages/vitnode/src/api/modules/users/sso/routes/callback.test.ts +++ b/packages/vitnode/src/api/modules/users/sso/routes/callback.test.ts @@ -133,7 +133,6 @@ describe("signing in through an SSO provider", () => { const response = await h.signInThroughGoogle(); expect(response.status).toBe(200); - // The session token lives in the HttpOnly cookie, never in the body. expect(await response.json()).toEqual({ id: 7 }); expect(h.createSession).toHaveBeenCalledWith(7); expect(h.createAdminSession).not.toHaveBeenCalled(); diff --git a/packages/vitnode/src/api/modules/users/sso/routes/link.route.ts b/packages/vitnode/src/api/modules/users/sso/routes/link.route.ts index 076bc873f7..24e3a2d5b6 100644 --- a/packages/vitnode/src/api/modules/users/sso/routes/link.route.ts +++ b/packages/vitnode/src/api/modules/users/sso/routes/link.route.ts @@ -68,7 +68,6 @@ export const linkRoute = buildRoute({ providerId, token, }); - // The session lives in the HttpOnly cookie only - see the sign-in route. await new SessionModel(c).createSessionByUserId(userId); return c.json({ id: userId }, 201); diff --git a/packages/vitnode/src/blocks/built-in/cta.tsx b/packages/vitnode/src/blocks/built-in/cta.tsx index 87c11cc324..fefa178693 100644 --- a/packages/vitnode/src/blocks/built-in/cta.tsx +++ b/packages/vitnode/src/blocks/built-in/cta.tsx @@ -28,7 +28,6 @@ const Cta = ({ data }: BlockComponentProps) => ( ) : null} - {/* Same rule as a menu item: a site path or an https URL, never `javascript:`. */} {isValidNavigationHref(data.href) ? ( ) => {

) : null} - {/* Same rule as a menu item: a site path or an https URL, never `javascript:`. */} {data.linkHref && data.linkLabel && isValidNavigationHref(data.linkHref) ? ( diff --git a/packages/vitnode/src/content/server/editorial-service.ts b/packages/vitnode/src/content/server/editorial-service.ts index 7da31fc6f1..e3c5745e8e 100644 --- a/packages/vitnode/src/content/server/editorial-service.ts +++ b/packages/vitnode/src/content/server/editorial-service.ts @@ -999,8 +999,6 @@ export const createContentEditorialService = < // satisfies a `maxBytes` somebody has since lowered is a different // question, and the answer is the same 422 a dropped column gets. try { - // Every file id here comes from this record's own history, so its - // origin was settled when the revision was written. await assertContentFileReferences(c, definition, patch, tx, { existing: async () => Promise.resolve(patch), }); diff --git a/packages/vitnode/src/content/server/files.ts b/packages/vitnode/src/content/server/files.ts index 9d4b98728d..ae06fee6cb 100644 --- a/packages/vitnode/src/content/server/files.ts +++ b/packages/vitnode/src/content/server/files.ts @@ -234,22 +234,9 @@ export const resolveContentPublicRowFiles = async ( }; export interface ContentFileReferenceOptions { - /** - * The values the record already holds - or, for a restore, held. A file id - * found in them is accepted whatever its origin, so a record keeps saving with - * the files it has. Only read when some id fails the origin check. - */ existing?: () => Promise>; } -/** - * Whether a caller may point a field at a file it did not just keep. - * - * Only a file uploaded through this content type's own upload route, or one the - * calling user uploaded themselves - never somebody else's avatar or another - * plugin's attachment found by counting ids. Server code running without a - * user (a job, a seed) picks its own ids and is trusted. - */ const isContentFileOriginAllowed = ( row: ContentFileOriginRow, { @@ -309,8 +296,6 @@ export const assertContentFileReferences = async ( for (const { id, name } of named) { const descriptor = byId.get(id); - // A file the caller may not use answers exactly like one that does not - // exist, so the error is not a way to learn which ids are taken. if (!descriptor) { throw new ContentFileReferenceError({ code: CONTENT_FILE_CODES.missing, diff --git a/packages/vitnode/src/content/server/revalidate-bridge.test.ts b/packages/vitnode/src/content/server/revalidate-bridge.test.ts index 4cf5a233a6..57bc52f92a 100644 --- a/packages/vitnode/src/content/server/revalidate-bridge.test.ts +++ b/packages/vitnode/src/content/server/revalidate-bridge.test.ts @@ -43,7 +43,6 @@ const harness = ({ return { c, fetch, log }; }; -// A fresh module per test, so the warn-once memory does not leak between them. const load = async () => await import("./revalidate-bridge"); describe("dispatchContentRevalidation", () => { diff --git a/packages/vitnode/src/content/server/revalidate-bridge.ts b/packages/vitnode/src/content/server/revalidate-bridge.ts index 3cc1320469..389f1683d1 100644 --- a/packages/vitnode/src/content/server/revalidate-bridge.ts +++ b/packages/vitnode/src/content/server/revalidate-bridge.ts @@ -26,11 +26,6 @@ export interface ContentRevalidationRequest extends ContentInvalidationInput { const LOOPBACK_HOSTS = new Set(["127.0.0.1", "[::1]", "localhost"]); -/** - * Each misconfiguration is reported once per process. Every scheduled run hits - * the same one, and a warning per run buries the log it is meant to stand out - * in. - */ const warned = new Set(); const warnOnce = (c: Context, key: string, message: string): void => { @@ -39,11 +34,6 @@ const warnOnce = (c: Context, key: string, message: string): void => { void log(c, message, "warn"); }; -/** - * The bearer is `CRON_SECRET` itself - the key to every cron job - so it only - * travels over TLS. Plain `http` is allowed for a loopback front end, which is - * what development looks like. - */ const isSafeOrigin = (origin: string): boolean => { if (CONFIG.node_development) return true; @@ -85,9 +75,6 @@ export const dispatchContentRevalidation = async ( const secret = c.get("core")?.cronSecret ?? CONFIG.cronJobSecret; - // The built-in default is in the repository, so outside development sending - // it proves nothing and hands it to every origin listed. Every origin counts - // as not delivered, so the run records the failure instead of hiding it. if (isCronSecretRejected(secret)) { warnOnce( c, diff --git a/packages/vitnode/src/emails/reset-password.tsx b/packages/vitnode/src/emails/reset-password.tsx index e11c52beb3..2e708f0877 100644 --- a/packages/vitnode/src/emails/reset-password.tsx +++ b/packages/vitnode/src/emails/reset-password.tsx @@ -1,18 +1,7 @@ -import { Hr, Link, Section, Text } from "react-email"; -import { createTranslator } from "use-intl"; - import DefaultTemplateEmail, { type DefaultTemplateEmailProps, } from "./default-template"; -import { EmailButton } from "./ui/button"; -import { - EmailCard, - EmailCardContent, - EmailCardDescription, - EmailCardFooter, - EmailCardHeader, - EmailCardTitle, -} from "./ui/card"; +import { ActionLinkEmail } from "./ui/action-link-email"; interface ResetPasswordEmailTemplateProps extends DefaultTemplateEmailProps { expiryDate: Date; @@ -61,89 +50,14 @@ ResetPasswordEmailTemplate.PreviewProps = { } satisfies ResetPasswordEmailTemplateProps; export default function ResetPasswordEmailTemplate({ - i18n, resetUrl, - expiryDate, - userIpAddress, - user, ...props }: ResetPasswordEmailTemplateProps) { - const t = createTranslator(i18n); - - const userName = user?.name ?? "there"; - return ( - - - - - {t("core.auth.reset_password.email.greeting", { name: userName })} - - - {t("core.auth.reset_password.email.intro")} - - - - -
- - {t("core.auth.reset_password.email.button")} - -
- - - {t("core.auth.reset_password.email.instructions")} - - - - {t("core.auth.reset_password.email.expire_time", { - date: expiryDate.toLocaleString(i18n.locale, { - year: "numeric", - month: "long", - day: "numeric", - hour: "2-digit", - minute: "2-digit", - timeZoneName: "short", - }), - })} - -
- - -
- - - {t("core.auth.reset_password.email.no_action")} - - - {userIpAddress && ( - - {t("core.auth.reset_password.email.security_note", { - ip: userIpAddress, - })} - - )} -
-
- -
- - {t("core.auth.reset_password.email.help")} - - - {resetUrl} - -
-
+ ); } diff --git a/packages/vitnode/src/emails/ui/action-link-email.tsx b/packages/vitnode/src/emails/ui/action-link-email.tsx new file mode 100644 index 0000000000..2504d061cf --- /dev/null +++ b/packages/vitnode/src/emails/ui/action-link-email.tsx @@ -0,0 +1,103 @@ +import { Hr, Link, Section, Text } from "react-email"; +import { createTranslator } from "use-intl"; + +import DefaultTemplateEmail, { + type DefaultTemplateEmailProps, +} from "../default-template"; +import { EmailButton } from "./button"; +import { + EmailCard, + EmailCardContent, + EmailCardDescription, + EmailCardFooter, + EmailCardHeader, + EmailCardTitle, +} from "./card"; + +export interface ActionLinkEmailProps extends DefaultTemplateEmailProps { + actionUrl: string; + expiryDate: Date; + namespace: "core.auth.reset_password.email" | "core.auth.verify_email.email"; + userIpAddress?: string; +} + +export const ActionLinkEmail = ({ + actionUrl, + expiryDate, + i18n, + namespace, + user, + userIpAddress, + ...props +}: ActionLinkEmailProps) => { + const t = createTranslator({ ...i18n, namespace }); + + const userName = user?.name ?? "there"; + + return ( + + + + {t("greeting", { name: userName })} + {t("intro")} + + + +
+ + {t("button")} + +
+ + + {t("instructions")} + + + + {t("expire_time", { + date: expiryDate.toLocaleString(i18n.locale, { + year: "numeric", + month: "long", + day: "numeric", + hour: "2-digit", + minute: "2-digit", + timeZoneName: "short", + }), + })} + +
+ + +
+ + + {t("no_action")} + + + {userIpAddress && ( + + {t("security_note", { ip: userIpAddress })} + + )} +
+
+ +
+ {t("help")} + + {actionUrl} + +
+
+ ); +}; diff --git a/packages/vitnode/src/emails/verify-email.tsx b/packages/vitnode/src/emails/verify-email.tsx index 0cc06e4417..33901d1871 100644 --- a/packages/vitnode/src/emails/verify-email.tsx +++ b/packages/vitnode/src/emails/verify-email.tsx @@ -1,18 +1,7 @@ -import { Hr, Link, Section, Text } from "react-email"; -import { createTranslator } from "use-intl"; - import DefaultTemplateEmail, { type DefaultTemplateEmailProps, } from "./default-template"; -import { EmailButton } from "./ui/button"; -import { - EmailCard, - EmailCardContent, - EmailCardDescription, - EmailCardFooter, - EmailCardHeader, - EmailCardTitle, -} from "./ui/card"; +import { ActionLinkEmail } from "./ui/action-link-email"; interface VerifyEmailTemplateProps extends DefaultTemplateEmailProps { expiryDate: Date; @@ -61,89 +50,14 @@ VerifyEmailTemplate.PreviewProps = { } satisfies VerifyEmailTemplateProps; export default function VerifyEmailTemplate({ - i18n, verifyUrl, - expiryDate, - userIpAddress, - user, ...props }: VerifyEmailTemplateProps) { - const t = createTranslator(i18n); - - const userName = user?.name ?? "there"; - return ( - - - - - {t("core.auth.verify_email.email.greeting", { name: userName })} - - - {t("core.auth.verify_email.email.intro")} - - - - -
- - {t("core.auth.verify_email.email.button")} - -
- - - {t("core.auth.verify_email.email.instructions")} - - - - {t("core.auth.verify_email.email.expire_time", { - date: expiryDate.toLocaleString(i18n.locale, { - year: "numeric", - month: "long", - day: "numeric", - hour: "2-digit", - minute: "2-digit", - timeZoneName: "short", - }), - })} - -
- - -
- - - {t("core.auth.verify_email.email.no_action")} - - - {userIpAddress && ( - - {t("core.auth.verify_email.email.security_note", { - ip: userIpAddress, - })} - - )} -
-
- -
- - {t("core.auth.verify_email.email.help")} - - - {verifyUrl} - -
-
+ ); } diff --git a/packages/vitnode/src/lib/api/like-pattern.ts b/packages/vitnode/src/lib/api/like-pattern.ts index b74701d201..49d3f5dd9e 100644 --- a/packages/vitnode/src/lib/api/like-pattern.ts +++ b/packages/vitnode/src/lib/api/like-pattern.ts @@ -1,10 +1,5 @@ -/** - * Escapes the `LIKE` wildcards so a search for "100%" matches the literal text - * rather than every row. Backslash is Postgres' default escape character. - */ export const escapeLikePattern = (value: string): string => value.replace(/[\\%_]/g, match => `\\${match}`); -/** `%term%` with the term's own wildcards escaped - a literal "contains". */ export const containsLikePattern = (value: string): string => `%${escapeLikePattern(value)}%`; diff --git a/packages/vitnode/src/lib/fetcher/raw.ts b/packages/vitnode/src/lib/fetcher/raw.ts index 79a1ec150d..263ec415c7 100644 --- a/packages/vitnode/src/lib/fetcher/raw.ts +++ b/packages/vitnode/src/lib/fetcher/raw.ts @@ -110,10 +110,6 @@ export const rawApiFetch = async ({ body: formData ?? (body === undefined ? undefined : JSON.stringify(body)), }); - // Only the path. The origin is the internal `VITNODE_API_URL`, and the query - // can carry credentials - the SSO callback forwards the OAuth `code` and - // `state` - while this message reaches logs and, from a loader, the error - // payload serialised into the SSR HTML. const loggedPath = url.pathname; if (response.status === 500) { diff --git a/packages/vitnode/src/routes.tsx b/packages/vitnode/src/routes.tsx index ff3db15817..7dbee423ae 100644 --- a/packages/vitnode/src/routes.tsx +++ b/packages/vitnode/src/routes.tsx @@ -126,8 +126,6 @@ export const routes = defineRoutes([ page("/login/verify-email", { component: lazy(() => import("./pages/login/verify-email")), - // Declared rather than loaded inside `load`: the crumb renders one of - // these strings, and a breadcrumb is drawn outside the page. messages: VERIFY_EMAIL_NAMESPACES, pendingComponent: AuthPendingSkeleton, search: normalizeVerifyEmailSearch, diff --git a/packages/vitnode/src/tanstack/auth/actions.ts b/packages/vitnode/src/tanstack/auth/actions.ts index 73c603e24d..8ec67e54eb 100644 --- a/packages/vitnode/src/tanstack/auth/actions.ts +++ b/packages/vitnode/src/tanstack/auth/actions.ts @@ -225,11 +225,6 @@ export const changePasswordFromResetAction: ChangePasswordSubmit = await authTransport().changePasswordFromReset(values), ); -/** - * Spends the confirmation link a sign-up emailed. No session comes back - the - * member signs in with their own password afterwards - so there is no identity - * boundary to cross and nothing cached to drop. - */ export const confirmEmailAction = (token: string): ConfirmEmailSubmit => async () => diff --git a/packages/vitnode/src/tanstack/auth/contract.ts b/packages/vitnode/src/tanstack/auth/contract.ts index e06e055217..ce427fa357 100644 --- a/packages/vitnode/src/tanstack/auth/contract.ts +++ b/packages/vitnode/src/tanstack/auth/contract.ts @@ -141,7 +141,6 @@ const emailNotVerifiedBodySchema = z.object({ error: z.literal("email_not_verified"), }); -/** Whether a refused sign-in was refused only because the address is unconfirmed. */ export const isEmailNotVerifiedBody = (body: unknown): boolean => emailNotVerifiedBodySchema.safeParse(body).success; @@ -171,7 +170,6 @@ export const passkeySignInStartResultFromStatus = ( return { ok: true, options: body }; }; -/** What every passkey sign-in, public or AdminCP, can answer. */ type PasskeyCeremonyResult = | { ok: false; reason: "access_denied" | "expired" | "server_error" } | { ok: true }; @@ -426,7 +424,6 @@ export const changePasswordResultFromStatus = ( return { ok: false, reason: "server_error" }; }; -/** The link a confirmation email carries - the same alphabet a reset token uses. */ export const verifyEmailInputSchema = z.object({ token: z .string() @@ -461,7 +458,6 @@ export const verifyEmailResultFromStatus = ( return { ok: false, reason: "server_error" }; }; -/** A request for a fresh confirmation link: one address, and the captcha. */ export const resendEmailVerificationInputSchema = passwordResetRequestInputSchema; @@ -469,10 +465,6 @@ export type ResendEmailVerificationInput = z.infer< typeof resendEmailVerificationInputSchema >; -/** - * Like a reset request, an accepted resend says nothing about the address - the - * API answers the same 201 whether or not there was anything to send. - */ export type ResendEmailVerificationResult = PasswordResetRequestResult; export const resendEmailVerificationResultFromStatus = diff --git a/packages/vitnode/src/tanstack/auth/verify-email-route.ts b/packages/vitnode/src/tanstack/auth/verify-email-route.ts index a94d4e92ce..67f2c37f66 100644 --- a/packages/vitnode/src/tanstack/auth/verify-email-route.ts +++ b/packages/vitnode/src/tanstack/auth/verify-email-route.ts @@ -8,14 +8,6 @@ import { EmailVerificationUnknownError, } from "./verify-email"; -/** - * Warms the deployment configuration - the resend form needs its captcha - and - * answers "not found" on an install that cannot send email, where there is - * nothing to confirm. - * - * The strings are not fetched here: the route declares - * `VERIFY_EMAIL_NAMESPACES` in `messages`, so the runtime warms them first. - */ export const loadVerifyEmailRoute = async ({ queryClient, }: { @@ -25,10 +17,8 @@ export const loadVerifyEmailRoute = async ({ await loadMiddlewareConfig(queryClient), ); - // Not a 404: the route exists, the API could not say whether the flow does. if (availability === "unknown") throw new EmailVerificationUnknownError(); - // TanStack Router's own control-flow signal, like `redirect()`. // oxlint-disable-next-line typescript/only-throw-error if (availability === "disabled") throw notFound(); }; diff --git a/packages/vitnode/src/tanstack/auth/verify-email-screen.tsx b/packages/vitnode/src/tanstack/auth/verify-email-screen.tsx index 201bf39ca4..026bcd3898 100644 --- a/packages/vitnode/src/tanstack/auth/verify-email-screen.tsx +++ b/packages/vitnode/src/tanstack/auth/verify-email-screen.tsx @@ -24,8 +24,6 @@ export const VerifyEmailRouteContent = ({ search }: VerifyEmailRouteProps) => { {mode.mode === "confirm" ? ( diff --git a/packages/vitnode/src/tanstack/auth/verify-email.ts b/packages/vitnode/src/tanstack/auth/verify-email.ts index bdd9b568fd..5ff4604f23 100644 --- a/packages/vitnode/src/tanstack/auth/verify-email.ts +++ b/packages/vitnode/src/tanstack/auth/verify-email.ts @@ -12,11 +12,6 @@ export const normalizeVerifyEmailSearch = ( return typeof token === "string" && token !== "" ? { token } : {}; }; -/** - * `confirm` for a URL that carries a usable link, `resend` for anything else - - * the bare page, and a token that could never be valid, both of which leave the - * visitor needing a new link rather than a button that is bound to fail. - */ export type VerifyEmailMode = | { mode: "confirm"; token: string } | { mode: "resend" }; @@ -29,7 +24,6 @@ export const verifyEmailMode = (search: VerifyEmailSearch): VerifyEmailMode => { : { mode: "resend" }; }; -/** What both verify-email screens render strings from, crumb included. */ export const VERIFY_EMAIL_NAMESPACES = [ "core.global", "core.auth.sign_up", @@ -41,11 +35,6 @@ export type EmailVerificationAvailability = | "disabled" | "unknown"; -/** - * Whether this install confirms addresses at all - only one that can send - * email does. Without an adapter every account is confirmed at sign-up, and the - * page has nothing to offer. - */ export const emailVerificationAvailability = ({ isEmail, isKnown, diff --git a/packages/vitnode/src/tanstack/start/create-start.ts b/packages/vitnode/src/tanstack/start/create-start.ts index 651807bfe2..d73ca6c704 100644 --- a/packages/vitnode/src/tanstack/start/create-start.ts +++ b/packages/vitnode/src/tanstack/start/create-start.ts @@ -18,10 +18,6 @@ export interface VitNodeStartOptions< config: VitNodeConfig; requestMiddleware?: readonly AnyRequestMiddleware[]; - /** - * Extra or replacement headers for rendered HTML pages, laid over - * `DOCUMENT_SECURITY_HEADERS`. `false` drops a default. - */ securityHeaders?: DocumentSecurityHeaders; } diff --git a/packages/vitnode/src/tanstack/start/document-headers.ts b/packages/vitnode/src/tanstack/start/document-headers.ts index a707d7ca39..b5aaa8c28a 100644 --- a/packages/vitnode/src/tanstack/start/document-headers.ts +++ b/packages/vitnode/src/tanstack/start/document-headers.ts @@ -1,23 +1,7 @@ export const DOCUMENT_CACHE_CONTROL = "private, no-store"; -/** - * Header name -> value, or `false` to drop one of the defaults. - */ export type DocumentSecurityHeaders = Record; -/** - * Sent with every rendered HTML page. Deliberately conservative: - * - * - `frame-ancestors 'self'` and its legacy twin `X-Frame-Options` stop another - * site from framing the AdminCP and clickjacking a signed-in admin; - * - `nosniff` keeps the browser from reinterpreting a response's type; - * - `strict-origin-when-cross-origin` keeps paths and query strings out of the - * `Referer` sent to other sites. - * - * No script policy - one strict enough to matter would break plugins and - * inline bootstrapping - and no HSTS, which only belongs on an origin that is - * HTTPS for good. Both can be added through `securityHeaders`. - */ export const DOCUMENT_SECURITY_HEADERS: Readonly> = { "content-security-policy": "frame-ancestors 'self'", "referrer-policy": "strict-origin-when-cross-origin", @@ -25,7 +9,6 @@ export const DOCUMENT_SECURITY_HEADERS: Readonly> = { "x-frame-options": "SAMEORIGIN", }; -/** The defaults with an install's own `securityHeaders` laid over them. */ export const resolveDocumentSecurityHeaders = ( overrides: DocumentSecurityHeaders = {}, ): Record => { @@ -50,10 +33,6 @@ export const applyDocumentCacheControl = (response: Response): void => { response.headers.set("cache-control", DOCUMENT_CACHE_CONTROL); }; -/** - * Adds the security headers to a rendered HTML page. A header the response - * already carries - one a route set for itself - is left as it is. - */ export const applyDocumentSecurityHeaders = ( response: Response, headers: Record = DOCUMENT_SECURITY_HEADERS, diff --git a/packages/vitnode/src/views/auth/sign-in/form/sign-in-form-content.tsx b/packages/vitnode/src/views/auth/sign-in/form/sign-in-form-content.tsx index d5f5c9b211..fb00dfc11f 100644 --- a/packages/vitnode/src/views/auth/sign-in/form/sign-in-form-content.tsx +++ b/packages/vitnode/src/views/auth/sign-in/form/sign-in-form-content.tsx @@ -50,7 +50,6 @@ export const SignInFormContent = ({ showResetPassword = false, }: { onSignIn: SignInSubmit; - /** Where "send a new confirmation link" goes for an unconfirmed account. */ resendVerificationHref?: string; resetPasswordHref?: string; /** Whether this deployment has an email adapter that can send a reset link. */ diff --git a/packages/vitnode/src/views/auth/sign-in/form/use-sign-in-form.ts b/packages/vitnode/src/views/auth/sign-in/form/use-sign-in-form.ts index 10cabd42fa..c90bcd1f3b 100644 --- a/packages/vitnode/src/views/auth/sign-in/form/use-sign-in-form.ts +++ b/packages/vitnode/src/views/auth/sign-in/form/use-sign-in-form.ts @@ -34,8 +34,6 @@ export const useSignInForm = ({ onSignIn }: { onSignIn: SignInSubmit }) => { const outcome = signInFormOutcome(await onSignIn(values)); if (outcome?.kind === "field") { - // Carried to the resend screen, so asking for a new link is one click - // rather than typing the address a second time. if (outcome.error === "email_not_verified") rememberEmail(values.email); setFailure(current => ({ diff --git a/packages/vitnode/src/views/auth/verify-email/confirm-email-content.tsx b/packages/vitnode/src/views/auth/verify-email/confirm-email-content.tsx index 906053d03d..759e5d360e 100644 --- a/packages/vitnode/src/views/auth/verify-email/confirm-email-content.tsx +++ b/packages/vitnode/src/views/auth/verify-email/confirm-email-content.tsx @@ -93,13 +93,6 @@ const InvalidLinkView = ({ resendHref }: { resendHref: string }) => { ); }; -/** - * The screen a confirmation link opens. - * - * It waits for a click rather than spending the link on load: mail scanners and - * link previews open every URL in an email, and one that confirmed on arrival - * would let a scanner confirm an account its owner never asked for. - */ export const ConfirmEmailContent = ({ onConfirm, resendHref = AUTH_HREF.verifyEmail, diff --git a/packages/vitnode/src/views/auth/verify-email/schema.ts b/packages/vitnode/src/views/auth/verify-email/schema.ts index 31472ca3d3..bff43aeeb5 100644 --- a/packages/vitnode/src/views/auth/verify-email/schema.ts +++ b/packages/vitnode/src/views/auth/verify-email/schema.ts @@ -1,6 +1,5 @@ import { z } from "zod"; -/** What spending a confirmation link can come back with. */ export type ConfirmEmailMutationResult = | { email: string; kind: "confirmed" } | { kind: "error" } @@ -20,17 +19,11 @@ export type ResendVerificationFormSchema = ReturnType< typeof createResendVerificationFormSchema >; -/** What the resend form sends: the address, and the captcha the route requires. */ export interface ResendVerificationSubmitValues { captchaToken: string; email: string; } -/** - * Only "accepted" or "the request failed". The API answers the same 201 for an - * address with an unconfirmed account and for one without, so there is no third - * outcome here that could tell them apart. - */ export type ResendVerificationMutationResult = | undefined | { message: "Internal Server Error" }; diff --git a/packages/vitnode/src/vitnode.config.ts b/packages/vitnode/src/vitnode.config.ts index eaf5b242c3..d64bf91e8e 100644 --- a/packages/vitnode/src/vitnode.config.ts +++ b/packages/vitnode/src/vitnode.config.ts @@ -120,10 +120,6 @@ export interface VitNodeApiConfig { notifications?: Partial; plugins: BuildPluginApiReturn[]; rateLimiter?: Omit & { - /** - * Separate, smaller budget for sign-in, sign-up, password reset and passkey - * sign-in. Defaults to 10 requests per 60 seconds per address. - */ auth?: Partial>; }; @@ -137,11 +133,6 @@ export interface VitNodeApiConfig { adapter?: StorageApiPlugin; image?: { - /** - * Largest image, in total pixels (width x height), that will be decoded. - * Anything bigger is refused with a 400 before it is processed. - * Defaults to 40 million. - */ maxPixels?: number; quality?: number; webp?: boolean; diff --git a/plugins/blog/src/api/admin-staff-permission.test.ts b/plugins/blog/src/api/admin-staff-permission.test.ts index bb402b74ca..67debb8692 100644 --- a/plugins/blog/src/api/admin-staff-permission.test.ts +++ b/plugins/blog/src/api/admin-staff-permission.test.ts @@ -8,7 +8,6 @@ describe("blog admin routes", () => { it("all require a staff permission", () => { const { hono } = blogApiPlugin(); - // Guards against passing vacuously on an empty route table. expect(hono.routes.some(route => route.path.includes("/admin/"))).toBe( true, ); diff --git a/plugins/example/src/api/admin-staff-permission.test.ts b/plugins/example/src/api/admin-staff-permission.test.ts index 8f05024f41..6fcd68e5b5 100644 --- a/plugins/example/src/api/admin-staff-permission.test.ts +++ b/plugins/example/src/api/admin-staff-permission.test.ts @@ -8,7 +8,6 @@ describe("example admin routes", () => { it("all require a staff permission", () => { const { hono } = exampleApiPlugin(); - // Guards against passing vacuously on an empty route table. expect(hono.routes.some(route => route.path.includes("/admin/"))).toBe( true, ); diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index e7ee1177d7..04331bae30 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -11,19 +11,9 @@ packageExtensions: '@tiptap/y-tiptap': dependencies: prosemirror-transform: 1.12.1 -# Security patches for transitive dependencies. Each selector only matches the -# vulnerable range, so an entry stops applying once its dependents move on and -# can then be deleted. overrides: - # GHSA-vcvr-r3jv-pc5j. `@react-email/ui` pins `next@16.3.3`; fumadocs peers on it. 'next@>=16.2.0 <16.3.6': ~16.3.8 - # GHSA-p6vx-979v-rg4c, GHSA-jp82-f5mq-hwhp. solid-js 1.9.15 asks for - # `seroval@~1.5.4`. 1.9.16 moves to `~1.6.8` with no change to how it calls - # seroval, but it is still inside `minimumReleaseAge`; drop these two entries - # once solid-js 1.9.16 resolves on its own. 'seroval@<1.6.3': ~1.6.8 'seroval-plugins@<1.6.3': ~1.6.8 - # GHSA-g7r4-m6w7-qqqr (Windows dev server). tsup still asks for `^0.27.0`. 'esbuild@>=0.27.3 <0.28.1': ^0.28.2 - # GHSA-ch52-4w7c-c8xp. Dev only: @swc/cli > got > cacheable-request (`^4.2.0`). 'http-cache-semantics@<4.2.1': ^4.3.0