From 59a65c41a295255652d810f464c285adde299ee5 Mon Sep 17 00:00:00 2001 From: aXenDeveloper Date: Thu, 5 Nov 2026 16:06:01 +0100 Subject: [PATCH] fix(security): close canary audit findings across core - Content files, comments, SSO and payments audit findings - Pin cache generation per request, re-auth for passkeys - HTTPS-only update links in the AdminCP, wider CSV formula guard - Findings from the full, line-by-line and thematic core scans - Staff and user routes invalidate all staff permissions - Align passkey and timeline tests with the new security rules Co-Authored-By: Claude Opus 5.5 --- apps/api/package.json | 2 +- .../docs/content-engine/public-lists-api.mdx | 1 + .../docs/dev/advanced/rate-limiter.mdx | 2 +- .../content/docs/dev/ai/costs-and-limits.mdx | 7 +- apps/web/content/docs/dev/ai/errors.mdx | 2 +- apps/web/content/docs/dev/ai/streaming.mdx | 3 +- .../docs/dev/passkeys/using-passkeys.mdx | 2 + apps/web/content/docs/dev/payments/plugin.mdx | 2 +- .../content/docs/dev/sso/custom-adapter.mdx | 2 +- .../docs/dev/sso/email-confirmation.mdx | 2 +- apps/web/package.json | 2 +- package.json | 2 +- packages/vitnode/package.json | 4 +- .../src/api/adapters/search/postgres.ts | 8 +- packages/vitnode/src/api/lib/ai/budget.ts | 4 +- .../vitnode/src/api/lib/ai/memory-ledger.ts | 1 + packages/vitnode/src/api/lib/ai/periods.ts | 5 +- .../vitnode/src/api/lib/ai/postgres-ledger.ts | 1 + .../api/lib/check-staff-permission.test.ts | 11 +- .../src/api/lib/check-staff-permission.ts | 12 +- .../src/api/lib/moderation/snapshot.ts | 14 ++ .../api/lib/notifications/digest-period.ts | 13 +- .../vitnode/src/api/lib/notifications/safe.ts | 5 +- .../vitnode/src/api/lib/payments/errors.ts | 5 + .../api/lib/staff-permission-cache.test.ts | 75 +------- .../src/api/lib/staff-permission-cache.ts | 89 +++------ .../middlewares/rate-limiter.middleware.ts | 8 +- .../src/api/models/email-verification.ts | 37 ++-- packages/vitnode/src/api/models/passkey.ts | 21 ++- packages/vitnode/src/api/models/payments.ts | 5 +- .../vitnode/src/api/models/session-admin.ts | 2 +- packages/vitnode/src/api/models/session.ts | 5 + .../vitnode/src/api/models/sso-connection.ts | 2 +- packages/vitnode/src/api/models/sso.ts | 14 +- packages/vitnode/src/api/models/storage.ts | 5 +- .../api/models/user/sign-in-with-passwords.ts | 22 ++- .../admin/debug/routes/test-ai.route.ts | 3 + .../admin/routes/notifications.route.ts | 8 +- .../admin/staff/routes/create.route.ts | 4 +- .../admin/staff/routes/delete.route.ts | 4 +- .../staff/routes/update-permissions.route.ts | 4 +- .../admin/users/routes/update.route.ts | 6 +- .../src/api/modules/moderation/lib/reports.ts | 5 +- .../modules/moderation/routes/create.route.ts | 13 +- .../modules/moderation/routes/show.route.ts | 3 +- .../api/modules/search/routes/facets.route.ts | 4 +- .../modules/search/routes/popularity.route.ts | 9 +- .../api/modules/search/routes/search.route.ts | 6 +- .../search/routes/timeline.route.test.ts | 5 +- .../modules/search/routes/timeline.route.ts | 7 +- .../modules/users/passkeys/passkeys.test.ts | 3 + .../src/api/modules/users/passkeys/schema.ts | 1 + .../users/routes/password-sign-in.test.ts | 2 +- .../api/modules/users/routes/sign-in.route.ts | 43 ++++- .../modules/users/routes/time-zone.route.ts | 7 +- .../users/sso/connections/connections.test.ts | 8 + .../modules/users/sso/routes/callback.test.ts | 1 + .../attachment/attachment-node-view.tsx | 3 +- .../tiptap/attachment/attachment.ts | 3 +- .../src/content/access/server/decide.ts | 4 +- .../content/access/server/download-route.ts | 24 +++ .../content/access/server/file-protection.ts | 38 +++- .../src/content/access/server/files.ts | 20 ++ packages/vitnode/src/content/registry.ts | 17 ++ .../vitnode/src/content/rich-text/document.ts | 36 +++- .../vitnode/src/content/rich-text/elements.ts | 30 ++- .../src/content/server/delivery-routes.ts | 5 +- packages/vitnode/src/content/server/files.ts | 11 +- packages/vitnode/src/content/server/index.ts | 5 +- .../vitnode/src/content/server/live/auth.ts | 4 + .../src/content/server/live/documents.ts | 22 ++- .../src/content/server/public-cache.ts | 42 ++++- .../src/content/server/public-routes.ts | 14 +- .../server/public-visibility-routes.ts | 9 +- .../content/server/public-write-service.ts | 66 ++++++- .../src/content/server/refresh/auth.ts | 4 + .../src/content/server/search-document.ts | 1 + .../src/content/server/search-indexer.ts | 1 + .../src/content/server/search-related.ts | 44 ++++- packages/vitnode/src/lib/api/upload.ts | 6 + packages/vitnode/src/lib/audit/csv.ts | 2 +- packages/vitnode/src/locales/en.json | 4 + .../src/page-cache/render-context.server.ts | 7 +- packages/vitnode/src/page-cache/tags.ts | 15 ++ .../src/tanstack/admin/dashboard/widgets.tsx | 1 + .../vitnode/src/tanstack/auth/return-to.ts | 2 + .../vitnode/src/tanstack/link/href-link.tsx | 1 + .../src/tanstack/page-cache/server.test.ts | 7 +- .../vitnode/src/tanstack/page-cache/server.ts | 3 +- packages/vitnode/src/tanstack/prerender.ts | 7 +- .../tanstack/realtime/realtime-listeners.tsx | 2 + .../tanstack/search/header-search-content.tsx | 2 +- .../src/tanstack/start/locale-request.ts | 7 +- .../views/core/debug/sections/security.tsx | 5 +- .../settings/passkeys/passkeys-mutations.ts | 18 +- plugins/blog/package.json | 2 +- .../modules/categories/categories.module.ts | 5 + plugins/forum/package.json | 2 +- plugins/newsroom/package.json | 2 +- plugins/payments-demo/package.json | 2 +- plugins/payments-demo/src/locales/en.json | 4 + .../payments-demo/src/pages/start-checkout.ts | 1 + pnpm-lock.yaml | 173 +++++++++--------- 103 files changed, 844 insertions(+), 382 deletions(-) diff --git a/apps/api/package.json b/apps/api/package.json index 3f432523d5..28b9015143 100644 --- a/apps/api/package.json +++ b/apps/api/package.json @@ -36,7 +36,7 @@ "react-dom": "^19.3.0", "use-intl": "^4.14.9", "ws": "^8.22.0", - "zod": "^4.6.5" + "zod": "^4.6.6" }, "devDependencies": { "@hono/node-server": "^2.1.4", diff --git a/apps/web/content/docs/content-engine/public-lists-api.mdx b/apps/web/content/docs/content-engine/public-lists-api.mdx index 739f6e4272..8afa4a320c 100644 --- a/apps/web/content/docs/content-engine/public-lists-api.mdx +++ b/apps/web/content/docs/content-engine/public-lists-api.mdx @@ -119,4 +119,5 @@ These live in `@vitnode/core/content/server` and are server-only. Use them in a | `resolveContentRequestLocale(c, definition)` | The request's locale - `?locale=`, then `Accept-Language`, then the default - or `null` for an explicit locale the install does not serve | | `contentLocaleHeaders(resolved)` | `Content-Language`, plus `Vary: Accept-Language` when the header decided the locale | | `rememberContentPublicRead(c, { contentTypeId, key, load })` | Caches a read that is the same for every reader until the content type's next write | +| `pinContentPublicGeneration(c, contentTypeId)` | Call first in a handler that caches: an access rule changed mid-request then never lands in the cache | | `contentListPageCacheTags(definition)` | The tags a cached page that read the content type's records needs, for `setPageCacheTags` | diff --git a/apps/web/content/docs/dev/advanced/rate-limiter.mdx b/apps/web/content/docs/dev/advanced/rate-limiter.mdx index 6795c00aa3..13c71b7ded 100644 --- a/apps/web/content/docs/dev/advanced/rate-limiter.mdx +++ b/apps/web/content/docs/dev/advanced/rate-limiter.mdx @@ -17,7 +17,7 @@ Every request spends points from the budgets that apply to it, in this order, an | Safety cap | Every request, including `OPTIONS` preflights | 1000 / 60 s | `rateLimiter.safety` | | Read | `GET` and `HEAD` | 300 / 60 s | `rateLimiter.read` | | Write | `POST`, `PUT`, `PATCH`, `DELETE` and any other method | 60 / 60 s | `rateLimiter.write` | -| Sign-in | `POST` to the sign-in, sign-up, password, email verification and passkey routes, plus write | 10 / 60 s | `rateLimiter.auth` | +| Sign-in | `POST` to the sign-in, sign-up, password, email verification, passkey and SSO link or pending-email routes, plus write | 10 / 60 s | `rateLimiter.auth` | | Content writes | `POST` and `PATCH` under a plugin's `/content/`, outside `/admin/`, plus write | 20 / 60 s | `rateLimiter.contentWrite` | So a sign-in spends the safety cap, the write budget and the sign-in budget. A `GET` spends only the safety cap and the read budget. Each budget is spent at most once per request. diff --git a/apps/web/content/docs/dev/ai/costs-and-limits.mdx b/apps/web/content/docs/dev/ai/costs-and-limits.mdx index ee385a2610..bee02329ab 100644 --- a/apps/web/content/docs/dev/ai/costs-and-limits.mdx +++ b/apps/web/content/docs/dev/ai/costs-and-limits.mdx @@ -271,10 +271,13 @@ How it stays correct with many API instances: | Retry: a lost attempt, then a valid answer | Full reservation (unknown) | Only the answering call, +1 run | | Provider returned an error | Real cost (usually $0) | Nothing | | Invalid output (empty, failed validation) | Real cost, since tokens were used | Nothing | -| Timeout or canceled, before any text was sent | Full reservation (unknown) | Nothing | -| Stream canceled or failed after text was sent | Full reservation (unknown) | Full reservation in points, +1 run | +| Timeout, before any text was sent | Full reservation (unknown) | Nothing | +| Canceled by the reader, at any point | Full reservation (unknown) | Full reservation in points, +1 run | +| Stream failed after text was sent | Full reservation (unknown) | Full reservation in points, +1 run | | Process died mid-call (lease expired) | Full reservation (unknown) | Nothing | +A canceled run costs the reader even with no text yet: the provider bills the call either way, and a free cancel would let anyone spend the site's budget by dropping the connection. + **A user is charged only when they received output** - a valid result, or streamed text they read before canceling. Otherwise stopping a stream once the answer arrived would be a free run. When the provider fails, times out, or the answer is unusable, the site pays for whatever the provider may have billed. Reconciliation can correct an unknown charge later. Runs whose process died stay `running` until their lease expires, which takes `timeoutMs` per possible attempt plus one minute. The `ai-maintenance` cron (every 10 minutes) then settles them as `uncertain`. The same cron reconciles costs and prunes history, so the host must run VitNode's cron. See [Cron](/docs/dev/cron). diff --git a/apps/web/content/docs/dev/ai/errors.mdx b/apps/web/content/docs/dev/ai/errors.mdx index 59cabd7248..5fec674572 100644 --- a/apps/web/content/docs/dev/ai/errors.mdx +++ b/apps/web/content/docs/dev/ai/errors.mdx @@ -62,7 +62,7 @@ HTTP/1.1 403 | `AI_PROVIDER_FAILED` | 502 | The provider returned an error. | Try again; check the provider key and status. | | `AI_INVALID_OUTPUT` | 502 | The answer was empty or failed `parseText()` / `outputSchema`. | Try again; loosen the schema or tighten the prompt if it repeats. | | `AI_TIMEOUT` | 504 | The provider took longer than `timeoutMs`. | Try again, or raise `timeoutMs`. | -| `AI_CANCELED` | 408 | The `signal` aborted the run. | Nothing; the reader stopped it. | +| `AI_CANCELED` | 408 | The `signal` aborted the run. | Points and +1 run; the provider still billed it. | VitNode charges a user only when they received output, meaning a valid result or streamed text that reached them before they stopped it. When the provider fails, times out or answers unusably, the site budget pays whatever the provider billed. [Who pays what](/docs/dev/ai/costs-and-limits#who-pays-what) has the full table. diff --git a/apps/web/content/docs/dev/ai/streaming.mdx b/apps/web/content/docs/dev/ai/streaming.mdx index 1036cd73a7..49144345d3 100644 --- a/apps/web/content/docs/dev/ai/streaming.mdx +++ b/apps/web/content/docs/dev/ai/streaming.mdx @@ -174,8 +174,7 @@ A failure after the stream started arrives as a last line `{"e":{"code":"AI_TIME A stream makes one attempt on the primary model, with no retries and no fallback model. When the reader stops it, the run settles as `canceled`: -- If no text had reached the reader yet, they pay nothing. -- If some text had arrived, Core charges their points and daily count as for a delivered run. +- Core charges their points and daily count as for a delivered run, whether or not any text had arrived. The provider bills the call either way, so a free cancel would be a free way to spend the site's budget. ## Related diff --git a/apps/web/content/docs/dev/passkeys/using-passkeys.mdx b/apps/web/content/docs/dev/passkeys/using-passkeys.mdx index b751fe2a55..20b19622b7 100644 --- a/apps/web/content/docs/dev/passkeys/using-passkeys.mdx +++ b/apps/web/content/docs/dev/passkeys/using-passkeys.mdx @@ -29,6 +29,8 @@ The passkey gets a name automatically, like "iCloud Keychain" or "Chrome (Mac OS passkey for your account - you're good to go. +Adding a passkey needs a sign-in from the last hour, so someone holding an old session can't sneak their own key into your account. If you signed in earlier than that, sign out, sign back in and add it right away. + You can add as many passkeys as you like - for example one on your phone and one on your laptop. If your password manager syncs passkeys, one passkey follows you to all your devices; these show a **Synced** badge. Passkeys marked **This device only** live on one device or security key. ## Sign in with a passkey diff --git a/apps/web/content/docs/dev/payments/plugin.mdx b/apps/web/content/docs/dev/payments/plugin.mdx index 8b61804c9d..66b02ceecd 100644 --- a/apps/web/content/docs/dev/payments/plugin.mdx +++ b/apps/web/content/docs/dev/payments/plugin.mdx @@ -130,7 +130,7 @@ open checkout (`resumed: true`) while it has more than 5 minutes left. When the provider, amount, currency or description changed, or the checkout is about to close, core closes it at the provider and starts a new one. Another member's open payment for the reference answers `reference_in_use`, and one that is -still settling answers `payment_processing`. +still settling answers `payment_processing`. When closing the old checkout finds it already paid (its webhook had not arrived yet), it answers `already_paid` instead of charging again. Errors are `PaymentError`s from `@vitnode/core/api/lib/payments/errors`; `provider_unavailable` is safe to retry. diff --git a/apps/web/content/docs/dev/sso/custom-adapter.mdx b/apps/web/content/docs/dev/sso/custom-adapter.mdx index a2bf5346fc..dd0c820a89 100644 --- a/apps/web/content/docs/dev/sso/custom-adapter.mdx +++ b/apps/web/content/docs/dev/sso/custom-adapter.mdx @@ -234,7 +234,7 @@ No `authorization` block yet? Add it with just `ssoAdapters`, as in [SSO](/docs/ type: "(code: string, context: SSOCallbackContext) => Promise", }, fetchUser: { - description: "Returns the provider account: a stable id, and optionally email, emailVerified, username, avatarUrl, firstName and lastName. Validate the ID token here when the provider issues one. Set emailVerified: true only when the provider verified the address. Return emailVerified: false (or email: null) whenever you cannot verify ownership from the provider's response; VitNode then asks a new visitor to confirm an email. An omitted emailVerified counts as true, for older adapters. A returning member is always recognized by id.", + description: "Returns the provider account: a stable id, and optionally email, emailVerified, username, avatarUrl, firstName and lastName. Validate the ID token here when the provider issues one. Set emailVerified: true only when the provider verified the address. Return emailVerified: false (or email: null) whenever you cannot verify ownership from the provider's response; VitNode then asks a new visitor to confirm an email. An omitted emailVerified counts as false, so an adapter that forgets it cannot make VitNode trust an unconfirmed address. A returning member is always recognized by id.", required: true, type: "(token: SSOTokenResponse, context: SSOCallbackContext) => Promise", }, diff --git a/apps/web/content/docs/dev/sso/email-confirmation.mdx b/apps/web/content/docs/dev/sso/email-confirmation.mdx index af6b9bb2e7..50841192e0 100644 --- a/apps/web/content/docs/dev/sso/email-confirmation.mdx +++ b/apps/web/content/docs/dev/sso/email-confirmation.mdx @@ -17,7 +17,7 @@ Signing in at the provider proves the visitor controls that provider account, no - With `emailVerified: true`, the adapter asserts the provider verified the address, and VitNode trusts it. - With `emailVerified: false`, the address is not trusted, and a new identity confirms an email first. -- With `emailVerified` omitted, VitNode treats it like `true`, so adapters written before the field existed keep working. A [custom adapter](/docs/dev/sso/custom-adapter) that cannot tell must return `false` instead. +- With `emailVerified` omitted, VitNode treats it like `false`. A [custom adapter](/docs/dev/sso/custom-adapter) written before the field existed now sends new visitors through email confirmation until it returns `true` for addresses the provider verified. - With `email: null` or no `email`, there is nothing to trust, and a new identity confirms an email first. Each built-in adapter sets `emailVerified` from the provider's own flag, such as `email_verified`, Microsoft's `xms_edov` or X's `confirmed_email`; the [provider table](/docs/dev/sso#providers) lists them. diff --git a/apps/web/package.json b/apps/web/package.json index 737e205f42..8521cddba8 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -52,7 +52,7 @@ "tailwindcss": "^4.3.3", "tslib": "^2.8.1", "use-intl": "^4.14.9", - "zod": "^4.6.5" + "zod": "^4.6.6" }, "devDependencies": { "@tanstack/devtools-vite": "^0.8.5", diff --git a/package.json b/package.json index 3d6bc884a0..dc6a5be726 100644 --- a/package.json +++ b/package.json @@ -33,7 +33,7 @@ "tsx": "^4.23.15", "turbo": "^2.11.7", "typescript": "^6.0.3", - "zod": "^4.6.5" + "zod": "^4.6.6" }, "engines": { "node": "^22.18.0 || ^24.11.0 || >=26.0.0" diff --git a/packages/vitnode/package.json b/packages/vitnode/package.json index b238f17375..03cbd27f6b 100644 --- a/packages/vitnode/package.json +++ b/packages/vitnode/package.json @@ -91,7 +91,7 @@ "typescript": "^6.0.3", "vite": "^8.3.4", "vitest": "^5.0.3", - "zod": "^4.6.5" + "zod": "^4.6.6" }, "bin": { "vitnode": "./cli.mjs" @@ -295,7 +295,7 @@ "react-day-picker": "^10.0.2", "react-resizable-panels": "^4.14.3", "redis": "^6.3.0", - "sanitize-html": "^2.18.0", + "sanitize-html": "^2.18.1", "semver": "^7.8.5", "shadcn": "^4.21.4", "sharp": "^0.35.5", diff --git a/packages/vitnode/src/api/adapters/search/postgres.ts b/packages/vitnode/src/api/adapters/search/postgres.ts index fd13cb3b18..d2dba71659 100644 --- a/packages/vitnode/src/api/adapters/search/postgres.ts +++ b/packages/vitnode/src/api/adapters/search/postgres.ts @@ -34,18 +34,20 @@ const CONTENT_EXCERPT_LENGTH = 1000; * The rows after the cursor in `(createdAt, id)` order - the order the page is * sorted by. The cursor is a row id, so the boundary's `createdAt` is read * back from that row, as `::text` to keep its microseconds. - * A cursor whose row has since gone falls back to the id alone. + * A cursor whose row has since gone - or that this viewer may not see - falls + * back to the id alone, so a cursor can't probe when a hidden row was created. */ const keysetAfter = async ( c: Context, cursor: number, oldest: boolean, + filters: SQL | undefined, ): Promise => { const [boundary] = await c .get("db") .select({ createdAt: sql`${core_search_index.createdAt}::text` }) .from(core_search_index) - .where(eq(core_search_index.id, cursor)) + .where(and(eq(core_search_index.id, cursor), filters)) .limit(1); if (!boundary) { return oldest @@ -150,7 +152,7 @@ export const PostgresSearchAdapter = (): SearchProviderApiPlugin => ({ oldest ? asc(core_search_index.id) : desc(core_search_index.id), ); if (cursorValue) { - const cond = await keysetAfter(c, cursorValue, oldest); + const cond = await keysetAfter(c, cursorValue, oldest, filters); where = where ? and(where, cond) : cond; } } diff --git a/packages/vitnode/src/api/lib/ai/budget.ts b/packages/vitnode/src/api/lib/ai/budget.ts index 62291e76e3..fe0e29ce74 100644 --- a/packages/vitnode/src/api/lib/ai/budget.ts +++ b/packages/vitnode/src/api/lib/ai/budget.ts @@ -126,12 +126,14 @@ export const deliveredCostUsd = ( }; export const settlementCharge = ({ + canceled = false, chargedUsd, delivered, deliveredUsd, outputSent = false, unit, }: { + canceled?: boolean; chargedUsd: Decimal; delivered: boolean; deliveredUsd: Decimal; @@ -139,7 +141,7 @@ export const settlementCharge = ({ unit: AiBudgetUnit; }): Decimal => { if (unit === "usd") return chargedUsd; - if (!delivered && !outputSent) return 0n; + if (!delivered && !outputSent && !canceled) return 0n; if (unit === "points") { return usdToPoints(delivered ? deliveredUsd : chargedUsd); } diff --git a/packages/vitnode/src/api/lib/ai/memory-ledger.ts b/packages/vitnode/src/api/lib/ai/memory-ledger.ts index b1209e113b..842f2b5251 100644 --- a/packages/vitnode/src/api/lib/ai/memory-ledger.ts +++ b/packages/vitnode/src/api/lib/ai/memory-ledger.ts @@ -312,6 +312,7 @@ export class MemoryAiLedger implements AiLedger { const row = this.budgets.get(hold.key); if (!row) continue; const charge = settlementCharge({ + canceled: settlement.status === "canceled", chargedUsd, deliveredUsd, delivered: settlement.delivered, diff --git a/packages/vitnode/src/api/lib/ai/periods.ts b/packages/vitnode/src/api/lib/ai/periods.ts index f7f4ebdf80..899a17b1b4 100644 --- a/packages/vitnode/src/api/lib/ai/periods.ts +++ b/packages/vitnode/src/api/lib/ai/periods.ts @@ -29,7 +29,10 @@ const formatterFor = (timeZone: string): Intl.DateTimeFormat => { timeZone, year: "numeric", }); - formatterCache.set(timeZone, formatter); + const canonical = formatter.resolvedOptions().timeZone; + const cached = formatterCache.get(canonical); + if (cached) return cached; + formatterCache.set(canonical, formatter); } return formatter; diff --git a/packages/vitnode/src/api/lib/ai/postgres-ledger.ts b/packages/vitnode/src/api/lib/ai/postgres-ledger.ts index fffd7ba623..455a225af3 100644 --- a/packages/vitnode/src/api/lib/ai/postgres-ledger.ts +++ b/packages/vitnode/src/api/lib/ai/postgres-ledger.ts @@ -600,6 +600,7 @@ export class PostgresAiLedger implements AiLedger { let chargedPoints = 0n; for (const hold of holds) { const charge = settlementCharge({ + canceled: settlement.status === "canceled", chargedUsd, deliveredUsd, delivered: settlement.delivered, diff --git a/packages/vitnode/src/api/lib/check-staff-permission.test.ts b/packages/vitnode/src/api/lib/check-staff-permission.test.ts index 8121c7692f..f5cf5de1d6 100644 --- a/packages/vitnode/src/api/lib/check-staff-permission.test.ts +++ b/packages/vitnode/src/api/lib/check-staff-permission.test.ts @@ -21,10 +21,7 @@ import { resolveStaffPermissions, } from "./check-staff-permission"; import { normalizePermissionStaffModules } from "./permission-staff"; -import { - invalidateAllStaffPermissions, - invalidateStaffEntry, -} from "./staff-permission-cache"; +import { invalidateAllStaffPermissions } from "./staff-permission-cache"; const ROOT_ROLE = 1; const MEMBER_ROLE = 3; @@ -226,7 +223,7 @@ describe("getStaffFlags", () => { expect((await getStaffFlags(c, USER)).isModerator).toBe(true); await c.db.delete(core_moderators_permissions); - await invalidateStaffEntry(c, { userId: USER.id }); + await invalidateAllStaffPermissions(c); expect((await getStaffFlags(c, USER)).isModerator).toBe(false); }); @@ -241,7 +238,7 @@ describe("getStaffFlags", () => { expect((await getStaffFlags(c, USER)).isAdmin).toBe(true); await c.db.delete(core_admin_permissions); - await invalidateStaffEntry(c, { roleId: STAFF_ROLE }); + await invalidateAllStaffPermissions(c); expect((await getStaffFlags(c, USER)).isAdmin).toBe(false); }); @@ -362,7 +359,7 @@ describe("resolveStaffPermissions with scoped grants", () => { .set({ permissions: [reports("can_view_reports", ["b"])] }); expect(await resolve()).toEqual([reports("can_view_reports", ["a"])]); - await invalidateStaffEntry(c, { userId: USER.id }); + await invalidateAllStaffPermissions(c); expect(await resolve()).toEqual([reports("can_view_reports", ["b"])]); }); }); diff --git a/packages/vitnode/src/api/lib/check-staff-permission.ts b/packages/vitnode/src/api/lib/check-staff-permission.ts index 98f80ed540..a03ccf12bc 100644 --- a/packages/vitnode/src/api/lib/check-staff-permission.ts +++ b/packages/vitnode/src/api/lib/check-staff-permission.ts @@ -23,10 +23,11 @@ import { import { readRequestStaffPermissions, readStaffPermissions, + readStaffPermissionsGeneration, readStaffPermissionsOfUser, rememberRequestStaffPermissions, STAFF_TYPES, - writeStaffPermissions, + writeStaffPermissionsInGeneration, } from "./staff-permission-cache"; const tableByType = { @@ -140,9 +141,11 @@ const loadAndCacheStaffPermissions = async ( c: Context, args: { type: PermissionStaffType; user: StaffUser }, ): Promise => { + const generation = await readStaffPermissionsGeneration(c); const resolved = await loadStaffPermissions(c, args); - await writeStaffPermissions( + await writeStaffPermissionsInGeneration( c, + generation, { type: args.type, userId: args.user.id }, resolved, ); @@ -227,6 +230,7 @@ export const isStaff = async ( } } + const generation = live ? null : await readStaffPermissionsGeneration(c); const roleIds = await findStaffUserRoleIds(c, userId); if (!roleIds) return false; @@ -235,7 +239,9 @@ export const isStaff = async ( type, userId, }); - if (!live) await writeStaffPermissions(c, args, resolved); + if (generation !== null) { + await writeStaffPermissionsInGeneration(c, generation, args, resolved); + } rememberRequestStaffPermissions(c, args, resolved, { live: true }); return resolved.staff; diff --git a/packages/vitnode/src/api/lib/moderation/snapshot.ts b/packages/vitnode/src/api/lib/moderation/snapshot.ts index 8c363b1873..30f03f55fe 100644 --- a/packages/vitnode/src/api/lib/moderation/snapshot.ts +++ b/packages/vitnode/src/api/lib/moderation/snapshot.ts @@ -3,6 +3,7 @@ import type { ModerationSnapshotField, } from "@/lib/moderation/snapshot"; +import { safeNotificationTarget } from "@/api/lib/notifications/safe"; import { richTextToPlainText } from "@/content/rich-text/plain-text"; import { MODERATION_LIMITS, @@ -11,6 +12,18 @@ import { import type { ModerationCaptureResult } from "./targets"; +export const safeReportTargetUrl = (value: unknown): null | string => { + const path = safeNotificationTarget(value); + if (path !== null) return path; + if (typeof value !== "string") return null; + + try { + return new URL(value).protocol === "https:" ? value : null; + } catch { + return null; + } +}; + const byteLength = (value: unknown): number => Buffer.byteLength(JSON.stringify(value), "utf8"); @@ -51,6 +64,7 @@ export const sealReportSnapshot = ({ target, title, truncated, + url: safeReportTargetUrl(capture.url), version: MODERATION_SNAPSHOT_VERSION, }; diff --git a/packages/vitnode/src/api/lib/notifications/digest-period.ts b/packages/vitnode/src/api/lib/notifications/digest-period.ts index 38246a5f2d..76d930e7fd 100644 --- a/packages/vitnode/src/api/lib/notifications/digest-period.ts +++ b/packages/vitnode/src/api/lib/notifications/digest-period.ts @@ -28,12 +28,23 @@ const formatterFor = (timeZone: string): Intl.DateTimeFormat => { weekday: "short", year: "numeric", }); - formatters.set(timeZone, formatter); + const canonical = formatter.resolvedOptions().timeZone; + const cached = formatters.get(canonical); + if (cached) return cached; + formatters.set(canonical, formatter); } return formatter; }; +export const canonicalTimeZone = (timeZone: string): null | string => { + try { + return formatterFor(timeZone).resolvedOptions().timeZone; + } catch { + return null; + } +}; + export const isValidTimeZone = (timeZone: string): boolean => { try { formatterFor(timeZone); diff --git a/packages/vitnode/src/api/lib/notifications/safe.ts b/packages/vitnode/src/api/lib/notifications/safe.ts index a79c9f77dd..219b3b8e75 100644 --- a/packages/vitnode/src/api/lib/notifications/safe.ts +++ b/packages/vitnode/src/api/lib/notifications/safe.ts @@ -17,7 +17,10 @@ export const safeNotificationTarget = (target: unknown): null | string => { const url = new URL(target, base); if (url.origin !== base) return null; - return `${url.pathname}${url.search}${url.hash}`; + const safe = `${url.pathname}${url.search}${url.hash}`; + if (safe.startsWith("//") || safe.includes("\\")) return null; + + return safe; } catch { return null; } diff --git a/packages/vitnode/src/api/lib/payments/errors.ts b/packages/vitnode/src/api/lib/payments/errors.ts index 1c99400623..46ce7844c3 100644 --- a/packages/vitnode/src/api/lib/payments/errors.ts +++ b/packages/vitnode/src/api/lib/payments/errors.ts @@ -14,6 +14,11 @@ export const PAYMENT_ERROR_STATUS = { payment_in_progress: 409, /** The customer finished a checkout that the provider has not settled yet. */ payment_processing: 409, + /** + * The open attempt being replaced turned out to be paid already. Starting a + * new checkout would charge the customer twice for the same reference. + */ + already_paid: 409, /** The provider refused to create the checkout; the attempt is `failed`. */ provider_rejected: 502, /** The provider could not be reached or is misconfigured. Safe to retry. */ diff --git a/packages/vitnode/src/api/lib/staff-permission-cache.test.ts b/packages/vitnode/src/api/lib/staff-permission-cache.test.ts index 816789aed4..8da2235ac3 100644 --- a/packages/vitnode/src/api/lib/staff-permission-cache.test.ts +++ b/packages/vitnode/src/api/lib/staff-permission-cache.test.ts @@ -7,8 +7,6 @@ import type { ResolvedStaffPermissionSet } from "./permission-staff"; import { resolveStaffPermissions } from "./check-staff-permission"; import { invalidateAllStaffPermissions, - invalidateStaffEntry, - invalidateStaffPermissionsForUser, readStaffPermissions, writeStaffPermissions, } from "./staff-permission-cache"; @@ -155,77 +153,6 @@ describe("staff permission cache", () => { ).toBeNull(); } }); - - it("a per-user invalidation leaves everyone else warm", async () => { - const c = context(cache); - - for (const userId of [1, 2]) { - await writeStaffPermissions( - c, - { type: "admin", userId }, - { root: true, permissions: [], staff: true }, - ); - await writeStaffPermissions( - c, - { type: "moderator", userId }, - { root: true, permissions: [], staff: true }, - ); - } - - await invalidateStaffPermissionsForUser(c, 1); - - expect( - await readStaffPermissions(c, { type: "admin", userId: 1 }), - ).toBeNull(); - expect( - await readStaffPermissions(c, { type: "moderator", userId: 1 }), - ).toBeNull(); - expect( - await readStaffPermissions(c, { type: "admin", userId: 2 }), - ).not.toBeNull(); - }); - - describe("invalidateStaffEntry", () => { - it("expires only the named user for a user-scoped entry", async () => { - const c = context(cache); - - await writeStaffPermissions( - c, - { type: "admin", userId: 1 }, - { root: true, permissions: [], staff: true }, - ); - await writeStaffPermissions( - c, - { type: "admin", userId: 2 }, - { root: true, permissions: [], staff: true }, - ); - - await invalidateStaffEntry(c, { roleId: null, userId: 1 }); - - expect( - await readStaffPermissions(c, { type: "admin", userId: 1 }), - ).toBeNull(); - expect( - await readStaffPermissions(c, { type: "admin", userId: 2 }), - ).not.toBeNull(); - }); - - it("expires everyone for a role-scoped entry", async () => { - const c = context(cache); - - await writeStaffPermissions( - c, - { type: "admin", userId: 2 }, - { root: true, permissions: [], staff: true }, - ); - - await invalidateStaffEntry(c, { roleId: 5, userId: null }); - - expect( - await readStaffPermissions(c, { type: "admin", userId: 2 }), - ).toBeNull(); - }); - }); }); describe("resolveStaffPermissions", () => { @@ -265,7 +192,7 @@ describe("resolveStaffPermissions", () => { user: USER, }); - await invalidateStaffPermissionsForUser(context(cache), USER.id); + await invalidateAllStaffPermissions(context(cache)); const after = fakeDb(oneStaffEntry()); await resolveStaffPermissions(context(cache, after.db), { diff --git a/packages/vitnode/src/api/lib/staff-permission-cache.ts b/packages/vitnode/src/api/lib/staff-permission-cache.ts index 77cc4cd3a7..bde05c0e46 100644 --- a/packages/vitnode/src/api/lib/staff-permission-cache.ts +++ b/packages/vitnode/src/api/lib/staff-permission-cache.ts @@ -40,6 +40,10 @@ const memoKey = ( live: boolean, ): string => `${live ? "live" : "cached"}:${type}:${userId}`; +export const forgetRequestStaffPermissions = (c: Context): void => { + requestMemos.delete(c); +}; + export const readRequestStaffPermissions = ( c: Context, args: { type: PermissionStaffType; userId: number }, @@ -61,7 +65,9 @@ const fetchEpoch = async (c: Context): Promise => // The memo holds the promise, set before awaiting, so an invalidation that // lands while the read is in flight clears it and the next read starts over. -const readEpoch = async (c: Context): Promise => { +export const readStaffPermissionsGeneration = async ( + c: Context, +): Promise => { const memo = memoOf(c); memo.epoch ??= fetchEpoch(c); @@ -81,10 +87,10 @@ export const readStaffPermissions = async ( await c .get("cache") .getSystem( - permissionsKey(await readEpoch(c), args), + permissionsKey(await readStaffPermissionsGeneration(c), args), ); -const writeStaffPermissionsInEpoch = async ( +export const writeStaffPermissionsInGeneration = async ( c: Context, epoch: string, args: { type: PermissionStaffType; userId: number }, @@ -105,7 +111,12 @@ export const writeStaffPermissions = async ( args: { type: PermissionStaffType; userId: number }, value: ResolvedStaffPermissionSet, ): Promise => { - await writeStaffPermissionsInEpoch(c, await readEpoch(c), args, value); + await writeStaffPermissionsInGeneration( + c, + await readStaffPermissionsGeneration(c), + args, + value, + ); }; export interface CachedStaffPermissionsOfUser { @@ -120,7 +131,7 @@ export const readStaffPermissionsOfUser = async ( c: Context, userId: number, ): Promise => { - const epoch = await readEpoch(c); + const epoch = await readStaffPermissionsGeneration(c); const [admin = null, moderator = null] = await c .get("cache") .getSystemMany([ @@ -131,7 +142,12 @@ export const readStaffPermissionsOfUser = async ( return { sets: { admin, moderator }, write: async (type, value) => { - await writeStaffPermissionsInEpoch(c, epoch, { type, userId }, value); + await writeStaffPermissionsInGeneration( + c, + epoch, + { type, userId }, + value, + ); }, }; }; @@ -139,12 +155,11 @@ export const readStaffPermissionsOfUser = async ( /** * Expires **every** cached permission set, for every user, at once. * - * By moving the generation rather than deleting keys, because the mutations that - * need this are the role-shaped ones - a role gaining `root`, a role being - * deleted and its members reassigned - and those change the answer for every - * member of that role. Enumerating them would be a query plus one delete per - * user; moving the stamp is a single write, and the entries it orphans fall out - * on their own TTL. + * Every staff change goes through here, a single user's included. Moving the + * generation is one write, and it cannot lose a race the way a delete does: a + * request that read the old grants before the change committed writes them + * under the old generation, where nobody reads any more. The orphaned entries + * fall out on their own TTL. * * The new stamp is a random id rather than a timestamp. Two app instances with * skewed clocks could otherwise write a *lower* stamp than the one already @@ -159,53 +174,3 @@ export const invalidateAllStaffPermissions = async ( memo.sets.clear(); await c.get("cache").setSystem(EPOCH_KEY, randomUUID()); }; - -/** - * Expires one user's cached permission sets - both kinds, since a role - * reassignment moves admin and moderator permissions together. - * - * Used where the mutation names a single user, so the rest of the staff keep - * their warm entries. - */ -export const invalidateStaffPermissionsForUser = async ( - c: Context, - userId: number, -): Promise => { - const memo = memoOf(c); - for (const type of STAFF_TYPES) { - memo.sets.delete(memoKey({ type, userId }, true)); - memo.sets.delete(memoKey({ type, userId }, false)); - } - // Read fresh, not from the memo: deleting under a generation another request - // has since moved past would leave the live entries in place. - memo.epoch = fetchEpoch(c); - const epoch = await memo.epoch; - - await c - .get("cache") - .deleteSystem( - STAFF_TYPES.map(type => permissionsKey(epoch, { type, userId })), - ); -}; - -/** - * Expires whatever one staff entry governs. - * - * A staff entry hangs off *either* a user or a role, and the two are worth - * different amounts of cache: a user entry affects exactly that person, while a - * role entry affects everyone who holds the role - a set this route has no cheap - * way to enumerate. So one gets a targeted delete and the other moves the - * generation. - */ -export const invalidateStaffEntry = async ( - c: Context, - entry: { roleId?: null | number; userId?: null | number }, -): Promise => { - if (entry.userId != null) { - await invalidateStaffPermissionsForUser(c, entry.userId); - - return; - } - - await invalidateAllStaffPermissions(c); -}; diff --git a/packages/vitnode/src/api/middlewares/rate-limiter.middleware.ts b/packages/vitnode/src/api/middlewares/rate-limiter.middleware.ts index c13811d2d1..8628174cea 100644 --- a/packages/vitnode/src/api/middlewares/rate-limiter.middleware.ts +++ b/packages/vitnode/src/api/middlewares/rate-limiter.middleware.ts @@ -85,6 +85,11 @@ const AUTH_RATE_LIMITED_PATHS = [ "/@vitnode/core/users/passkeys/admin-sign-in", ] as const; +const AUTH_RATE_LIMITED_PATTERNS = [ + /\/@vitnode\/core\/users\/sso\/[^/]+\/link$/, + /\/@vitnode\/core\/users\/sso\/pending\/[^/]+\/(?:email|confirm)$/, +] as const; + const READ_METHODS = new Set(["GET", "HEAD"]); const PAYMENT_WEBHOOK_PATH = /\/@vitnode\/core\/payments\/webhooks\/[^/]+$/; @@ -100,7 +105,8 @@ export const rateLimiterCategoriesFor = ( const categories: RateLimiterCategory[] = ["safety", "write"]; if ( method === "POST" && - AUTH_RATE_LIMITED_PATHS.some(authPath => path.endsWith(authPath)) + (AUTH_RATE_LIMITED_PATHS.some(authPath => path.endsWith(authPath)) || + AUTH_RATE_LIMITED_PATTERNS.some(pattern => pattern.test(path))) ) { categories.push("auth"); } diff --git a/packages/vitnode/src/api/models/email-verification.ts b/packages/vitnode/src/api/models/email-verification.ts index 3a040f10ce..50167a977b 100644 --- a/packages/vitnode/src/api/models/email-verification.ts +++ b/packages/vitnode/src/api/models/email-verification.ts @@ -1,6 +1,6 @@ import type { Context } from "hono"; -import { and, eq, gt } from "drizzle-orm"; +import { and, eq, gt, sql } from "drizzle-orm"; import crypto from "node:crypto"; import { createTranslator } from "use-intl"; @@ -51,23 +51,27 @@ export class EmailVerificationModel { { 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 => { + const issuedNow = await db.transaction(async tx => { + await tx.execute( + sql`select pg_advisory_xact_lock(hashtext(${`verify-email:${user.id}`}))`, + ); + + if (respectCooldown) { + const issued = await tx + .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; + } + } + await tx .delete(core_users_confirm_emails) .where(eq(core_users_confirm_emails.userId, user.id)); @@ -77,7 +81,10 @@ export class EmailVerificationModel { expiresAt, ipAddress: this.c.get("ipAddress"), }); + + return true; }); + if (!issuedNow) return false; const verifyUrl = new URL( `login/verify-email?token=${encodeURIComponent(token)}`, diff --git a/packages/vitnode/src/api/models/passkey.ts b/packages/vitnode/src/api/models/passkey.ts index a7edd0c202..757813d1dd 100644 --- a/packages/vitnode/src/api/models/passkey.ts +++ b/packages/vitnode/src/api/models/passkey.ts @@ -31,6 +31,7 @@ import type { import { defaultPasskeyName, normalizePasskeyName } from "./passkey-names"; import { drizzlePasskeyStore } from "./passkey-store"; +import { SessionModel } from "./session"; import { SessionAdminModel } from "./session-admin"; export const PASSKEY_CHALLENGE_TTL_MS = 5 * 60_000; @@ -59,6 +60,8 @@ const TRANSPORTS: readonly string[] = [ const isTransport = (value: string): boolean => TRANSPORTS.includes(value); +export const PASSKEY_ENROLLMENT_RECENT_AUTH_MS = 60 * 60_000; + export type PasskeyErrorStatus = 400 | 403 | 404 | 409; export class PasskeyError extends Error { @@ -113,8 +116,18 @@ export class PasskeyModel { protected readonly c: Context; - private async assertStaffEnrollmentAllowed(userId: number) { - if (!(await isStaff(this.c, { live: true, type: "admin", userId }))) return; + private async assertEnrollmentAllowed(userId: number) { + if (!(await isStaff(this.c, { live: true, type: "admin", userId }))) { + const signedInAt = await new SessionModel(this.c).getSessionCreatedAt(); + if ( + signedInAt === null || + Date.now() - signedInAt.getTime() > PASSKEY_ENROLLMENT_RECENT_AUTH_MS + ) { + throw new PasskeyError("reauthentication_required", 403); + } + + return; + } const adminSession = await new SessionAdminModel(this.c).getSession({ extend: true, @@ -253,7 +266,7 @@ export class PasskeyModel { async registrationOptions(user: { email: string; id: number; name: string }) { const { rpId, rpName } = this.enabledConfig(); - await this.assertStaffEnrollmentAllowed(user.id); + await this.assertEnrollmentAllowed(user.id); const store = this.store; const now = new Date(); @@ -384,7 +397,7 @@ export class PasskeyModel { }): Promise { const { origins, rpId } = this.enabledConfig(); const challenge = await this.consumeChallenge("registration", userId); - await this.assertStaffEnrollmentAllowed(userId); + await this.assertEnrollmentAllowed(userId); if (!challenge.webauthnUserId) { throw new PasskeyError("invalid_challenge", 400); diff --git a/packages/vitnode/src/api/models/payments.ts b/packages/vitnode/src/api/models/payments.ts index 916b43c489..32dd85b1d3 100644 --- a/packages/vitnode/src/api/models/payments.ts +++ b/packages/vitnode/src/api/models/payments.ts @@ -501,6 +501,9 @@ export class PaymentsModel { throw new PaymentError("provider_unavailable"); } const after = await this.applyCheckoutState(open, state); + if (after.status === "succeeded") { + throw new PaymentError("already_paid"); + } if (after.status === "processing") { throw new PaymentError("payment_processing"); } @@ -673,7 +676,7 @@ export class PaymentsModel { * * @throws `PaymentError` - `provider_not_found`, `invalid_amount`, * `invalid_currency`, `invalid_return_path`, `reference_in_use`, - * `payment_processing`, `payment_in_progress`, `provider_rejected` or + * `already_paid`, `payment_processing`, `payment_in_progress`, `provider_rejected` or * `provider_unavailable` (safe to retry). */ async createCheckout( diff --git a/packages/vitnode/src/api/models/session-admin.ts b/packages/vitnode/src/api/models/session-admin.ts index 62d623195b..c3e4c1fde3 100644 --- a/packages/vitnode/src/api/models/session-admin.ts +++ b/packages/vitnode/src/api/models/session-admin.ts @@ -86,7 +86,7 @@ export class SessionAdminModel { async createSessionByUserId(userId: number) { if (!(await isStaff(this.c, { live: true, type: "admin", userId }))) { - throw new HTTPException(403, { message: "Forbidden" }); + throw new HTTPException(403); } const randomBytes = new Uint8Array(64); diff --git a/packages/vitnode/src/api/models/session.ts b/packages/vitnode/src/api/models/session.ts index 148979b3bb..fed0a433e2 100644 --- a/packages/vitnode/src/api/models/session.ts +++ b/packages/vitnode/src/api/models/session.ts @@ -6,6 +6,7 @@ import { getCookie } from "hono/cookie"; import { deleteAuthCookie, setAuthCookie } from "@/api/lib/auth-cookie"; import { hashSessionToken } from "@/api/lib/session-token"; import { core_sessions } from "@/database/sessions"; +import { realtime } from "@/ws/registry"; import { DeviceModel } from "./device"; import { @@ -92,6 +93,10 @@ export class SessionModel { .get("cache") .deleteSystem(sessionCacheKey(hashedToken, device.id)); + for (const { userId } of deleted) { + realtime.closeUserConnections(userId, device.id); + } + // Only this session goes offline; the member's other devices stay online. await Promise.all( deleted.map( diff --git a/packages/vitnode/src/api/models/sso-connection.ts b/packages/vitnode/src/api/models/sso-connection.ts index 82c0e9b925..992878a66b 100644 --- a/packages/vitnode/src/api/models/sso-connection.ts +++ b/packages/vitnode/src/api/models/sso-connection.ts @@ -861,7 +861,7 @@ export const ssoIdentityOf = ( adapter: Pick, user: Awaited>, ): SsoProviderIdentity => { - const email = user.emailVerified === false ? null : boundedText(user.email); + const email = user.emailVerified ? boundedText(user.email) : null; return { email: email ? normalizeEmailAddress(email) : null, diff --git a/packages/vitnode/src/api/models/sso.ts b/packages/vitnode/src/api/models/sso.ts index dc1cde69e9..3098c6f79d 100644 --- a/packages/vitnode/src/api/models/sso.ts +++ b/packages/vitnode/src/api/models/sso.ts @@ -58,10 +58,11 @@ export interface SSOProviderUser { */ email?: null | string; /** - * Whether the provider confirmed the user owns `email`. `false` means core - * never trusts the address: a returning member still signs in by `id`, and a - * new one confirms an address of their own by email link. Leave it unset only - * when the provider never returns an unconfirmed address. + * Whether the provider confirmed the user owns `email`. Only `true` makes + * core trust the address. Unset or `false` means core never trusts it: a + * returning member still signs in by `id`, and a new one confirms an address + * of their own by email link. Set `true` only when the provider vouches for + * the address. */ emailVerified?: boolean; firstName?: null | string; @@ -138,9 +139,8 @@ export interface SSOApiPlugin { ) => Promise; /** * Reads the provider account with the tokens. Validate an ID token here when - * the provider issues one. Report an address the provider has not confirmed - * with `emailVerified: false` - an unconfirmed email must never reach core - * as a trusted one. + * the provider issues one. Set `emailVerified: true` only for an address + * the provider confirmed - anything else is treated as unconfirmed. */ fetchUser: ( token: SSOTokenResponse, diff --git a/packages/vitnode/src/api/models/storage.ts b/packages/vitnode/src/api/models/storage.ts index ea9ce6472f..9a3d17a592 100644 --- a/packages/vitnode/src/api/models/storage.ts +++ b/packages/vitnode/src/api/models/storage.ts @@ -21,6 +21,7 @@ import { buildStorageKey, generateStorageFileName, replaceFileExtension, + storedContentType, } from "@/lib/api/upload"; import { STORAGE_FILE_IN_USE } from "@/lib/files/in-use"; import { formatBytes } from "@/lib/format-bytes"; @@ -609,7 +610,7 @@ export class StorageModel { await store.upload({ body: bytes, - contentType: row.mimeType ?? undefined, + contentType: storedContentType(row.mimeType), key: row.key, }); await db @@ -758,7 +759,7 @@ export class StorageModel { const stored = { key, body: processed.body, - contentType: processed.mimeType || undefined, + contentType: storedContentType(processed.mimeType), }; // A private object has no URL: whoever serves it builds the route to it. const result: StorageUploadResult = privateStore 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 7d3659ae18..6c8617926a 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 @@ -1,5 +1,6 @@ import type { Context } from "hono"; +import { eq } from "drizzle-orm"; import { HTTPException } from "hono/http-exception"; import { matchesEmail, pickAccountForEmail } from "@/api/lib/user-email-lookup"; @@ -47,5 +48,24 @@ export const signInWithPassword = async ({ throw new HTTPException(403); } - return { id: user.id, email: user.email, emailVerified: user.emailVerified }; + return { + id: user.id, + email: user.email, + emailVerified: user.emailVerified, + passwordHash: user.password, + }; +}; + +export const isPasswordStillCurrent = async ( + c: Context, + { id, passwordHash }: { id: number; passwordHash: string }, +): Promise => { + const [row] = await c + .get("db") + .select({ password: core_users.password }) + .from(core_users) + .where(eq(core_users.id, id)) + .limit(1); + + return row?.password === passwordHash; }; diff --git a/packages/vitnode/src/api/modules/admin/debug/routes/test-ai.route.ts b/packages/vitnode/src/api/modules/admin/debug/routes/test-ai.route.ts index 6f1ee29635..8a72bf41d5 100644 --- a/packages/vitnode/src/api/modules/admin/debug/routes/test-ai.route.ts +++ b/packages/vitnode/src/api/modules/admin/debug/routes/test-ai.route.ts @@ -4,6 +4,8 @@ import { z } from "zod"; import { buildRoute } from "@/api/builders"; +const TEST_AI_MAX_OUTPUT_TOKENS = 512; + export const zodTestAiSchema = z.object({ // A configured model id (see `ai.models`). Omit for the default (first) model. model: z.string().optional(), @@ -56,6 +58,7 @@ export const testAiDebugAdminRoute = buildRoute({ const { model, prompt } = c.req.valid("json"); const result = streamText({ + maxOutputTokens: TEST_AI_MAX_OUTPUT_TOKENS, model: c.get("ai").model(model), prompt, onError: ({ error }) => { diff --git a/packages/vitnode/src/api/modules/admin/routes/notifications.route.ts b/packages/vitnode/src/api/modules/admin/routes/notifications.route.ts index a2f8913de0..90f1bbafce 100644 --- a/packages/vitnode/src/api/modules/admin/routes/notifications.route.ts +++ b/packages/vitnode/src/api/modules/admin/routes/notifications.route.ts @@ -13,13 +13,7 @@ export const zodSendNotificationSchema = z.object({ }); export const sendNotificationRoute = buildRoute({ - // The send-notification dashboard widget is the only caller, and its sibling - // (`PUT /admin/dashboard/widget-settings`) is gated the same way. Without a - // declaration the route asked only for *an* admin session, so an - // administrator restricted to one unrelated screen could still push arbitrary - // titles and bodies to any user id - a message that arrives inside the - // product, wearing the product's own notification UI. - adminStaffPermission: { module: "dashboard", permission: "can_edit" }, + adminStaffPermission: { module: "notifications", permission: "can_manage" }, route: { method: "post", description: diff --git a/packages/vitnode/src/api/modules/admin/staff/routes/create.route.ts b/packages/vitnode/src/api/modules/admin/staff/routes/create.route.ts index b31ec09e40..38b330715e 100644 --- a/packages/vitnode/src/api/modules/admin/staff/routes/create.route.ts +++ b/packages/vitnode/src/api/modules/admin/staff/routes/create.route.ts @@ -5,7 +5,7 @@ import { buildRoute } from "@/api/builders"; import { staffEntryAuditLabel } from "@/api/lib/audit/labels"; import { writeAuditLog } from "@/api/lib/audit/write"; import { assertStaffPermission } from "@/api/lib/check-staff-permission"; -import { invalidateStaffEntry } from "@/api/lib/staff-permission-cache"; +import { invalidateAllStaffPermissions } from "@/api/lib/staff-permission-cache"; import { core_admin_permissions } from "@/database/admins"; import { core_moderators_permissions } from "@/database/moderators"; import { manifest } from "@/manifest"; @@ -137,7 +137,7 @@ export const createStaffAdminRoute = buildRoute({ return inserted; }); - await invalidateStaffEntry(c, { roleId, userId }); + await invalidateAllStaffPermissions(c); return c.json({ id: created.id }, 201); }, diff --git a/packages/vitnode/src/api/modules/admin/staff/routes/delete.route.ts b/packages/vitnode/src/api/modules/admin/staff/routes/delete.route.ts index 76efeb4aee..dca2fe97f0 100644 --- a/packages/vitnode/src/api/modules/admin/staff/routes/delete.route.ts +++ b/packages/vitnode/src/api/modules/admin/staff/routes/delete.route.ts @@ -9,7 +9,7 @@ import { } from "@/api/lib/audit/labels"; import { writeAuditLog } from "@/api/lib/audit/write"; import { assertStaffPermission } from "@/api/lib/check-staff-permission"; -import { invalidateStaffEntry } from "@/api/lib/staff-permission-cache"; +import { invalidateAllStaffPermissions } from "@/api/lib/staff-permission-cache"; import { core_admin_permissions } from "@/database/admins"; import { core_moderators_permissions } from "@/database/moderators"; import { manifest } from "@/manifest"; @@ -119,7 +119,7 @@ export const deleteStaffAdminRoute = buildRoute({ ); }); - await invalidateStaffEntry(c, entry); + await invalidateAllStaffPermissions(c); return c.body(null, 200); }, diff --git a/packages/vitnode/src/api/modules/admin/staff/routes/update-permissions.route.ts b/packages/vitnode/src/api/modules/admin/staff/routes/update-permissions.route.ts index ebf9236d08..6992cd9226 100644 --- a/packages/vitnode/src/api/modules/admin/staff/routes/update-permissions.route.ts +++ b/packages/vitnode/src/api/modules/admin/staff/routes/update-permissions.route.ts @@ -11,7 +11,7 @@ import { } from "@/api/lib/audit/labels"; import { writeAuditLog } from "@/api/lib/audit/write"; import { assertStaffPermission } from "@/api/lib/check-staff-permission"; -import { invalidateStaffEntry } from "@/api/lib/staff-permission-cache"; +import { invalidateAllStaffPermissions } from "@/api/lib/staff-permission-cache"; import { core_admin_permissions } from "@/database/admins"; import { core_moderators_permissions } from "@/database/moderators"; import { auditDiff } from "@/lib/audit/diff"; @@ -196,7 +196,7 @@ export const updatePermissionsStaffAdminRoute = buildRoute({ return c.json({ error: "Staff entry not found" }, 404); } - await invalidateStaffEntry(c, entry); + await invalidateAllStaffPermissions(c); return c.json({ unrestricted, permissions: sanitized }, 200); }, diff --git a/packages/vitnode/src/api/modules/admin/users/routes/update.route.ts b/packages/vitnode/src/api/modules/admin/users/routes/update.route.ts index fcc4962b9d..a5b08f0163 100644 --- a/packages/vitnode/src/api/modules/admin/users/routes/update.route.ts +++ b/packages/vitnode/src/api/modules/admin/users/routes/update.route.ts @@ -5,7 +5,7 @@ import { buildRoute } from "@/api/builders"; import { writeAuditLog } from "@/api/lib/audit/write"; import { isValidTimeZone } from "@/api/lib/notifications/digest-period"; import { revokeAccountRecoveryTokens } from "@/api/lib/recovery-tokens"; -import { invalidateStaffPermissionsForUser } from "@/api/lib/staff-permission-cache"; +import { invalidateAllStaffPermissions } from "@/api/lib/staff-permission-cache"; import { matchesEmail } from "@/api/lib/user-email-lookup"; import { invalidateSessionCacheForUser } from "@/api/models/session-revoke"; import { SsoConnectionModel } from "@/api/models/sso-connection"; @@ -422,7 +422,7 @@ export const updateUserAdminRoute = buildRoute({ // from a stale `roleId` reaches the same answer it just discarded - and // a demotion applied here would not take effect for another minute. await Promise.all([ - invalidateStaffPermissionsForUser(c, user.id), + invalidateAllStaffPermissions(c), invalidateSessionCacheForUser(c, user.id), ]); } else { @@ -454,7 +454,7 @@ export const updateUserAdminRoute = buildRoute({ // The same pair as above - and this is the likelier path of the two, since // a request that changes only roles never reaches the `values` branch. await Promise.all([ - invalidateStaffPermissionsForUser(c, user.id), + invalidateAllStaffPermissions(c), invalidateSessionCacheForUser(c, user.id), ]); } diff --git a/packages/vitnode/src/api/modules/moderation/lib/reports.ts b/packages/vitnode/src/api/modules/moderation/lib/reports.ts index 29715cb06a..23227852a9 100644 --- a/packages/vitnode/src/api/modules/moderation/lib/reports.ts +++ b/packages/vitnode/src/api/modules/moderation/lib/reports.ts @@ -5,6 +5,7 @@ import { HTTPException } from "hono/http-exception"; import type { ModeratorContext } from "@/api/lib/moderation/access"; import type { RegisteredReportTarget } from "@/api/lib/moderation/targets"; +import type { EnvVariablesVitNode } from "@/api/middlewares/global.middleware"; import type { ContentDatabase } from "@/content/server/service"; import type { ModerationReportEventType } from "@/lib/moderation/const"; @@ -166,9 +167,9 @@ export const authorizeReport = async ( export const countRecentReports = async ( c: Context, reporterId: number, + db: Pick = c.get("db"), ): Promise => { - const [row] = await c - .get("db") + const [row] = await db .select({ value: count() }) .from(core_moderation_reports) .where( diff --git a/packages/vitnode/src/api/modules/moderation/routes/create.route.ts b/packages/vitnode/src/api/modules/moderation/routes/create.route.ts index 5c7fe26919..6ba73482e9 100644 --- a/packages/vitnode/src/api/modules/moderation/routes/create.route.ts +++ b/packages/vitnode/src/api/modules/moderation/routes/create.route.ts @@ -1,5 +1,5 @@ import { z } from "@hono/zod-openapi"; -import { and, eq } from "drizzle-orm"; +import { and, eq, sql } from "drizzle-orm"; import { HTTPException } from "hono/http-exception"; import { buildRoute } from "@/api/builders"; @@ -153,6 +153,17 @@ export const createReportRoute = buildRoute({ let reportId: number; try { reportId = await c.get("db").transaction(async tx => { + await tx.execute( + sql`select pg_advisory_xact_lock(hashtext(${`moderation-report:${user.id}`}))`, + ); + if ( + (await countRecentReports(c, user.id, tx)) >= + MODERATION_LIMITS.reportsPerUserPerHour + ) { + c.header("Retry-After", "3600"); + throw reportError(429, { error: "rate_limited" }); + } + const [created] = await tx .insert(core_moderation_reports) .values({ diff --git a/packages/vitnode/src/api/modules/moderation/routes/show.route.ts b/packages/vitnode/src/api/modules/moderation/routes/show.route.ts index 653e96b4d0..db0c63f8fa 100644 --- a/packages/vitnode/src/api/modules/moderation/routes/show.route.ts +++ b/packages/vitnode/src/api/modules/moderation/routes/show.route.ts @@ -3,6 +3,7 @@ import { asc, eq } from "drizzle-orm"; import { buildRoute } from "@/api/builders"; import { canOnReport, requireModerator } from "@/api/lib/moderation/access"; +import { safeReportTargetUrl } from "@/api/lib/moderation/snapshot"; import { core_moderation_report_events } from "@/database/moderation"; import { MODERATION_PERMISSIONS, @@ -212,7 +213,7 @@ export const showReportRoute = buildRoute({ current: { exists: current !== null, hidden: current?.hidden ?? false, - url: current?.url ?? null, + url: safeReportTargetUrl(current?.url), registered: target !== undefined, }, permissions: { diff --git a/packages/vitnode/src/api/modules/search/routes/facets.route.ts b/packages/vitnode/src/api/modules/search/routes/facets.route.ts index fb00b5a6d9..0c790fe01b 100644 --- a/packages/vitnode/src/api/modules/search/routes/facets.route.ts +++ b/packages/vitnode/src/api/modules/search/routes/facets.route.ts @@ -17,12 +17,12 @@ export const facetsRoute = buildRoute({ path: "/facets", request: { query: z.object({ - search: z.string().optional(), + search: z.string().max(200).optional(), authorIds: z.string().max(512).optional(), containerId: z.string().optional(), from: z.string().optional(), to: z.string().optional(), - lang: z.string().optional(), + lang: z.string().max(32).optional(), }), }, responses: { diff --git a/packages/vitnode/src/api/modules/search/routes/popularity.route.ts b/packages/vitnode/src/api/modules/search/routes/popularity.route.ts index 62d4c41e10..a0e0eb9b05 100644 --- a/packages/vitnode/src/api/modules/search/routes/popularity.route.ts +++ b/packages/vitnode/src/api/modules/search/routes/popularity.route.ts @@ -5,6 +5,8 @@ import { createHash } from "node:crypto"; import { buildRoute } from "@/api/builders"; import { resolveClientIp } from "@/api/lib/client-ip"; +import { rateLimitKey } from "@/api/middlewares/rate-limiter.middleware"; +import { verifyContentSearchHits } from "@/content/access/server/search"; import { CONTENT_ID_KEY_LENGTH } from "@/content/const"; const SEARCH_POPULARITY_REPEAT_WINDOW_SECONDS = 30 * 60; @@ -13,7 +15,10 @@ const viewerKey = (c: Context): string => { const user = c.get("user"); if (user) return `user:${user.id}`; - return `ip:${createHash("sha256").update(resolveClientIp(c)).digest("hex").slice(0, 32)}`; + return `ip:${createHash("sha256") + .update(rateLimitKey(resolveClientIp(c))) + .digest("hex") + .slice(0, 32)}`; }; export const popularityRoute = buildRoute({ @@ -54,6 +59,8 @@ export const popularityRoute = buildRoute({ if (!(await search.isListed(itemType, itemId))) { return c.json({ recorded: false }); } + const { kept } = await verifyContentSearchHits(c, [{ itemId, itemType }]); + if (kept.length === 0) return c.json({ recorded: false }); const isFirstInWindow = await c .get("cache") diff --git a/packages/vitnode/src/api/modules/search/routes/search.route.ts b/packages/vitnode/src/api/modules/search/routes/search.route.ts index 79cca610d3..42f442545a 100644 --- a/packages/vitnode/src/api/modules/search/routes/search.route.ts +++ b/packages/vitnode/src/api/modules/search/routes/search.route.ts @@ -118,15 +118,15 @@ export const searchRoute = buildRoute({ path: "/", request: { query: zodPaginationQuery.extend({ - search: z.string().optional(), - types: z.string().optional(), + search: z.string().max(200).optional(), + types: z.string().max(512).optional(), authorId: z.string().optional(), authorIds: z.string().max(512).optional(), containerId: z.string().optional(), sort: z.enum(["newest", "oldest", "relevance"]).optional(), from: z.string().optional(), to: z.string().optional(), - lang: z.string().optional(), + lang: z.string().max(32).optional(), }), }, responses: { diff --git a/packages/vitnode/src/api/modules/search/routes/timeline.route.test.ts b/packages/vitnode/src/api/modules/search/routes/timeline.route.test.ts index 18d7568933..8baaeb0ba1 100644 --- a/packages/vitnode/src/api/modules/search/routes/timeline.route.test.ts +++ b/packages/vitnode/src/api/modules/search/routes/timeline.route.test.ts @@ -91,7 +91,7 @@ describe("member timeline", () => { ); }); - it("shows members their own drafts", async () => { + it("shows members their own hidden documents, never every private one", async () => { const { app, search } = await appFor(timelineRoute, { id: 1 }); await app.request("/timeline/1?cursor=42"); @@ -100,7 +100,8 @@ describe("member timeline", () => { expect.objectContaining({ authorId: 1, cursor: "42", - includePrivate: true, + hiddenAccess: expect.objectContaining({ authorId: 1 }), + includePrivate: false, }), ); }); diff --git a/packages/vitnode/src/api/modules/search/routes/timeline.route.ts b/packages/vitnode/src/api/modules/search/routes/timeline.route.ts index 044627b163..3ee4650cc9 100644 --- a/packages/vitnode/src/api/modules/search/routes/timeline.route.ts +++ b/packages/vitnode/src/api/modules/search/routes/timeline.route.ts @@ -71,13 +71,10 @@ export const timelineRoute = buildRoute({ handler: async c => { const authorId = Number(c.req.valid("param").userId); - const includePrivate = c.get("user")?.id === authorId; const result = await searchTimeline(c, { authorId, - hiddenAccess: includePrivate - ? undefined - : await contentSearchHiddenAccess(c), - includePrivate, + hiddenAccess: await contentSearchHiddenAccess(c), + includePrivate: false, query: c.req.valid("query"), }); 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 77e1724131..4c6f1d8fe6 100644 --- a/packages/vitnode/src/api/modules/users/passkeys/passkeys.test.ts +++ b/packages/vitnode/src/api/modules/users/passkeys/passkeys.test.ts @@ -101,6 +101,9 @@ const harness = ({ const createSession = vi .spyOn(SessionModel.prototype, "createSessionByUserId") .mockResolvedValue({ token: "session-token" }); + vi.spyOn(SessionModel.prototype, "getSessionCreatedAt").mockImplementation( + async () => await Promise.resolve(new Date()), + ); const createAdminSession = vi .spyOn(SessionAdminModel.prototype, "createSessionByUserId") .mockResolvedValue({ token: "admin-token" }); diff --git a/packages/vitnode/src/api/modules/users/passkeys/schema.ts b/packages/vitnode/src/api/modules/users/passkeys/schema.ts index d08587b9dd..dc71f49d98 100644 --- a/packages/vitnode/src/api/modules/users/passkeys/schema.ts +++ b/packages/vitnode/src/api/modules/users/passkeys/schema.ts @@ -113,6 +113,7 @@ export const zodPasskeyErrorSchema = z.object({ "last_recovery_method", "not_found", "not_staff", + "reauthentication_required", "passkeys_disabled", "verification_failed", ]), diff --git a/packages/vitnode/src/api/modules/users/routes/password-sign-in.test.ts b/packages/vitnode/src/api/modules/users/routes/password-sign-in.test.ts index 622622e6bb..ca94a4d046 100644 --- a/packages/vitnode/src/api/modules/users/routes/password-sign-in.test.ts +++ b/packages/vitnode/src/api/modules/users/routes/password-sign-in.test.ts @@ -160,7 +160,7 @@ describe("authorization.password on (the default)", () => { const response = await app.request("/sign_in", post(CREDENTIALS)); expect(response.status).toBe(201); - expect(selects).toHaveBeenCalledOnce(); + expect(selects).toHaveBeenCalledTimes(2); expect(createSession).toHaveBeenCalledWith(1); }); }); 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 dee8e599bd..9924a54f2b 100644 --- a/packages/vitnode/src/api/modules/users/routes/sign-in.route.ts +++ b/packages/vitnode/src/api/modules/users/routes/sign-in.route.ts @@ -1,7 +1,13 @@ +import type { Context } from "hono"; + +import { eq } from "drizzle-orm"; +import { HTTPException } from "hono/http-exception"; import { z } from "zod"; import { buildRoute } from "@/api/builders"; +import { deleteAuthCookie } from "@/api/lib/auth-cookie"; import { assertPasswordSignInEnabled } from "@/api/lib/password-sign-in"; +import { hashSessionToken } from "@/api/lib/session-token"; import { EMAIL_NOT_VERIFIED, mustVerifyEmail, @@ -9,6 +15,9 @@ import { import { SessionModel } from "@/api/models/session"; import { SessionAdminModel } from "@/api/models/session-admin"; import { UserModel } from "@/api/models/user"; +import { isPasswordStillCurrent } from "@/api/models/user/sign-in-with-passwords"; +import { core_admin_sessions } from "@/database/admins"; +import { core_sessions } from "@/database/sessions"; import { USER_EMAIL_MAX_LENGTH, @@ -27,6 +36,32 @@ export const zodSignInSchema = z.object({ }), }); +const revokeIfPasswordChanged = async ( + c: Context, + data: { id: number; passwordHash: string }, + { admin, token }: { admin: boolean; token: string }, +): Promise => { + if (await isPasswordStillCurrent(c, data)) return; + + const hashed = await hashSessionToken(token); + const { authorization } = c.get("core"); + if (admin) { + await c + .get("db") + .delete(core_admin_sessions) + .where(eq(core_admin_sessions.token, hashed)); + deleteAuthCookie(c, authorization.adminCookieName); + } else { + await c + .get("db") + .delete(core_sessions) + .where(eq(core_sessions.token, hashed)); + deleteAuthCookie(c, authorization.cookieName); + } + + throw new HTTPException(403); +}; + export const signInRoute = buildRoute({ route: { method: "post", @@ -74,7 +109,10 @@ export const signInRoute = buildRoute({ }); if (isAdmin) { - await new SessionAdminModel(c).createSessionByUserId(data.id); + const { token } = await new SessionAdminModel(c).createSessionByUserId( + data.id, + ); + await revokeIfPasswordChanged(c, data, { admin: true, token }); return c.json({ id: data.id }, 201); } @@ -83,7 +121,8 @@ export const signInRoute = buildRoute({ return c.json({ error: EMAIL_NOT_VERIFIED } as const, 403); } - await new SessionModel(c).createSessionByUserId(data.id); + const { token } = await new SessionModel(c).createSessionByUserId(data.id); + await revokeIfPasswordChanged(c, data, { admin: false, token }); return c.json({ id: data.id }, 201); }, diff --git a/packages/vitnode/src/api/modules/users/routes/time-zone.route.ts b/packages/vitnode/src/api/modules/users/routes/time-zone.route.ts index beb02d54d6..6e34eb874b 100644 --- a/packages/vitnode/src/api/modules/users/routes/time-zone.route.ts +++ b/packages/vitnode/src/api/modules/users/routes/time-zone.route.ts @@ -3,7 +3,7 @@ import { eq } from "drizzle-orm"; import { HTTPException } from "hono/http-exception"; import { buildRoute } from "@/api/builders"; -import { isValidTimeZone } from "@/api/lib/notifications/digest-period"; +import { canonicalTimeZone } from "@/api/lib/notifications/digest-period"; import { invalidateSessionCacheForUser } from "@/api/models/session-revoke"; import { core_users } from "@/database/users"; @@ -48,8 +48,9 @@ export const updateMyTimeZoneRoute = buildRoute({ throw new HTTPException(401, { message: "Unauthorized" }); } - const { timeZone } = c.req.valid("json"); - if (timeZone !== null && !isValidTimeZone(timeZone)) { + const requested = c.req.valid("json").timeZone; + const timeZone = requested === null ? null : canonicalTimeZone(requested); + if (requested !== null && timeZone === null) { throw new HTTPException(400, { message: "Unknown time zone." }); } diff --git a/packages/vitnode/src/api/modules/users/sso/connections/connections.test.ts b/packages/vitnode/src/api/modules/users/sso/connections/connections.test.ts index b970f99c45..9ebced03ae 100644 --- a/packages/vitnode/src/api/modules/users/sso/connections/connections.test.ts +++ b/packages/vitnode/src/api/modules/users/sso/connections/connections.test.ts @@ -238,6 +238,7 @@ const connectGoogle = async ( ) => { h.profiles.set("code-google", { email: "alice.personal@gmail.test", + emailVerified: true, id: "google-alice", username: "Alice Personal", ...profile, @@ -402,6 +403,7 @@ describe("connecting an account from settings", () => { h.signInAs(ALICE); h.profiles.set("code-google", { email: "a@gmail.test", + emailVerified: true, id: "google-alice", username: "A", }); @@ -433,6 +435,7 @@ describe("the state of a settings round trip", () => { h.signInAs(ALICE); h.profiles.set("code-google", { email: "a@gmail.test", + emailVerified: true, id: "google-alice", username: "A", }); @@ -773,6 +776,7 @@ describe("importing a profile now", () => { h.profiles.set("code-import", { avatarUrl: "https://lh3.googleusercontent.test/a/new.png", email: "alice.personal@gmail.test", + emailVerified: true, firstName: "Alice", id: "google-alice", lastName: "Smith", @@ -988,6 +992,7 @@ describe("updating the profile on sign-in", () => { const adapter = h.google; const user: SSOProviderUser = { email: "Alice.Personal@gmail.test", + emailVerified: true, id: "google-alice", username: "Alice Smith", ...profile, @@ -1110,6 +1115,7 @@ describe("syncing now, without signing out", () => { const syncFrom = async (h: Harness, profile: Partial) => { h.profiles.set("code-sync", { email: "alice.personal@gmail.test", + emailVerified: true, id: "google-alice", username: "Alice Smith", ...profile, @@ -1177,6 +1183,7 @@ describe("syncing now, without signing out", () => { const h = prepare(); h.profiles.set("code-sync", { email: "a@gmail.test", + emailVerified: true, firstName: "Alice", id: "google-alice", lastName: "Smith", @@ -1199,6 +1206,7 @@ describe("syncing now, without signing out", () => { const h = prepare(); h.profiles.set("code-sync", { email: "a@gmail.test", + emailVerified: true, firstName: "Alice", id: "google-alice", username: "A", 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 d98dd42bb0..b00624c773 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 @@ -28,6 +28,7 @@ const LINK_SECRET = "link-secret"; const PROVIDER_USER: SSOProviderUser = { avatarUrl: "https://lh3.googleusercontent.test/a/photo.png", email: "Alice@Example.test", + emailVerified: true, firstName: "Alice", id: "google-alice", lastName: "Smith", diff --git a/packages/vitnode/src/components/tiptap/attachment/attachment-node-view.tsx b/packages/vitnode/src/components/tiptap/attachment/attachment-node-view.tsx index c8132d353a..d3e807e0e9 100644 --- a/packages/vitnode/src/components/tiptap/attachment/attachment-node-view.tsx +++ b/packages/vitnode/src/components/tiptap/attachment/attachment-node-view.tsx @@ -12,6 +12,7 @@ import { useTranslations } from "use-intl"; import { DeletedUpload } from "@/components/rich-text/deleted-upload"; import { Button } from "@/components/ui/button"; import { Spinner } from "@/components/ui/spinner"; +import { sanitizeRichTextHref } from "@/content/rich-text/elements"; import { type EditorAttachmentKind, editorAttachmentKindOf, @@ -40,7 +41,7 @@ export const EditorAttachmentNodeView = ({ const t = useTranslations("core.global.editor.attachment"); const name = typeof node.attrs.name === "string" ? node.attrs.name : ""; const size = typeof node.attrs.size === "number" ? node.attrs.size : null; - const src = typeof node.attrs.src === "string" ? node.attrs.src : null; + const src = sanitizeRichTextHref(node.attrs.src); const uploadId = typeof node.attrs.uploadId === "string" ? node.attrs.uploadId : null; const isUploading = diff --git a/packages/vitnode/src/components/tiptap/attachment/attachment.ts b/packages/vitnode/src/components/tiptap/attachment/attachment.ts index aa0804e2ad..d25817c531 100644 --- a/packages/vitnode/src/components/tiptap/attachment/attachment.ts +++ b/packages/vitnode/src/components/tiptap/attachment/attachment.ts @@ -76,6 +76,7 @@ export const EditorAttachment = Node.create({ const size = typeof node.attrs.size === "number" ? node.attrs.size : null; const mimeType = typeof node.attrs.mimeType === "string" ? node.attrs.mimeType : null; + const href = sanitizeRichTextHref(node.attrs.src); return [ "a", @@ -84,7 +85,7 @@ export const EditorAttachment = Node.create({ "data-kind": editorAttachmentKindOf(mimeType) ?? "file", rel: "noopener noreferrer nofollow", target: "_blank", - ...(typeof node.attrs.src === "string" ? { href: node.attrs.src } : {}), + ...(href ? { href } : {}), ...(typeof node.attrs.fileId === "number" ? { "data-file-id": node.attrs.fileId } : {}), diff --git a/packages/vitnode/src/content/access/server/decide.ts b/packages/vitnode/src/content/access/server/decide.ts index 5c2cbffab3..1faecd17ad 100644 --- a/packages/vitnode/src/content/access/server/decide.ts +++ b/packages/vitnode/src/content/access/server/decide.ts @@ -191,13 +191,13 @@ export const createContentAccess = (c: Context) => ({ /** The current viewer, resolved once per request. */ actor: async (): Promise => await resolveContentAccessActor(c), - /** The scopes of one family that allow `action`. */ + /** The scopes of one family that allow `action`. An unknown family allows none. */ allowedScopes: async ( familyId: string, action: ContentAccessAction, ): Promise => { const family = findContentAccessFamily(c, familyId); - if (!family) return { all: true, ids: [] }; + if (!family) return { all: false, ids: [] }; return await contentAccessAllowedScopes(c, { action, diff --git a/packages/vitnode/src/content/access/server/download-route.ts b/packages/vitnode/src/content/access/server/download-route.ts index 49fb12c991..372d363c2a 100644 --- a/packages/vitnode/src/content/access/server/download-route.ts +++ b/packages/vitnode/src/content/access/server/download-route.ts @@ -6,6 +6,7 @@ import { and, eq } from "drizzle-orm"; import type { AnyContentModel, ContentModel } from "../../server/model"; import type { AnyContentTypeDefinition } from "../../types"; +import { isStaff } from "../../../api/lib/check-staff-permission"; import { attachStoredFile } from "../../../api/lib/file-download"; import { buildRoute } from "../../../api/lib/route"; import { core_files } from "../../../database/files"; @@ -131,6 +132,7 @@ export const contentFileDownloadRoute = < key: core_files.key, mimeType: core_files.mimeType, name: core_files.name, + userId: core_files.userId, visibility: core_files.visibility, }) .from(core_files) @@ -138,6 +140,28 @@ export const contentFileDownloadRoute = < .limit(1); if (!file || !c.get("core").storage?.adapter) return missing(); + const ownerField = definition.publicWrite.enabled + ? definition.publicWrite.ownerField + : null; + if (ownerField) { + const ownerColumn = columns[ownerField]; + const [record] = ownerColumn + ? await c + .get("db") + .select({ owner: ownerColumn }) + .from(table) + .where(eq(columns.id, itemId)) + .limit(1) + : []; + const byAuthor = file.userId !== null && record?.owner === file.userId; + const byStaff = + !byAuthor && + file.userId !== null && + ((await isStaff(c, { type: "admin", userId: file.userId })) || + (await isStaff(c, { type: "moderator", userId: file.userId }))); + if (!byAuthor && !byStaff) return missing(); + } + const isImage = file.mimeType?.startsWith("image/") === true; if ( !isImage && diff --git a/packages/vitnode/src/content/access/server/file-protection.ts b/packages/vitnode/src/content/access/server/file-protection.ts index 80f70fb284..543803b9fb 100644 --- a/packages/vitnode/src/content/access/server/file-protection.ts +++ b/packages/vitnode/src/content/access/server/file-protection.ts @@ -1,12 +1,13 @@ import type { Context } from "hono"; -import { getColumns, inArray, sql } from "drizzle-orm"; +import { getColumns, inArray, or, sql } from "drizzle-orm"; import { type PgColumn, PgTable } from "drizzle-orm/pg-core"; import type { AnyContentModel } from "../../server/model"; import type { ContentFieldMap } from "../../types"; import { core_files } from "../../../database/files"; +import { core_users } from "../../../database/users"; import { contentPrivateAdminFileUrl } from "./files"; import { contentModelsInAccessFamily } from "./invalidate"; @@ -199,11 +200,42 @@ export const protectContentAccessFiles = async ( ? [] : await c .get("db") - .select({ id: core_files.id, visibility: core_files.visibility }) + .select({ + id: core_files.id, + metadata: core_files.metadata, + visibility: core_files.visibility, + }) .from(core_files) .where(inArray(core_files.id, [...ids])); + const candidateIds = candidates.map(row => row.id); + const familyTypes = new Set(inside.map(model => model.definition.id)); + const userImageIds = new Set( + candidateIds.length === 0 + ? [] + : ( + await c + .get("db") + .select({ + avatarId: core_users.avatarId, + coverId: core_users.coverId, + }) + .from(core_users) + .where( + or( + inArray(core_users.avatarId, candidateIds), + inArray(core_users.coverId, candidateIds), + ), + ) + ).flatMap(row => [row.avatarId, row.coverId]), + ); + const ownedByFamily = (row: (typeof candidates)[number]): boolean => { + if (userImageIds.has(row.id)) return false; + const type = row.metadata.contentTypeId; + + return typeof type !== "string" || familyTypes.has(type); + }; const publicIds = candidates - .filter(row => row.visibility === "public") + .filter(row => row.visibility === "public" && ownedByFamily(row)) .map(row => row.id); const shared = publicIds.filter(id => elsewhere.has(id)); const movable = publicIds.filter(id => !elsewhere.has(id)); diff --git a/packages/vitnode/src/content/access/server/files.ts b/packages/vitnode/src/content/access/server/files.ts index 6a6d93ccbe..f6800c61ee 100644 --- a/packages/vitnode/src/content/access/server/files.ts +++ b/packages/vitnode/src/content/access/server/files.ts @@ -56,6 +56,26 @@ const richTextFileIds = (value: unknown, into: Set): void => { if (Array.isArray(value.content)) richTextFileIds(value.content, into); }; +export const richTextFileIdsOf = (value: unknown): Set => { + const into = new Set(); + const walk = (node: unknown): void => { + if (Array.isArray(node)) { + for (const item of node) walk(item); + + return; + } + if (!isRecord(node)) return; + if (isRecord(node.attrs)) { + const id = positiveId(node.attrs.fileId); + if (id !== null) into.add(id); + } + for (const child of Object.values(node)) walk(child); + }; + walk(value); + + return into; +}; + const registeredOf = ( c: Context, contentTypeId: string, diff --git a/packages/vitnode/src/content/registry.ts b/packages/vitnode/src/content/registry.ts index d227c43d56..f476a3fb80 100644 --- a/packages/vitnode/src/content/registry.ts +++ b/packages/vitnode/src/content/registry.ts @@ -291,6 +291,23 @@ export const validateContentTypes = ( } } + for (const entry of entries) { + const { definition } = entry; + const from = definition.access?.from; + if (!from) continue; + + const descriptor = definition.fields[from]; + const targetId = + descriptor?.kind === "relation" ? descriptor.target().id : undefined; + const target = targetId === undefined ? undefined : byId.get(targetId); + if (!target?.definition.access?.scope.enabled) { + throw new ContentEngineError( + `${describe(entry)} takes its access from "${from}", but ${targetId === undefined ? "that field is not a relation" : `"${targetId}" is not a registered content type with \`access: { scope }\``}. Its records would be readable by everyone - point \`access.from\` at a scope content type.`, + { contentTypeId: definition.id }, + ); + } + } + return [...entries].sort((a, b) => a.definition.id.localeCompare(b.definition.id), ); diff --git a/packages/vitnode/src/content/rich-text/document.ts b/packages/vitnode/src/content/rich-text/document.ts index eec3fbba0d..359139d2d8 100644 --- a/packages/vitnode/src/content/rich-text/document.ts +++ b/packages/vitnode/src/content/rich-text/document.ts @@ -60,15 +60,33 @@ const zodMark = z.strictObject({ type: zodType, }); -const zodNode: z.ZodType = z.strictObject({ - attrs: zodAttrs.optional(), - get content(): z.ZodOptional>> { - return z.array(zodNode).optional(); - }, - marks: z.array(zodMark).optional(), - text: z.string().optional(), - type: zodType, -}); +export const RICH_TEXT_SPAN_LIMITS = { + colspan: 1000, + rowspan: 65_534, +} as const; + +const zodNode: z.ZodType = z + .strictObject({ + attrs: zodAttrs.optional(), + get content(): z.ZodOptional>> { + return z.array(zodNode).optional(); + }, + marks: z.array(zodMark).optional(), + text: z.string().optional(), + type: zodType, + }) + .superRefine((node, ctx) => { + for (const [name, max] of Object.entries(RICH_TEXT_SPAN_LIMITS)) { + const value = node.attrs?.[name]; + if (typeof value === "number" && value > max) { + ctx.addIssue({ + code: "custom", + message: `A table cell's ${name} is at most ${String(max)}.`, + path: ["attrs", name], + }); + } + } + }); const zodDocumentShape = z.strictObject({ attrs: zodAttrs.optional(), diff --git a/packages/vitnode/src/content/rich-text/elements.ts b/packages/vitnode/src/content/rich-text/elements.ts index 1ac485f538..56e1e6c367 100644 --- a/packages/vitnode/src/content/rich-text/elements.ts +++ b/packages/vitnode/src/content/rich-text/elements.ts @@ -1,5 +1,3 @@ -import type { RichTextAttrs, RichTextMark, RichTextNode } from "./document"; - import { EDITOR_IMAGE_MAX_ALT_LENGTH, editorAttachmentKindOf, @@ -7,6 +5,12 @@ import { isEditorImageWrap, normalizeEditorImageWidth, } from "../../lib/editor-uploads"; +import { + RICH_TEXT_SPAN_LIMITS, + type RichTextAttrs, + type RichTextMark, + type RichTextNode, +} from "./document"; import { richTextToPlainText } from "./plain-text"; export const RICH_TEXT_SLOT = 0; @@ -122,10 +126,24 @@ const withStyle = ( style: Record | undefined, ): RichTextElement => (style ? { ...element, style } : element); +const MAX_TABLE_COLUMNS = 1000; + +const clampedSpan = (value: unknown, max: number): null | number => { + const span = positiveInteger(value); + + return span === null ? null : Math.min(span, max); +}; + const cellElement = (tag: "td" | "th", node: RichTextNode): RichTextElement => { const attrs: Record = {}; - const colspan = positiveInteger(node.attrs?.colspan); - const rowspan = positiveInteger(node.attrs?.rowspan); + const colspan = clampedSpan( + node.attrs?.colspan, + RICH_TEXT_SPAN_LIMITS.colspan, + ); + const rowspan = clampedSpan( + node.attrs?.rowspan, + RICH_TEXT_SPAN_LIMITS.rowspan, + ); if (colspan !== null && colspan > 1) attrs.colspan = colspan; if (rowspan !== null && rowspan > 1) attrs.rowspan = rowspan; @@ -137,12 +155,14 @@ const columnWidths = (table: RichTextNode): (null | number)[] => { const widths: (null | number)[] = []; for (const cell of firstRow?.content ?? []) { - const colspan = positiveInteger(cell.attrs?.colspan) ?? 1; + const colspan = + clampedSpan(cell.attrs?.colspan, RICH_TEXT_SPAN_LIMITS.colspan) ?? 1; const colwidth = Array.isArray(cell.attrs?.colwidth) ? cell.attrs.colwidth : []; for (let index = 0; index < colspan; index += 1) { + if (widths.length >= MAX_TABLE_COLUMNS) return widths; widths.push(positiveInteger(colwidth[index])); } } diff --git a/packages/vitnode/src/content/server/delivery-routes.ts b/packages/vitnode/src/content/server/delivery-routes.ts index 00a0feca00..4ac62ccc8c 100644 --- a/packages/vitnode/src/content/server/delivery-routes.ts +++ b/packages/vitnode/src/content/server/delivery-routes.ts @@ -26,6 +26,7 @@ import { contentIdParamSchema, contentIdSchema } from "../ids"; import { resolveContentPublicLocale } from "../locale"; import { listContentLanguages } from "./language-resolver"; import { contentRecordPageCacheTags } from "./page-cache"; +import { contentLocaleHeaders } from "./request-locale"; import { contentIdentifier } from "./route-helpers"; /** @@ -201,7 +202,7 @@ export const buildContentDeliveryRoutes = < "Cache-Control": "private, no-store", "X-Robots-Tag": "noindex, nofollow", }) - : c.json(resolution, 200); + : c.json(resolution, 200, contentLocaleHeaders(resolved)); }, }); @@ -246,7 +247,7 @@ export const buildContentDeliveryRoutes = < ...contentAccessPageCacheTags(c, accessModel), ]); - return c.json(metadata, 200); + return c.json(metadata, 200, contentLocaleHeaders(resolved)); }, }); diff --git a/packages/vitnode/src/content/server/files.ts b/packages/vitnode/src/content/server/files.ts index 327f6042e5..93374952b8 100644 --- a/packages/vitnode/src/content/server/files.ts +++ b/packages/vitnode/src/content/server/files.ts @@ -273,11 +273,12 @@ const isContentFileOriginAllowed = ( { callerId, contentTypeId, - }: { callerId: null | number; contentTypeId: string }, + staff, + }: { callerId: null | number; contentTypeId: string; staff: boolean }, ): boolean => callerId === null || - row.metadata?.contentTypeId === contentTypeId || - row.userId === callerId; + row.userId === callerId || + (staff && row.metadata?.contentTypeId === contentTypeId); export const assertContentFileReferences = async ( c: Context, @@ -305,9 +306,11 @@ export const assertContentFileReferences = async ( named.map(entry => entry.id), tx, ); + const actor = resolveContentActor(c); const origin = { - callerId: resolveContentActor(c).userId, + callerId: actor.userId, contentTypeId: definition.id, + staff: actor.type === "staff", }; const foreign = new Set( rows diff --git a/packages/vitnode/src/content/server/index.ts b/packages/vitnode/src/content/server/index.ts index e3559b54f1..93a4896a1c 100644 --- a/packages/vitnode/src/content/server/index.ts +++ b/packages/vitnode/src/content/server/index.ts @@ -286,7 +286,10 @@ export type { ContentPreviewToken, ContentPreviewTokenPayload, } from "./preview-token"; -export { rememberContentPublicRead } from "./public-cache"; +export { + pinContentPublicGeneration, + rememberContentPublicRead, +} from "./public-cache"; export { contentPublicLocaleStates } from "./public-locales"; export { buildContentPublicModule } from "./public-module"; export type { diff --git a/packages/vitnode/src/content/server/live/auth.ts b/packages/vitnode/src/content/server/live/auth.ts index 8d70ec8dda..30db9d6370 100644 --- a/packages/vitnode/src/content/server/live/auth.ts +++ b/packages/vitnode/src/content/server/live/auth.ts @@ -7,7 +7,9 @@ import type { import type { RegisteredContentType } from "@/content/registry"; import { checkStaffPermissionOfUser } from "@/api/lib/check-staff-permission"; +import { forgetRequestStaffPermissions } from "@/api/lib/staff-permission-cache"; import { SessionAdminModel } from "@/api/models/session-admin"; +import { forgetContentAccessState } from "@/content/access/server/invalidate"; import { CONTENT_PERMISSIONS } from "@/content/const"; import { parseContentId } from "@/content/ids"; import { contentLiveRoom } from "@/content/live/protocol"; @@ -55,6 +57,8 @@ export const authorizeContentLive = async ( c: Context, room: ContentLiveRoomRef, ): Promise => { + forgetContentAccessState(c); + forgetRequestStaffPermissions(c); const entry = findLiveContentType(c, room.contentTypeId); if (!entry) return { code: "NOT_FOUND", ok: false }; diff --git a/packages/vitnode/src/content/server/live/documents.ts b/packages/vitnode/src/content/server/live/documents.ts index 59f1f6e769..b7676a6b4b 100644 --- a/packages/vitnode/src/content/server/live/documents.ts +++ b/packages/vitnode/src/content/server/live/documents.ts @@ -36,6 +36,8 @@ export interface ContentDocumentStore { ) => Promise; } +export const CONTENT_LIVE_MAX_DOCUMENT_BYTES = 16 * 1024 * 1024; + export type ContentDocumentResult = "INVALID_MESSAGE" | "NOT_JOINED" | "ok"; const REMOTE_ORIGIN = Symbol("content-live-remote"); @@ -58,6 +60,7 @@ interface LoadedDoc { ref: ContentLiveDocRef; room: string; seeder: null | string; + sizeUpperBound: number; store: ContentDocumentStore; updatedBy: null | number; } @@ -158,6 +161,7 @@ export const createContentDocuments = ({ ref, room, seeder: null, + sizeUpperBound: state?.byteLength ?? 0, store, updatedBy: null, }; @@ -182,7 +186,9 @@ export const createContentDocuments = ({ entry.dirty = false; try { - await entry.store.save(entry.ref, Y.encodeStateAsUpdate(entry.doc), { + const state = Y.encodeStateAsUpdate(entry.doc); + entry.sizeUpperBound = state.byteLength; + await entry.store.save(entry.ref, state, { baseVersion: entry.baseVersion, userId: entry.updatedBy, }); @@ -310,6 +316,7 @@ export const createContentDocuments = ({ const bytes = decodeContentLiveBytes(message.update); if (message.type === "doc:update") { Y.applyUpdate(entry.doc, bytes, REMOTE_ORIGIN); + entry.sizeUpperBound += bytes.byteLength; markSeeded(entry); schedulePersist(entry); } else if (message.type === "doc:awareness") { @@ -510,12 +517,25 @@ export const createContentDocuments = ({ if (!entry) return "NOT_JOINED"; const bytes = decode(update); if (!bytes) return "INVALID_MESSAGE"; + if ( + entry.sizeUpperBound + bytes.byteLength > + CONTENT_LIVE_MAX_DOCUMENT_BYTES + ) { + entry.sizeUpperBound = Y.encodeStateAsUpdate(entry.doc).byteLength; + if ( + entry.sizeUpperBound + bytes.byteLength > + CONTENT_LIVE_MAX_DOCUMENT_BYTES + ) { + return "INVALID_MESSAGE"; + } + } try { Y.applyUpdate(entry.doc, bytes, clientId); } catch { return "INVALID_MESSAGE"; } + entry.sizeUpperBound += bytes.byteLength; markSeeded(entry); entry.updatedBy = userId; relay(entry.room, { diff --git a/packages/vitnode/src/content/server/public-cache.ts b/packages/vitnode/src/content/server/public-cache.ts index 98bf99be38..1d4bc811b2 100644 --- a/packages/vitnode/src/content/server/public-cache.ts +++ b/packages/vitnode/src/content/server/public-cache.ts @@ -51,6 +51,27 @@ const bumpGeneration = async ( const inflight = new Map>(); +const pinnedGenerations = new WeakMap>>(); + +const pinnedGeneration = async ( + c: Context, + cache: CacheModel, + generationKey: string, +): Promise => { + let pins = pinnedGenerations.get(c); + if (!pins) { + pins = new Map(); + pinnedGenerations.set(c, pins); + } + let pin = pins.get(generationKey); + if (!pin) { + pin = readGeneration(cache, generationKey); + pins.set(generationKey, pin); + } + + return await pin; +}; + /** * A generation-keyed read-through: `:g:`. * @@ -77,7 +98,7 @@ const rememberInGeneration = async ( const cache: CacheModel | undefined = c.get("cache"); if (!cache) return await load(); - const generation = await readGeneration(cache, generationKey); + const generation = await pinnedGeneration(c, cache, generationKey); const fullKey = `${prefix}:g${generation}:${createHash("sha256") .update(stableJson(key)) .digest("base64url")}`; @@ -90,7 +111,9 @@ const rememberInGeneration = async ( const loading = (async () => { const value = await load(); - await cache.setSystem(fullKey, { value }, ttlSeconds); + if ((await readGeneration(cache, generationKey)) === generation) { + await cache.setSystem(fullKey, { value }, ttlSeconds); + } return value; })(); @@ -122,6 +145,21 @@ export const bumpContentPublicGeneration = async ( await bumpGeneration(c, publicGenerationKey(contentTypeId), { strict: true }); }; +/** + * Pins the content type's cache generation for this request. Call it at the + * start of a handler, before any access rule or visibility state is read, so a + * later `rememberContentPublicRead` cannot store a response built from rules + * that changed while the request ran. + */ +export const pinContentPublicGeneration = async ( + c: Context, + contentTypeId: string, +): Promise => { + const cache: CacheModel | undefined = c.get("cache"); + if (cache) + await pinnedGeneration(c, cache, publicGenerationKey(contentTypeId)); +}; + /** * One cached public read. `key` must hold everything the response depends on - * the normalized query, the resolved locale and where it came from - and the diff --git a/packages/vitnode/src/content/server/public-routes.ts b/packages/vitnode/src/content/server/public-routes.ts index 1b76895e3f..154fa71299 100644 --- a/packages/vitnode/src/content/server/public-routes.ts +++ b/packages/vitnode/src/content/server/public-routes.ts @@ -73,7 +73,10 @@ import { } from "./page-cache"; import { contentPreviewSecret } from "./preview-link"; import { verifyContentPreviewToken } from "./preview-token"; -import { rememberContentPublicRead } from "./public-cache"; +import { + pinContentPublicGeneration, + rememberContentPublicRead, +} from "./public-cache"; import { contentPermissionsRoute } from "./public-permissions"; import { resolveContentPublicRowPeople } from "./public-row-hydration"; import { @@ -401,6 +404,7 @@ export const buildContentPublicRoutes = < }, }, handler: async c => { + await pinContentPublicGeneration(c, definition.id); // The whole query string goes through both schemas, each reading only the // keys it owns, and neither is strict - so a stale bookmark or a tracking // parameter is ignored rather than turned into a 400. `orderBy` is the @@ -775,6 +779,7 @@ export const buildContentPublicRoutes = < }, }, handler: async c => { + await pinContentPublicGeneration(c, definition.id); const query = c.req.valid("query"); const unique = new Map(); for (const raw of (query.ids ?? "").split(",")) { @@ -824,7 +829,11 @@ export const buildContentPublicRoutes = < }; }; - if (hiddenAccess || readable.hidden) { + if ( + hiddenAccess || + readable.hidden || + !(await isContentAccessShareable(c, accessModel)) + ) { return c.json(await load(), 200, { "Cache-Control": "private, no-store", }); @@ -874,6 +883,7 @@ export const buildContentPublicRoutes = < }, }, handler: async c => { + await pinContentPublicGeneration(c, definition.id); const resolved = await localeFor(c); if (!resolved) throw notFound(); const shareable = await isContentAccessShareable(c, accessModel); diff --git a/packages/vitnode/src/content/server/public-visibility-routes.ts b/packages/vitnode/src/content/server/public-visibility-routes.ts index c25435076f..276f70b9fc 100644 --- a/packages/vitnode/src/content/server/public-visibility-routes.ts +++ b/packages/vitnode/src/content/server/public-visibility-routes.ts @@ -25,6 +25,7 @@ import { emitContentEvent } from "./emit"; import { withHttpErrors } from "./http-errors"; import { resetContentLiveRecord } from "./live/hooks"; import { contentPublicStateOf } from "./page-cache"; +import { contentPublicWriteRefusal } from "./public-write-authorization"; import { visibilityAction } from "./report-target"; import { contentIdentifier, jsonBody, readOptionalJson } from "./route-helpers"; import { syncContentSearch } from "./search-sync"; @@ -105,6 +106,9 @@ export const contentPublicVisibilityRoutes = < throw new HTTPException(404, { message: `${name} not found.` }); } if (!(await canHideContentRecord(c, { action, model, pluginId, row }))) { + if (row.hiddenAt != null) { + throw new HTTPException(404, { message: `${name} not found.` }); + } throw new HTTPException(403, { message: `You cannot ${action} this ${name}.`, }); @@ -119,7 +123,7 @@ export const contentPublicVisibilityRoutes = < targetId: contentIdKey(id), tx, }); - if (result && reason) { + if (result?.changed && reason) { await tx .update(table) .set({ hiddenReason: reason }) @@ -228,6 +232,9 @@ export const contentPublicDeleteRoute = < const row = await readRow(c, model, id); if (!row) throw new HTTPException(404, { message: `${name} not found.` }); if (!(await canDeleteContentRecord(c, { model, pluginId, row }))) { + if (contentPublicWriteRefusal(row) === "notFound") { + throw new HTTPException(404, { message: `${name} not found.` }); + } throw new HTTPException(403, { message: `You cannot delete this ${name}.`, }); diff --git a/packages/vitnode/src/content/server/public-write-service.ts b/packages/vitnode/src/content/server/public-write-service.ts index d5d0e1f17a..4f6387d8c6 100644 --- a/packages/vitnode/src/content/server/public-write-service.ts +++ b/packages/vitnode/src/content/server/public-write-service.ts @@ -5,7 +5,7 @@ import type { } from "drizzle-orm/pg-core"; import type { Context } from "hono"; -import { eq, getColumns, getColumnTable, inArray } from "drizzle-orm"; +import { and, eq, getColumns, getColumnTable, inArray } from "drizzle-orm"; import { getTableConfig } from "drizzle-orm/pg-core"; import type { ContentAccessAction } from "../access/policy"; @@ -25,7 +25,12 @@ import type { AnyContentModel, ContentModel } from "./model"; import type { ContentPublicWriteActor } from "./public-write-authorization"; import type { ContentDatabase } from "./service"; +import { core_files } from "../../database/files"; import { decideContentAccessForScopes } from "../access/server/decide"; +import { + contentRecordHasFile, + richTextFileIdsOf, +} from "../access/server/files"; import { contentAccessScopesForValues, contentAccessSourceOf, @@ -314,6 +319,9 @@ export const createContentPublicWriteService = < ...(targetColumns.hiddenAt ? { hiddenAt: targetColumns.hiddenAt } : {}), + ...(targetColumns.deletedAt + ? { deletedAt: targetColumns.deletedAt } + : {}), }) .from(targetTable) .where(inArray(target, ids)); @@ -358,6 +366,46 @@ export const createContentPublicWriteService = < } }; + const assertRichTextFileOrigins = async ( + tx: ContentDatabase, + values: Record, + { itemId, userId }: { itemId?: ContentId; userId: number }, + ): Promise => { + for (const [field, value] of Object.entries(values)) { + if (definition.fields[field]?.kind !== "richText") continue; + + const ids = [...richTextFileIdsOf(value)]; + if (ids.length === 0) continue; + + const owned = new Set( + ( + await tx + .select({ id: core_files.id }) + .from(core_files) + .where( + and(inArray(core_files.id, ids), eq(core_files.userId, userId)), + ) + ).map(row => row.id), + ); + + for (const fileId of ids) { + if (owned.has(fileId)) continue; + if ( + itemId !== undefined && + (await contentRecordHasFile(c, accessModel, itemId, fileId)) + ) { + continue; + } + + throw new ContentPublicWriteInputError({ + contentTypeId, + field, + message: "The document references a file that is not available.", + }); + } + } + }; + const asInputError = async (run: () => Promise): Promise => { try { return await run(); @@ -386,6 +434,7 @@ export const createContentPublicWriteService = < ...(editorial ? { version: columns.version } : {}), ...(ownerField === null ? {} : { [ownerField]: columns[ownerField] }), ...(definition.visibility.enabled ? { hiddenAt: columns.hiddenAt } : {}), + ...(columns.deletedAt ? { deletedAt: columns.deletedAt } : {}), ...(containerSource && columns[containerSource.name] ? { [containerSource.name]: columns[containerSource.name] } : {}), @@ -563,6 +612,7 @@ export const createContentPublicWriteService = < const prepare = async (tx: ContentDatabase) => { await assertPublicRelationTargets(tx, parsed); + await assertRichTextFileOrigins(tx, parsed, { userId: actor.userId }); await assertWriteAccess( await contentAccessScopesForValues(c, accessModel, parsed), ); @@ -763,6 +813,13 @@ export const createContentPublicWriteService = < const result = await c.get("db").transaction(async tx => { const access = await authorize(tx, actor, await lockRow(tx, id)); await assertPublicRelationTargets(tx, values); + await assertRichTextFileOrigins(tx, values, { + itemId: id, + userId: actor.userId, + }); + await assertWriteAccess( + await contentAccessScopesForValues(c, accessModel, values), + ); if (await needsReview(access)) { return { @@ -808,6 +865,13 @@ export const createContentPublicWriteService = < const result = await c.get("db").transaction(async tx => { const access = await authorize(tx, actor, await lockRow(tx, id)); await assertPublicRelationTargets(tx, values); + await assertRichTextFileOrigins(tx, values, { + itemId: id, + userId: actor.userId, + }); + await assertWriteAccess( + await contentAccessScopesForValues(c, accessModel, values), + ); if (await needsReview(access)) { return { kind: "proposal" as const, ...(await propose(tx, access)) }; diff --git a/packages/vitnode/src/content/server/refresh/auth.ts b/packages/vitnode/src/content/server/refresh/auth.ts index d8670ed764..b3690cdb2a 100644 --- a/packages/vitnode/src/content/server/refresh/auth.ts +++ b/packages/vitnode/src/content/server/refresh/auth.ts @@ -3,8 +3,10 @@ import type { Context } from "hono"; import type { ContentRefreshScope } from "@/content/live/refresh"; import { checkStaffPermissionOfUser } from "@/api/lib/check-staff-permission"; +import { forgetRequestStaffPermissions } from "@/api/lib/staff-permission-cache"; import { SessionModel } from "@/api/models/session"; import { SessionAdminModel } from "@/api/models/session-admin"; +import { forgetContentAccessState } from "@/content/access/server/invalidate"; import { CONTENT_PERMISSIONS } from "@/content/const"; import { contentRefreshRoom } from "@/content/live/refresh"; @@ -29,6 +31,8 @@ export const authorizeContentRefresh = async ( c: Context, scope: ContentRefreshScope, ): Promise => { + forgetContentAccessState(c); + forgetRequestStaffPermissions(c); const entry = (c.get("core")?.contentTypes ?? []).find( candidate => candidate.definition.id === scope.contentTypeId, ); diff --git a/packages/vitnode/src/content/server/search-document.ts b/packages/vitnode/src/content/server/search-document.ts index a253ee7514..c7e0d0299f 100644 --- a/packages/vitnode/src/content/server/search-document.ts +++ b/packages/vitnode/src/content/server/search-document.ts @@ -70,6 +70,7 @@ const isContentRowHidden = (row: object): boolean => { export const isContentRowPublic = (row: object): boolean => { const values = row as Record; + if (values.deletedAt != null) return false; return isContentPubliclyVisible({ hiddenAt: toTimestamp(values.hiddenAt), diff --git a/packages/vitnode/src/content/server/search-indexer.ts b/packages/vitnode/src/content/server/search-indexer.ts index a3c1cd5abb..8af4537b76 100644 --- a/packages/vitnode/src/content/server/search-indexer.ts +++ b/packages/vitnode/src/content/server/search-indexer.ts @@ -51,6 +51,7 @@ const REQUIRED_COLUMNS = [ const requiredColumns = (definition: AnyContentTypeDefinition): string[] => [ ...REQUIRED_COLUMNS, ...(definition.visibility.enabled ? ["hiddenAt"] : []), + ...(definition.thread.enabled ? ["deletedAt"] : []), ]; interface ContentSearchSources { diff --git a/packages/vitnode/src/content/server/search-related.ts b/packages/vitnode/src/content/server/search-related.ts index 973dfa0896..9046ab0a2d 100644 --- a/packages/vitnode/src/content/server/search-related.ts +++ b/packages/vitnode/src/content/server/search-related.ts @@ -9,6 +9,7 @@ import type { AnyContentTypeDefinition } from "../types"; import type { ContentSearchContext } from "./search-document"; import { core_content_containers } from "../../database/content-containers"; +import { isContentPubliclyVisible } from "../cache"; import { contentIdKey, parseContentId } from "../ids"; import { contentSearchContextSource, @@ -21,6 +22,8 @@ import { findContentModel } from "./model"; import { isContentRowPublic } from "./search-document"; const RELATED_PUBLICATION_COLUMNS = ["status", "publishedAt", "hiddenAt"]; +const TRANSLATION_STATUS = "__translationStatus"; +const TRANSLATION_PUBLISHED_AT = "__translationPublishedAt"; const contextIdOf = ( source: ContentSearchContextSource, @@ -126,6 +129,12 @@ const loadRelatedTranslations = async ( const column = columns[name]; if (column) selection[name] = column; } + const translationStatus = columns.status; + const translationPublishedAt = columns.publishedAt; + if (translationStatus) selection[TRANSLATION_STATUS] = translationStatus; + if (translationPublishedAt) { + selection[TRANSLATION_PUBLISHED_AT] = translationPublishedAt; + } const [rows, languages] = await Promise.all([ c @@ -143,12 +152,30 @@ const loadRelatedTranslations = async ( for (const row of rows) { const locale = localeOf.get(row.languageId as number); if (locale === undefined) continue; + if ( + (translationStatus || translationPublishedAt) && + !isContentPubliclyVisible({ + publishedAt: translationPublishedAt + ? (row[TRANSLATION_PUBLISHED_AT] as Date | null | string) + : new Date(0), + status: translationStatus + ? (row[TRANSLATION_STATUS] as string | undefined) + : "published", + }) + ) { + continue; + } + const { + [TRANSLATION_PUBLISHED_AT]: _publishedAt, + [TRANSLATION_STATUS]: _status, + ...values + } = row; const key = contentIdKey(row.itemId as number | string); const current = picked.get(key); if (current && current.locale === defaultLocale) continue; if (!current || locale === defaultLocale) { - picked.set(key, { locale, values: row }); + picked.set(key, { locale, values }); } } @@ -213,16 +240,19 @@ const loadRelatedContexts = async ( const key = contentIdKey(id); const translation = translations.get(key); + const context = toRelatedSearchContext( + target, + translation ? { ...row, ...translation.values } : row, + routing, + translation?.locale, + ); return [ [ key, - toRelatedSearchContext( - target, - translation ? { ...row, ...translation.values } : row, - routing, - translation?.locale, - ), + translated.length > 0 && !translation + ? { ...context, isPublic: false } + : context, ], ]; }); diff --git a/packages/vitnode/src/lib/api/upload.ts b/packages/vitnode/src/lib/api/upload.ts index bbbd933328..9ff3faa668 100644 --- a/packages/vitnode/src/lib/api/upload.ts +++ b/packages/vitnode/src/lib/api/upload.ts @@ -61,6 +61,12 @@ const EXTENSIONS_BY_MIME: Record = { "video/webm": [".webm"], }; +export const storedContentType = (mimeType?: null | string): string => { + const type = mimeType?.toLowerCase().split(";")[0]?.trim(); + + return type && type in EXTENSIONS_BY_MIME ? type : "application/octet-stream"; +}; + const ACTIVE_CONTENT_EXTENSIONS = new Set([ ".asp", ".aspx", diff --git a/packages/vitnode/src/lib/audit/csv.ts b/packages/vitnode/src/lib/audit/csv.ts index 06d0360cfa..614bc5b909 100644 --- a/packages/vitnode/src/lib/audit/csv.ts +++ b/packages/vitnode/src/lib/audit/csv.ts @@ -1,5 +1,5 @@ /** Characters a spreadsheet reads as the start of a formula. */ -const FORMULA_START = /^[=+\-@\t\r]/; +const FORMULA_START = /^\s*[=+\-@\t\r\uFF1D\uFF0B\uFF0D\uFF20]/; /** * One CSV cell: `null` and `undefined` are empty, objects are JSON, and a diff --git a/packages/vitnode/src/locales/en.json b/packages/vitnode/src/locales/en.json index f21a33f97e..ffb221f0c0 100644 --- a/packages/vitnode/src/locales/en.json +++ b/packages/vitnode/src/locales/en.json @@ -1304,6 +1304,10 @@ "admin_session_required": { "title": "Open the AdminCP first", "desc": "Your account has AdminCP access, so adding a passkey needs one extra check. Sign in to the AdminCP with your password in this browser, then try again." + }, + "reauthentication_required": { + "title": "Please sign in again", + "desc": "For your security, adding a passkey needs a recent sign-in. Sign out, sign back in, and add it within the hour." } } }, diff --git a/packages/vitnode/src/page-cache/render-context.server.ts b/packages/vitnode/src/page-cache/render-context.server.ts index a026e97e04..cdaf6aa1c3 100644 --- a/packages/vitnode/src/page-cache/render-context.server.ts +++ b/packages/vitnode/src/page-cache/render-context.server.ts @@ -39,6 +39,7 @@ const isPrivateResponse = (cacheControl: null | string): boolean => */ export const recordPageCacheApiResponse = (response: { headers: Headers; + status: number; }): void => { const state = storage().getStore(); if (state === undefined) return; @@ -49,7 +50,11 @@ export const recordPageCacheApiResponse = (response: { state.tags.add(tag); } - if (isPrivateResponse(response.headers.get("cache-control"))) { + if ( + isPrivateResponse(response.headers.get("cache-control")) || + response.status === 429 || + response.status >= 500 + ) { state.isPrivate = true; } }; diff --git a/packages/vitnode/src/page-cache/tags.ts b/packages/vitnode/src/page-cache/tags.ts index 31ab7305fa..4d7a5ea120 100644 --- a/packages/vitnode/src/page-cache/tags.ts +++ b/packages/vitnode/src/page-cache/tags.ts @@ -15,6 +15,21 @@ export const PAGE_CACHE_TAGS_HEADER = "x-vitnode-cache-tags"; */ export const PAGE_CACHE_RENDER_HEADER = "x-vitnode-shared-render"; +const RENDER_TOKEN_KEY = Symbol.for("vitnode.page-cache.render-token"); + +export const pageCacheRenderToken = (): string => { + const holder = globalThis as typeof globalThis & { + [RENDER_TOKEN_KEY]?: string; + }; + holder[RENDER_TOKEN_KEY] ??= crypto.randomUUID(); + + return holder[RENDER_TOKEN_KEY]; +}; + +export const isPageCacheRenderMarker = ( + value: null | string | undefined, +): boolean => value === pageCacheRenderToken(); + /** `HIT`, `STALE`, `MISS`, `REVALIDATED` or `BYPASS` - for checking it works. */ export const PAGE_CACHE_STATUS_HEADER = "x-vitnode-cache"; diff --git a/packages/vitnode/src/tanstack/admin/dashboard/widgets.tsx b/packages/vitnode/src/tanstack/admin/dashboard/widgets.tsx index e84daf25cb..f2acb2c686 100644 --- a/packages/vitnode/src/tanstack/admin/dashboard/widgets.tsx +++ b/packages/vitnode/src/tanstack/admin/dashboard/widgets.tsx @@ -56,5 +56,6 @@ export const coreDashboardBrowserWidgets: AdminDashboardWidget[] = [ minSpan: 2, defaultEnabled: true, allowMultiple: true, + permission: { module: "notifications", permission: "can_manage" }, }, ]; diff --git a/packages/vitnode/src/tanstack/auth/return-to.ts b/packages/vitnode/src/tanstack/auth/return-to.ts index 4048476d60..9c9dea4930 100644 --- a/packages/vitnode/src/tanstack/auth/return-to.ts +++ b/packages/vitnode/src/tanstack/auth/return-to.ts @@ -22,6 +22,8 @@ const normalize = (value: string): null | string => { // navigates to what was actually validated. The locale prefix rides along in // the pathname untouched: `/pl/discover` is just a path here, which is why // this needs to know nothing about languages. + if (url.pathname.startsWith("//")) return null; + return `${url.pathname}${url.search}${url.hash}`; }; diff --git a/packages/vitnode/src/tanstack/link/href-link.tsx b/packages/vitnode/src/tanstack/link/href-link.tsx index 82e39cb3a6..7de3426a21 100644 --- a/packages/vitnode/src/tanstack/link/href-link.tsx +++ b/packages/vitnode/src/tanstack/link/href-link.tsx @@ -102,6 +102,7 @@ export const useHrefNavigate = () => { return useCallback( async (href: string, { replace }: { replace?: boolean } = {}) => { if (isExternalHref(href)) { + if (!/^(?:https?|mailto):/i.test(href)) return; if (replace) window.location.replace(href); else window.location.assign(href); diff --git a/packages/vitnode/src/tanstack/page-cache/server.test.ts b/packages/vitnode/src/tanstack/page-cache/server.test.ts index 9b8079c943..dbe8bb6abc 100644 --- a/packages/vitnode/src/tanstack/page-cache/server.test.ts +++ b/packages/vitnode/src/tanstack/page-cache/server.test.ts @@ -11,6 +11,7 @@ import { resetPageCacheStore, } from "@/page-cache/store.server"; import { + isPageCacheRenderMarker, PAGE_CACHE_RENDER_HEADER, PAGE_CACHE_STATUS_HEADER, PAGE_CACHE_TAGS_HEADER, @@ -84,7 +85,9 @@ const setup = ({ authorization: request.headers.get("authorization"), cookie: request.headers.get("cookie"), method: request.method, - shared: request.headers.get(PAGE_CACHE_RENDER_HEADER) === "1", + shared: isPageCacheRenderMarker( + request.headers.get(PAGE_CACHE_RENDER_HEADER), + ), url: request.url, }; seen.push(current); @@ -92,6 +95,7 @@ const setup = ({ // What the server-side fetcher does for every API response it reads. recordPageCacheApiResponse({ headers: new Headers({ [PAGE_CACHE_TAGS_HEADER]: tags.join(",") }), + status: 200, }); await gate; @@ -263,6 +267,7 @@ describe("sessions never leak into shared HTML", () => { (_, count) => { recordPageCacheApiResponse({ headers: new Headers({ "cache-control": "private, no-store" }), + status: 200, }); return html(`render #${String(count)}`); diff --git a/packages/vitnode/src/tanstack/page-cache/server.ts b/packages/vitnode/src/tanstack/page-cache/server.ts index 9ee59a5d7f..a97bc6d11c 100644 --- a/packages/vitnode/src/tanstack/page-cache/server.ts +++ b/packages/vitnode/src/tanstack/page-cache/server.ts @@ -24,6 +24,7 @@ import { encodePageCacheTags, PAGE_CACHE_RENDER_HEADER, PAGE_CACHE_STATUS_HEADER, + pageCacheRenderToken, } from "@/page-cache/tags"; import type { PageCacheRouteTable } from "../../page-cache/policy"; @@ -238,7 +239,7 @@ export const createPageCacheHandler = ( rest: never[], ): Promise => { const headers = new Headers(event.req.headers); - headers.set(PAGE_CACHE_RENDER_HEADER, "1"); + headers.set(PAGE_CACHE_RENDER_HEADER, pageCacheRenderToken()); const request = new Request(event.req.url, { headers, method: event.req.method, diff --git a/packages/vitnode/src/tanstack/prerender.ts b/packages/vitnode/src/tanstack/prerender.ts index 57affd854b..065a2941d2 100644 --- a/packages/vitnode/src/tanstack/prerender.ts +++ b/packages/vitnode/src/tanstack/prerender.ts @@ -1,7 +1,10 @@ import { createIsomorphicFn } from "@tanstack/react-start"; import { getRequestHeader } from "@tanstack/react-start/server"; -import { PAGE_CACHE_RENDER_HEADER } from "@/page-cache/tags"; +import { + isPageCacheRenderMarker, + PAGE_CACHE_RENDER_HEADER, +} from "@/page-cache/tags"; const PRERENDER_REQUEST_HEADER = "x-nitro-prerender"; @@ -26,7 +29,7 @@ export const isSharedRenderRequest = createIsomorphicFn() try { return ( getRequestHeader(PRERENDER_REQUEST_HEADER) !== undefined || - getRequestHeader(PAGE_CACHE_RENDER_HEADER) !== undefined + isPageCacheRenderMarker(getRequestHeader(PAGE_CACHE_RENDER_HEADER)) ); } catch { return false; diff --git a/packages/vitnode/src/tanstack/realtime/realtime-listeners.tsx b/packages/vitnode/src/tanstack/realtime/realtime-listeners.tsx index 627a501def..922549f700 100644 --- a/packages/vitnode/src/tanstack/realtime/realtime-listeners.tsx +++ b/packages/vitnode/src/tanstack/realtime/realtime-listeners.tsx @@ -5,6 +5,7 @@ import { useUserPresenceHeartbeat } from "@/hooks/use-user-presence-heartbeat"; import { NotificationListener } from "@/views/layouts/theme/notification-listener"; import { WebSocketAuthSync } from "@/views/layouts/theme/web-socket-auth-sync"; +import { removeUserIdentityQueries } from "../auth/queries"; import { invalidateSession, useSessionQuery } from "../auth/session-query"; import { NotificationStateSync } from "../notifications/state-sync"; import { socketUserIdFromSession } from "./session"; @@ -18,6 +19,7 @@ export const RealtimeListeners = () => { const { data: session } = useSessionQuery(); const queryClient = useQueryClient(); const onSignedOut = React.useCallback(() => { + removeUserIdentityQueries(queryClient); void invalidateSession(queryClient); }, [queryClient]); diff --git a/packages/vitnode/src/tanstack/search/header-search-content.tsx b/packages/vitnode/src/tanstack/search/header-search-content.tsx index 701d366b22..2a82248bfe 100644 --- a/packages/vitnode/src/tanstack/search/header-search-content.tsx +++ b/packages/vitnode/src/tanstack/search/header-search-content.tsx @@ -103,7 +103,7 @@ interface SearchSourceGroup { } const isInternalPath = (url: string): boolean => - url.startsWith("/") && !url.startsWith("//"); + url.startsWith("/") && !/^[\\/][\\/]/.test(url) && !url.includes("\\"); const searchPageHref = (term: string): string => `/search?${new URLSearchParams({ search: term }).toString()}`; diff --git a/packages/vitnode/src/tanstack/start/locale-request.ts b/packages/vitnode/src/tanstack/start/locale-request.ts index e7d5fe1750..0ca1f7e7e1 100644 --- a/packages/vitnode/src/tanstack/start/locale-request.ts +++ b/packages/vitnode/src/tanstack/start/locale-request.ts @@ -2,7 +2,10 @@ import type { RequestServerOptions } from "@tanstack/react-start"; import type { LocaleRouting } from "@/lib/i18n/locale-routing"; -import { PAGE_CACHE_RENDER_HEADER } from "@/page-cache/tags"; +import { + isPageCacheRenderMarker, + PAGE_CACHE_RENDER_HEADER, +} from "@/page-cache/tags"; import { handleLocaleRequest } from "../i18n/request"; import { @@ -36,7 +39,7 @@ export const runLocaleRequest = async ( // A shared render is stored and served to everybody, so the visitor's own // locale cookie and the no-store default are the page cache's to apply - // once it knows whether this response may be shared at all. - if (request.headers.has(PAGE_CACHE_RENDER_HEADER)) { + if (isPageCacheRenderMarker(request.headers.get(PAGE_CACHE_RENDER_HEADER))) { applyDocumentSecurityHeaders(result.response, securityHeaders); return result; diff --git a/packages/vitnode/src/views/admin/views/core/debug/sections/security.tsx b/packages/vitnode/src/views/admin/views/core/debug/sections/security.tsx index 951b71c305..5412d27ab6 100644 --- a/packages/vitnode/src/views/admin/views/core/debug/sections/security.tsx +++ b/packages/vitnode/src/views/admin/views/core/debug/sections/security.tsx @@ -20,6 +20,7 @@ import { } from "@/components/ui/frame"; import { debugTabPath } from "@/tanstack/admin/debug/route-search"; +import { safeHttpsHref } from "../../system/plugins/plugin-update-status"; import { coreSecurityUpdate } from "../health/health"; const SECURITY_SURFACE = @@ -125,7 +126,7 @@ export const SecurityUpdatePanel = ({ @@ -165,7 +166,7 @@ export const SecurityUpdatePanel = ({
diff --git a/packages/vitnode/src/views/auth/settings/passkeys/passkeys-mutations.ts b/packages/vitnode/src/views/auth/settings/passkeys/passkeys-mutations.ts index 643be3a249..c55066cc66 100644 --- a/packages/vitnode/src/views/auth/settings/passkeys/passkeys-mutations.ts +++ b/packages/vitnode/src/views/auth/settings/passkeys/passkeys-mutations.ts @@ -9,6 +9,7 @@ import { createPasskeyInBrowser } from "../../passkeys/webauthn"; export type AddPasskeyFailure = | "admin_session_required" | "expired" + | "reauthentication_required" | "server_error" | "unavailable" | PasskeyCeremonyFailure; @@ -43,6 +44,19 @@ const CREDENTIALS = { credentials: "include" } as const; const failed = (failure: Failure) => ({ failure, ok: false as const }); +const forbiddenReason = async ( + response: Response, +): Promise<"admin_session_required" | "reauthentication_required"> => { + const body: unknown = await response.json().catch(() => null); + + return body !== null && + typeof body === "object" && + "error" in body && + body.error === "reauthentication_required" + ? "reauthentication_required" + : "admin_session_required"; +}; + export const addPasskeyInBrowser: AddPasskey = async () => { try { const optionsResponse = await fetcherClient({ @@ -55,7 +69,7 @@ export const addPasskeyInBrowser: AddPasskey = async () => { if (optionsResponse.status === 404) return failed("unavailable"); if (optionsResponse.status === 403) { - return failed("admin_session_required"); + return failed(await forbiddenReason(optionsResponse)); } if (optionsResponse.status !== 200) return failed("server_error"); @@ -76,7 +90,7 @@ export const addPasskeyInBrowser: AddPasskey = async () => { if (verifyResponse.status === 409) return failed("already_registered"); if (verifyResponse.status === 403) { - return failed("admin_session_required"); + return failed(await forbiddenReason(verifyResponse)); } if (verifyResponse.status === 400) return failed("expired"); if (verifyResponse.status !== 201) return failed("server_error"); diff --git a/plugins/blog/package.json b/plugins/blog/package.json index 62727e9fa6..72b4958c97 100644 --- a/plugins/blog/package.json +++ b/plugins/blog/package.json @@ -55,7 +55,7 @@ "react-email": "^6.11.1", "sonner": "^2.0.8", "use-intl": "^4.14.9", - "zod": "^4.6.5" + "zod": "^4.6.6" }, "devDependencies": { "@react-email/ui": "^6.11.1", diff --git a/plugins/blog/src/api/modules/categories/categories.module.ts b/plugins/blog/src/api/modules/categories/categories.module.ts index 641f4e506e..541dda63c4 100644 --- a/plugins/blog/src/api/modules/categories/categories.module.ts +++ b/plugins/blog/src/api/modules/categories/categories.module.ts @@ -20,6 +20,7 @@ import { findContentAccessFamily, isContentAccessFamilyOpen, listContentLanguages, + pinContentPublicGeneration, rememberContentPublicRead, resolveContentRequestLocale, } from "@vitnode/core/content/server"; @@ -216,6 +217,10 @@ const publicCategoriesRoute = buildRoute({ }, }, handler: async c => { + await Promise.all([ + pinContentPublicGeneration(c, definition.id), + pinContentPublicGeneration(c, postContent.definition.id), + ]); const resolved = await resolveContentRequestLocale(c, definition); if (!resolved) { throw new HTTPException(404, { message: "Locale not found." }); diff --git a/plugins/forum/package.json b/plugins/forum/package.json index 686d0726ad..591350914f 100644 --- a/plugins/forum/package.json +++ b/plugins/forum/package.json @@ -40,7 +40,7 @@ "react-dom": "^19.3.0", "sonner": "^2.0.8", "use-intl": "^4.14.9", - "zod": "^4.6.5" + "zod": "^4.6.6" }, "devDependencies": { "@types/react": "^19.3.0", diff --git a/plugins/newsroom/package.json b/plugins/newsroom/package.json index defbaf007c..7b0960d96c 100644 --- a/plugins/newsroom/package.json +++ b/plugins/newsroom/package.json @@ -37,7 +37,7 @@ "react-dom": "^19.3.0", "sonner": "^2.0.8", "use-intl": "^4.14.9", - "zod": "^4.6.5" + "zod": "^4.6.6" }, "devDependencies": { "@types/react": "^19.3.0", diff --git a/plugins/payments-demo/package.json b/plugins/payments-demo/package.json index 078552edf1..92003de210 100644 --- a/plugins/payments-demo/package.json +++ b/plugins/payments-demo/package.json @@ -36,7 +36,7 @@ "react-dom": "^19.3.0", "sonner": "^2.0.8", "use-intl": "^4.14.9", - "zod": "^4.6.5" + "zod": "^4.6.6" }, "devDependencies": { "@types/react": "^19.3.0", diff --git a/plugins/payments-demo/src/locales/en.json b/plugins/payments-demo/src/locales/en.json index ce517dd737..255cfe1b06 100644 --- a/plugins/payments-demo/src/locales/en.json +++ b/plugins/payments-demo/src/locales/en.json @@ -55,6 +55,10 @@ "title": "Already yours", "desc": "You have already bought this item." }, + "already_paid": { + "title": "Already paid", + "desc": "Your earlier payment for this item went through. It will show as yours in a moment." + }, "payment_in_progress": { "title": "A checkout is still open", "desc": "An earlier checkout for this item is still open. Finish it, or try again in a moment." diff --git a/plugins/payments-demo/src/pages/start-checkout.ts b/plugins/payments-demo/src/pages/start-checkout.ts index 872c98d9f9..57891a9c3f 100644 --- a/plugins/payments-demo/src/pages/start-checkout.ts +++ b/plugins/payments-demo/src/pages/start-checkout.ts @@ -6,6 +6,7 @@ import { manifest } from "@/manifest.gen"; const SERVER_ERRORS = [ "already_owned", + "already_paid", "payment_in_progress", "payment_processing", "provider_not_found", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 1013ddf223..b1503bc4dd 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -39,23 +39,23 @@ importers: specifier: ^6.0.3 version: 6.0.3 zod: - specifier: ^4.6.5 - version: 4.6.5 + specifier: ^4.6.6 + version: 4.6.7 apps/api: dependencies: '@ai-sdk/anthropic': specifier: ^4.0.77 - version: 4.0.77(zod@4.6.5) + version: 4.0.77(zod@4.6.7) '@ai-sdk/google': specifier: ^4.0.92 - version: 4.0.92(zod@4.6.5) + version: 4.0.92(zod@4.6.7) '@hono/zod-openapi': specifier: ^1.6.3 - version: 1.6.3(hono@4.13.13)(zod@4.6.5) + version: 1.6.3(hono@4.13.13)(zod@4.6.7) '@hono/zod-validator': specifier: ^0.9.1 - version: 0.9.1(hono@4.13.13)(zod@4.6.5) + version: 0.9.1(hono@4.13.13)(zod@4.6.7) '@vitnode/core': specifier: workspace:* version: link:../../packages/vitnode @@ -73,7 +73,7 @@ importers: version: 1.0.0-rc.4 drizzle-orm: specifier: 1.0.0-rc.4 - version: 1.0.0-rc.4(@opentelemetry/api@1.9.1)(postgres@3.4.9)(zod@4.6.5) + version: 1.0.0-rc.4(@opentelemetry/api@1.9.1)(postgres@3.4.9)(zod@4.6.7) hono: specifier: ^4.13.13 version: 4.13.13 @@ -93,8 +93,8 @@ importers: specifier: ^8.22.0 version: 8.22.0 zod: - specifier: ^4.6.5 - version: 4.6.5 + specifier: ^4.6.6 + version: 4.6.7 devDependencies: '@hono/node-server': specifier: ^2.1.4 @@ -155,7 +155,7 @@ importers: dependencies: '@hono/zod-openapi': specifier: ^1.6.3 - version: 1.6.3(hono@4.13.13)(zod@4.6.5) + version: 1.6.3(hono@4.13.13)(zod@4.6.7) '@tailwindcss/vite': specifier: ^4.3.3 version: 4.3.3(vite@8.3.4(@types/node@26.6.4)(esbuild@0.28.2)(jiti@2.7.0)(tsx@4.23.15)(yaml@2.9.1)) @@ -212,16 +212,16 @@ importers: version: 1.0.0-rc.4 drizzle-orm: specifier: 1.0.0-rc.4 - version: 1.0.0-rc.4(@opentelemetry/api@1.9.1)(postgres@3.4.9)(zod@4.6.5) + version: 1.0.0-rc.4(@opentelemetry/api@1.9.1)(postgres@3.4.9)(zod@4.6.7) fumadocs-core: specifier: 16.17.1 - version: 16.17.1(@mdx-js/mdx@3.1.1)(@tanstack/react-router@1.170.44(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.53.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) + version: 16.17.1(@mdx-js/mdx@3.1.1)(@tanstack/react-router@1.170.44(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.53.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.7) 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.17.1(@mdx-js/mdx@3.1.1)(@tanstack/react-router@1.170.44(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.53.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.4(@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.17.1(@mdx-js/mdx@3.1.1)(@tanstack/react-router@1.170.44(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.53.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.7))(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.4(@types/node@26.6.4)(esbuild@0.28.2)(jiti@2.7.0)(tsx@4.23.15)(yaml@2.9.1)) fumadocs-ui: specifier: npm:@fumadocs/base-ui@16.17.1 - version: '@fumadocs/base-ui@16.17.1(@date-fns/tz@1.5.0)(@types/mdx@2.0.14)(@types/react@19.3.0)(date-fns@4.4.0)(fumadocs-core@16.17.1(@mdx-js/mdx@3.1.1)(@tanstack/react-router@1.170.44(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.53.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)' + version: '@fumadocs/base-ui@16.17.1(@date-fns/tz@1.5.0)(@types/mdx@2.0.14)(@types/react@19.3.0)(date-fns@4.4.0)(fumadocs-core@16.17.1(@mdx-js/mdx@3.1.1)(@tanstack/react-router@1.170.44(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.53.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.7))(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 @@ -262,8 +262,8 @@ importers: specifier: ^4.14.9 version: 4.14.9(@types/react@19.3.0)(react@19.3.0) zod: - specifier: ^4.6.5 - version: 4.6.5 + specifier: ^4.6.6 + version: 4.6.7 devDependencies: '@tanstack/devtools-vite': specifier: ^0.8.5 @@ -657,7 +657,7 @@ importers: version: 3.0.9(prosemirror-model@1.25.12)(prosemirror-state@1.4.4)(prosemirror-view@1.42.6)(y-protocols@1.0.7(yjs@13.6.33))(yjs@13.6.33) ai: specifier: ^7.0.134 - version: 7.0.134(zod@4.6.5) + version: 7.0.134(zod@4.6.7) class-variance-authority: specifier: ^0.7.1 version: 0.7.1 @@ -719,8 +719,8 @@ importers: specifier: ^6.3.0 version: 6.3.0(@opentelemetry/api@1.9.1) sanitize-html: - specifier: ^2.18.0 - version: 2.18.0 + specifier: ^2.18.1 + version: 2.18.1 semver: specifier: ^7.8.5 version: 7.8.5 @@ -745,10 +745,10 @@ importers: devDependencies: '@hono/zod-openapi': specifier: ^1.6.3 - version: 1.6.3(hono@4.13.13)(zod@4.6.5) + version: 1.6.3(hono@4.13.13)(zod@4.6.7) '@hono/zod-validator': specifier: ^0.9.1 - version: 0.9.1(hono@4.13.13)(zod@4.6.5) + version: 0.9.1(hono@4.13.13)(zod@4.6.7) '@react-email/ui': specifier: ^6.11.1 version: 6.11.1(@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) @@ -802,7 +802,7 @@ importers: version: 1.0.0-rc.4 drizzle-orm: specifier: 1.0.0-rc.4 - version: 1.0.0-rc.4(@opentelemetry/api@1.9.1)(postgres@3.4.9)(zod@4.6.5) + version: 1.0.0-rc.4(@opentelemetry/api@1.9.1)(postgres@3.4.9)(zod@4.6.7) happy-dom: specifier: ^20.14.6 version: 20.14.6 @@ -858,14 +858,14 @@ importers: specifier: ^5.0.3 version: 5.0.3(@opentelemetry/api@1.9.1)(@types/node@26.6.4)(@vitest/coverage-v8@5.0.3)(happy-dom@20.14.6)(jsdom@30.1.2)(vite@8.3.4(@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 + specifier: ^4.6.6 + version: 4.6.7 plugins/blog: dependencies: '@hono/zod-openapi': specifier: ^1.6.3 - version: 1.6.3(hono@4.13.13)(zod@4.6.5) + version: 1.6.3(hono@4.13.13)(zod@4.6.7) '@tanstack/react-form': specifier: ^1.33.5 version: 1.33.5(@tanstack/react-start@1.169.1(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.4(@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) @@ -880,13 +880,13 @@ importers: version: link:../../packages/vitnode ai: specifier: ^7.0.134 - version: 7.0.134(zod@4.6.5) + version: 7.0.134(zod@4.6.7) drizzle-kit: specifier: 1.0.0-rc.4 version: 1.0.0-rc.4 drizzle-orm: specifier: 1.0.0-rc.4 - version: 1.0.0-rc.4(@opentelemetry/api@1.9.1)(postgres@3.4.9)(zod@4.6.5) + version: 1.0.0-rc.4(@opentelemetry/api@1.9.1)(postgres@3.4.9)(zod@4.6.7) hono: specifier: ^4.13.13 version: 4.13.13 @@ -909,8 +909,8 @@ importers: specifier: ^4.14.9 version: 4.14.9(@types/react@19.3.0)(react@19.3.0) zod: - specifier: ^4.6.5 - version: 4.6.5 + specifier: ^4.6.6 + version: 4.6.7 devDependencies: '@react-email/ui': specifier: ^6.11.1 @@ -959,7 +959,7 @@ importers: dependencies: '@hono/zod-openapi': specifier: ^1.6.3 - version: 1.6.3(hono@4.13.13)(zod@4.6.5) + version: 1.6.3(hono@4.13.13)(zod@4.6.7) '@tanstack/react-form': specifier: ^1.33.5 version: 1.33.5(@tanstack/react-start@1.169.1(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.4(@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) @@ -977,7 +977,7 @@ importers: version: 1.0.0-rc.4 drizzle-orm: specifier: 1.0.0-rc.4 - version: 1.0.0-rc.4(@opentelemetry/api@1.9.1)(postgres@3.4.9)(zod@4.6.5) + version: 1.0.0-rc.4(@opentelemetry/api@1.9.1)(postgres@3.4.9)(zod@4.6.7) hono: specifier: ^4.13.13 version: 4.13.13 @@ -997,8 +997,8 @@ importers: specifier: ^4.14.9 version: 4.14.9(@types/react@19.3.0)(react@19.3.0) zod: - specifier: ^4.6.5 - version: 4.6.5 + specifier: ^4.6.6 + version: 4.6.7 devDependencies: '@types/react': specifier: ^19.3.0 @@ -1035,7 +1035,7 @@ importers: dependencies: '@hono/zod-openapi': specifier: ^1.6.3 - version: 1.6.3(hono@4.13.13)(zod@4.6.5) + version: 1.6.3(hono@4.13.13)(zod@4.6.7) '@tanstack/react-form': specifier: ^1.33.5 version: 1.33.5(@tanstack/react-start@1.169.1(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.4(@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) @@ -1053,7 +1053,7 @@ importers: version: 1.0.0-rc.4 drizzle-orm: specifier: 1.0.0-rc.4 - version: 1.0.0-rc.4(@opentelemetry/api@1.9.1)(postgres@3.4.9)(zod@4.6.5) + version: 1.0.0-rc.4(@opentelemetry/api@1.9.1)(postgres@3.4.9)(zod@4.6.7) hono: specifier: ^4.13.13 version: 4.13.13 @@ -1073,8 +1073,8 @@ importers: specifier: ^4.14.9 version: 4.14.9(@types/react@19.3.0)(react@19.3.0) zod: - specifier: ^4.6.5 - version: 4.6.5 + specifier: ^4.6.6 + version: 4.6.7 devDependencies: '@types/react': specifier: ^19.3.0 @@ -1111,7 +1111,7 @@ importers: dependencies: '@hono/zod-openapi': specifier: ^1.6.3 - version: 1.6.3(hono@4.13.13)(zod@4.6.5) + version: 1.6.3(hono@4.13.13)(zod@4.6.7) '@tanstack/react-query': specifier: ^5.104.1 version: 5.104.1(react@19.3.0) @@ -1126,7 +1126,7 @@ importers: version: 1.0.0-rc.4 drizzle-orm: specifier: 1.0.0-rc.4 - version: 1.0.0-rc.4(@opentelemetry/api@1.9.1)(postgres@3.4.9)(zod@4.6.5) + version: 1.0.0-rc.4(@opentelemetry/api@1.9.1)(postgres@3.4.9)(zod@4.6.7) hono: specifier: ^4.13.13 version: 4.13.13 @@ -1146,8 +1146,8 @@ importers: specifier: ^4.14.9 version: 4.14.9(@types/react@19.3.0)(react@19.3.0) zod: - specifier: ^4.6.5 - version: 4.6.5 + specifier: ^4.6.6 + version: 4.6.7 devDependencies: '@types/react': specifier: ^19.3.0 @@ -4137,6 +4137,7 @@ packages: '@smithy/core@3.35.2': resolution: {integrity: sha512-ejTukBuP/Pg3t9N8Ux+0zKsXeHWm7p6W3HbXFOyltKasSxZwCFmvTut7kBdZSCF3m9y1bXXyVu6p2xuMFcsTyQ==} engines: {node: '>=18.0.0'} + deprecated: Deprecated due to a bug in custom logger data redaction. See https://github.com/aws/aws-sdk-js-v3/issues/8393. '@smithy/credential-provider-imds@4.5.2': resolution: {integrity: sha512-A9uSdn72ozbRUSit0eib0TW7nXuNPlaeM0zcGkJ+nE6tFcSDbnmtwoxbTCFBukVQcszDAyvsd7+rTduPTXpygg==} @@ -8489,8 +8490,8 @@ packages: safer-buffer@2.1.2: resolution: {integrity: sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==} - sanitize-html@2.18.0: - resolution: {integrity: sha512-CvY+PV+NBhxe3BnjFI5f//vEDKLULm9OlZArso7gdgVsWvanBuq9PNDx+/2llfL+8XFYa/UNBxvqRBg3TWU2Ug==} + sanitize-html@2.18.1: + resolution: {integrity: sha512-2X5lC8fYla8YaT8g73Nj+HcLWXan5dLyCWLgVvaOmfw7JI78IRsyNtIDytTHdn7hBQbIkCah7Zbmd5jabzeDig==} engines: {node: '>=22.12.0'} saxes@6.0.0: @@ -9403,38 +9404,41 @@ packages: zod@4.6.5: resolution: {integrity: sha512-v5l/aFXZQeai4awLbOpSoHecE9UiMrnfx75tEXLjNonXVARxQ5mOeipTjROUchszUNCqnE+hqAMujRsRHsut2Q==} + zod@4.6.7: + resolution: {integrity: sha512-g8W2D2kU6a8untc053fX1uVBKzM7yqAxW/3zwk83+6RyF2+SKOuV+c0wBDhUwzV1PaGapmZH+rmcUzPf+3CGwg==} + zwitch@2.0.4: resolution: {integrity: sha512-bXE4cR/kVZhKZX/RjPEflHaKVhUVl85noU3v6b8apfQEc1x4A+zBxjZ4lN8LqGd6WZ3dl98pY4o717VFmoPp+A==} snapshots: - '@ai-sdk/anthropic@4.0.77(zod@4.6.5)': + '@ai-sdk/anthropic@4.0.77(zod@4.6.7)': dependencies: '@ai-sdk/provider': 4.0.25 - '@ai-sdk/provider-utils': 5.0.57(zod@4.6.5) - zod: 4.6.5 + '@ai-sdk/provider-utils': 5.0.57(zod@4.6.7) + zod: 4.6.7 - '@ai-sdk/gateway@4.0.108(zod@4.6.5)': + '@ai-sdk/gateway@4.0.108(zod@4.6.7)': dependencies: '@ai-sdk/provider': 4.0.25 - '@ai-sdk/provider-utils': 5.0.57(zod@4.6.5) + '@ai-sdk/provider-utils': 5.0.57(zod@4.6.7) '@vercel/oidc': 3.2.0 - zod: 4.6.5 + zod: 4.6.7 - '@ai-sdk/google@4.0.92(zod@4.6.5)': + '@ai-sdk/google@4.0.92(zod@4.6.7)': dependencies: '@ai-sdk/provider': 4.0.25 - '@ai-sdk/provider-utils': 5.0.57(zod@4.6.5) - zod: 4.6.5 + '@ai-sdk/provider-utils': 5.0.57(zod@4.6.7) + zod: 4.6.7 - '@ai-sdk/provider-utils@5.0.57(zod@4.6.5)': + '@ai-sdk/provider-utils@5.0.57(zod@4.6.7)': dependencies: '@ai-sdk/provider': 4.0.25 '@standard-schema/spec': 1.1.0 '@workflow/serde': 4.1.0 eventsource-parser: 3.1.1 undici: 7.30.0 - zod: 4.6.5 + zod: 4.6.7 '@ai-sdk/provider@4.0.25': dependencies: @@ -9455,10 +9459,10 @@ snapshots: is-potential-custom-element-name: 1.0.1 lru-cache: 11.5.3 - '@asteasolutions/zod-to-openapi@9.1.0(zod@4.6.5)': + '@asteasolutions/zod-to-openapi@9.1.0(zod@4.6.7)': dependencies: openapi3-ts: 4.6.1 - zod: 4.6.5 + zod: 4.6.7 '@astrojs/compiler@4.0.0': {} @@ -10348,14 +10352,14 @@ snapshots: optionalDependencies: '@types/react': 19.3.0 - '@fumadocs/base-ui@16.17.1(@date-fns/tz@1.5.0)(@types/mdx@2.0.14)(@types/react@19.3.0)(date-fns@4.4.0)(fumadocs-core@16.17.1(@mdx-js/mdx@3.1.1)(@tanstack/react-router@1.170.44(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.53.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)': + '@fumadocs/base-ui@16.17.1(@date-fns/tz@1.5.0)(@types/mdx@2.0.14)(@types/react@19.3.0)(date-fns@4.4.0)(fumadocs-core@16.17.1(@mdx-js/mdx@3.1.1)(@tanstack/react-router@1.170.44(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.53.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.7))(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: '@base-ui/react': 1.8.0(@date-fns/tz@1.5.0)(@types/react@19.3.0)(date-fns@4.4.0)(react-dom@19.3.0(react@19.3.0))(react@19.3.0) '@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.3(tailwindcss@4.3.3) class-variance-authority: 0.7.1 cn: 0.4.0 - fumadocs-core: 16.17.1(@mdx-js/mdx@3.1.1)(@tanstack/react-router@1.170.44(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.53.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-core: 16.17.1(@mdx-js/mdx@3.1.1)(@tanstack/react-router@1.170.44(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.53.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.7) hast-util-to-jsx-runtime: 2.3.6 lucide-react: 1.53.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) @@ -10391,18 +10395,18 @@ snapshots: dependencies: hono: 4.13.13 - '@hono/zod-openapi@1.6.3(hono@4.13.13)(zod@4.6.5)': + '@hono/zod-openapi@1.6.3(hono@4.13.13)(zod@4.6.7)': dependencies: - '@asteasolutions/zod-to-openapi': 9.1.0(zod@4.6.5) - '@hono/zod-validator': 0.9.1(hono@4.13.13)(zod@4.6.5) + '@asteasolutions/zod-to-openapi': 9.1.0(zod@4.6.7) + '@hono/zod-validator': 0.9.1(hono@4.13.13)(zod@4.6.7) hono: 4.13.13 openapi3-ts: 4.6.1 - zod: 4.6.5 + zod: 4.6.7 - '@hono/zod-validator@0.9.1(hono@4.13.13)(zod@4.6.5)': + '@hono/zod-validator@0.9.1(hono@4.13.13)(zod@4.6.7)': dependencies: hono: 4.13.13 - zod: 4.6.5 + zod: 4.6.7 '@humanfs/core@0.19.2': dependencies: @@ -12386,7 +12390,7 @@ snapshots: jiti: 2.7.0 magic-string: 0.30.21 prettier: 3.9.9 - zod: 4.6.5 + zod: 4.6.7 transitivePeerDependencies: - supports-color @@ -12400,7 +12404,7 @@ snapshots: '@tanstack/router-utils': 1.162.3 chokidar: 5.0.0 unplugin: 3.4.0(esbuild@0.28.2)(rolldown@1.2.13)(rollup@4.63.5)(vite@8.3.4(@types/node@26.6.4)(esbuild@0.28.2)(jiti@2.7.0)(tsx@4.23.15)(yaml@2.9.1)) - zod: 4.6.5 + zod: 4.6.7 optionalDependencies: '@tanstack/react-router': 1.170.44(react-dom@19.3.0(react@19.3.0))(react@19.3.0) vite: 8.3.4(@types/node@26.6.4)(esbuild@0.28.2)(jiti@2.7.0)(tsx@4.23.15)(yaml@2.9.1) @@ -12463,7 +12467,7 @@ snapshots: ufo: 1.6.4 vitefu: 1.1.3(vite@8.3.4(@types/node@26.6.4)(esbuild@0.28.2)(jiti@2.7.0)(tsx@4.23.15)(yaml@2.9.1)) xmlbuilder2: 4.0.3 - zod: 4.6.5 + zod: 4.6.7 optionalDependencies: vite: 8.3.4(@types/node@26.6.4)(esbuild@0.28.2)(jiti@2.7.0)(tsx@4.23.15)(yaml@2.9.1) transitivePeerDependencies: @@ -13195,12 +13199,12 @@ snapshots: prompts: 2.4.2 yaml: 2.9.1 - ai@7.0.134(zod@4.6.5): + ai@7.0.134(zod@4.6.7): dependencies: - '@ai-sdk/gateway': 4.0.108(zod@4.6.5) + '@ai-sdk/gateway': 4.0.108(zod@4.6.7) '@ai-sdk/provider': 4.0.25 - '@ai-sdk/provider-utils': 5.0.57(zod@4.6.5) - zod: 4.6.5 + '@ai-sdk/provider-utils': 5.0.57(zod@4.6.7) + zod: 4.6.7 ajv-formats@2.1.1(ajv@8.20.0): optionalDependencies: @@ -13806,11 +13810,11 @@ snapshots: get-tsconfig: 4.14.3 jiti: 2.7.0 - drizzle-orm@1.0.0-rc.4(@opentelemetry/api@1.9.1)(postgres@3.4.9)(zod@4.6.5): + drizzle-orm@1.0.0-rc.4(@opentelemetry/api@1.9.1)(postgres@3.4.9)(zod@4.6.7): optionalDependencies: '@opentelemetry/api': 1.9.1 postgres: 3.4.9 - zod: 4.6.5 + zod: 4.6.7 dts-resolver@3.0.0(oxc-resolver@11.24.2): optionalDependencies: @@ -14025,8 +14029,8 @@ snapshots: '@babel/parser': 7.29.9 eslint: 10.11.0(jiti@2.7.0) hermes-parser: 0.25.1 - zod: 4.6.5 - zod-validation-error: 4.0.2(zod@4.6.5) + zod: 4.6.7 + zod-validation-error: 4.0.2(zod@4.6.7) transitivePeerDependencies: - supports-color @@ -14414,7 +14418,7 @@ snapshots: fsevents@2.3.3: optional: true - fumadocs-core@16.17.1(@mdx-js/mdx@3.1.1)(@tanstack/react-router@1.170.44(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.53.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-core@16.17.1(@mdx-js/mdx@3.1.1)(@tanstack/react-router@1.170.44(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.53.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.7): dependencies: '@fumari/image-size': 0.1.1 estree-util-value-to-estree: 3.5.0 @@ -14447,18 +14451,18 @@ snapshots: 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 + zod: 4.6.7 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.17.1(@mdx-js/mdx@3.1.1)(@tanstack/react-router@1.170.44(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.53.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.4(@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.17.1(@mdx-js/mdx@3.1.1)(@tanstack/react-router@1.170.44(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.53.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.7))(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.4(@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.17.1(@mdx-js/mdx@3.1.1)(@tanstack/react-router@1.170.44(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.53.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-core: 16.17.1(@mdx-js/mdx@3.1.1)(@tanstack/react-router@1.170.44(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.53.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.7) github-slugger: 2.0.0 magic-string: 1.4.3 mdast-util-mdx: 3.0.0 @@ -14472,7 +14476,7 @@ snapshots: vfile: 6.0.3 yaml: 2.9.1 yuku-analyzer: 0.10.2 - zod: 4.6.5 + zod: 4.6.7 optionalDependencies: '@types/mdast': 4.0.4 '@types/mdx': 2.0.14 @@ -16612,9 +16616,8 @@ snapshots: safer-buffer@2.1.2: {} - sanitize-html@2.18.0: + sanitize-html@2.18.1: dependencies: - deepmerge: 4.3.1 escape-string-regexp: 4.0.0 htmlparser2: 12.0.0 is-plain-object: 5.1.0 @@ -17479,12 +17482,14 @@ snapshots: dependencies: zod: 3.25.76 - zod-validation-error@4.0.2(zod@4.6.5): + zod-validation-error@4.0.2(zod@4.6.7): dependencies: - zod: 4.6.5 + zod: 4.6.7 zod@3.25.76: {} zod@4.6.5: {} + zod@4.6.7: {} + zwitch@2.0.4: {}