diff --git a/apps/api/package.json b/apps/api/package.json index 3f432523d..28b901514 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 739f6e427..8afa4a320 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 6795c00aa..13c71b7de 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 ee385a261..bee02329a 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 59cabd724..5fec67457 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 1036cd73a..49144345d 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 b751fe2a5..20b19622b 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 8b61804c9..66b02ceec 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 a2bf5346f..dd0c820a8 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 af6b9bb2e..50841192e 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 737e205f4..8521cddba 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 3d6bc884a..dc6a5be72 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 b238f1737..03cbd27f6 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 fd13cb3b1..d2dba7165 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 62291e76e..fe0e29ce7 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 b1209e113..842f2b525 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 f7f4ebdf8..899a17b1b 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 fffd7ba62..455a225af 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 8121c7692..f5cf5de1d 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 98f80ed54..a03ccf12b 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 8c363b187..30f03f55f 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 38246a5f2..76d930e7f 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 a79c9f77d..219b3b8e7 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 1c9940062..46ce7844c 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 816789aed..8da2235ac 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 77cc4cd3a..bde05c0e4 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 c13811d2d..8628174ce 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 3a040f10c..50167a977 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 a7edd0c20..757813d1d 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 916b43c48..32dd85b1d 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 62d623195..c3e4c1fde 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 148979b3b..fed0a433e 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 82c0e9b92..992878a66 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 dc1cde69e..3098c6f79 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 ea9ce6472..9a3d17a59 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 7d3659ae1..6c8617926 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 6f1ee2963..8a72bf41d 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 a2f8913de..90f1bbafc 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 b31ec09e4..38b330715 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 76efeb4ae..dca2fe97f 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 ebf9236d0..6992cd922 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 fcc4962b9..a5b08f016 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 29715cb06..23227852a 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 5c7fe2691..6ba73482e 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 653e96b4d..db0c63f8f 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 fb00b5a6d..0c790fe01 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 62d4c41e1..a0e0eb9b0 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 79cca610d..42f442545 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 18d756893..8baaeb0ba 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 044627b16..3ee4650cc 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 77e172413..4c6f1d8fe 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 d08587b9d..dc71f49d9 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 622622e6b..ca94a4d04 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 dee8e599b..9924a54f2 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 beb02d54d..6e34eb874 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 b970f99c4..9ebced03a 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 d98dd42bb..b00624c77 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 c8132d353..d3e807e0e 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 aa0804e2a..d25817c53 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 5c2cbffab..1faecd17a 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 49fb12c99..372d363c2 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 80f70fb28..543803b9f 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 6a6d93ccb..f6800c61e 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 d227c43d5..f476a3fb8 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 eec3fbba0..359139d2d 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 1ac485f53..56e1e6c36 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 00a0feca0..4ac62ccc8 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 327f6042e..93374952b 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 e3559b54f..93a4896a1 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 8d70ec8dd..30db9d637 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 59f1f6e76..b7676a6b4 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 98bf99be3..1d4bc811b 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 1b76895e3..154fa7129 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 c25435076..276f70b9f 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 d5d0e1f17..4f6387d8c 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 d8670ed76..b3690cdb2 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 a253ee751..c7e0d0299 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 a3c1cd5ab..8af4537b7 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 973dfa089..9046ab0a2 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 bbbd93332..9ff3faa66 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 06d0360cf..614bc5b90 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 f21a33f97..ffb221f0c 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 a026e97e0..cdaf6aa1c 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 31ab7305f..4d7a5ea12 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 e84daf25c..f2acb2c68 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 4048476d6..9c9dea493 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 82e39cb3a..7de3426a2 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 9b8079c94..dbe8bb6ab 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 9ee59a5d7..a97bc6d11 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 57affd854..065a2941d 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 627a501de..922549f70 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 701d366b2..2a82248bf 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 e7d5fe175..0ca1f7e7e 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 951b71c30..5412d27ab 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 643be3a24..c55066cc6 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 62727e9fa..72b4958c9 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 641f4e506..541dda63c 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 686d0726a..591350914 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 defbaf007..7b0960d96 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 078552edf..92003de21 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 ce517dd73..255cfe1b0 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 872c98d9f..57891a9c3 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 1013ddf22..b1503bc4d 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: {}