Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion apps/api/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
1 change: 1 addition & 0 deletions apps/web/content/docs/content-engine/public-lists-api.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -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` |
2 changes: 1 addition & 1 deletion apps/web/content/docs/dev/advanced/rate-limiter.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
7 changes: 5 additions & 2 deletions apps/web/content/docs/dev/ai/costs-and-limits.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -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).
Expand Down
2 changes: 1 addition & 1 deletion apps/web/content/docs/dev/ai/errors.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand Down
3 changes: 1 addition & 2 deletions apps/web/content/docs/dev/ai/streaming.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
2 changes: 2 additions & 0 deletions apps/web/content/docs/dev/passkeys/using-passkeys.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -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.
</Callout>

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
Expand Down
2 changes: 1 addition & 1 deletion apps/web/content/docs/dev/payments/plugin.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
2 changes: 1 addition & 1 deletion apps/web/content/docs/dev/sso/custom-adapter.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -234,7 +234,7 @@ No `authorization` block yet? Add it with just `ssoAdapters`, as in [SSO](/docs/
type: "(code: string, context: SSOCallbackContext) => Promise<SSOTokenResponse>",
},
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<SSOProviderUser>",
},
Expand Down
2 changes: 1 addition & 1 deletion apps/web/content/docs/dev/sso/email-confirmation.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
2 changes: 1 addition & 1 deletion apps/web/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
4 changes: 2 additions & 2 deletions packages/vitnode/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -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",
Expand Down
8 changes: 5 additions & 3 deletions packages/vitnode/src/api/adapters/search/postgres.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<SQL> => {
const [boundary] = await c
.get("db")
.select({ createdAt: sql<string>`${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
Expand Down Expand Up @@ -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;
}
}
Expand Down
4 changes: 3 additions & 1 deletion packages/vitnode/src/api/lib/ai/budget.ts
Original file line number Diff line number Diff line change
Expand Up @@ -126,20 +126,22 @@ export const deliveredCostUsd = (
};

export const settlementCharge = ({
canceled = false,
chargedUsd,
delivered,
deliveredUsd,
outputSent = false,
unit,
}: {
canceled?: boolean;
chargedUsd: Decimal;
delivered: boolean;
deliveredUsd: Decimal;
outputSent?: boolean;
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);
}
Expand Down
1 change: 1 addition & 0 deletions packages/vitnode/src/api/lib/ai/memory-ledger.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
5 changes: 4 additions & 1 deletion packages/vitnode/src/api/lib/ai/periods.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
1 change: 1 addition & 0 deletions packages/vitnode/src/api/lib/ai/postgres-ledger.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
11 changes: 4 additions & 7 deletions packages/vitnode/src/api/lib/check-staff-permission.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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);
});
Expand All @@ -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);
});
Expand Down Expand Up @@ -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"])]);
});
});
12 changes: 9 additions & 3 deletions packages/vitnode/src/api/lib/check-staff-permission.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,10 +23,11 @@ import {
import {
readRequestStaffPermissions,
readStaffPermissions,
readStaffPermissionsGeneration,
readStaffPermissionsOfUser,
rememberRequestStaffPermissions,
STAFF_TYPES,
writeStaffPermissions,
writeStaffPermissionsInGeneration,
} from "./staff-permission-cache";

const tableByType = {
Expand Down Expand Up @@ -140,9 +141,11 @@ const loadAndCacheStaffPermissions = async (
c: Context,
args: { type: PermissionStaffType; user: StaffUser },
): Promise<ResolvedStaffPermissionSet> => {
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,
);
Expand Down Expand Up @@ -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;

Expand All @@ -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;
Expand Down
14 changes: 14 additions & 0 deletions packages/vitnode/src/api/lib/moderation/snapshot.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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");

Expand Down Expand Up @@ -51,6 +64,7 @@ export const sealReportSnapshot = ({
target,
title,
truncated,
url: safeReportTargetUrl(capture.url),
version: MODERATION_SNAPSHOT_VERSION,
};

Expand Down
13 changes: 12 additions & 1 deletion packages/vitnode/src/api/lib/notifications/digest-period.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down
5 changes: 4 additions & 1 deletion packages/vitnode/src/api/lib/notifications/safe.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
Expand Down
5 changes: 5 additions & 0 deletions packages/vitnode/src/api/lib/payments/errors.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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. */
Expand Down
Loading
Loading