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/web/content/docs/content-engine/public-api.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -124,7 +124,7 @@ All routes live under `/api/{pluginId}/content/{publicApi.path}`.
| `GET /thread?root={id}` | Nested replies under a record. With [`thread`](/docs/content-engine/threads) |
| `POST /`, `PATCH /{id}` | Create and edit from the site. With [`publicWrite`](/docs/content-engine/public-write) |

Each record comes back with exactly the listed fields. Dates are ISO strings, a relation is `{ id }`, a container field is the container's id, and a file is `{ id, name, url, mimeType, size, width, height }`. A draft, a record scheduled for later, a hidden record and a typo all answer the same `404`. The list adds the hidden records a signed-in author, editor or moderator may see, marked `hidden: true`.
Each record comes back with exactly the listed fields. Dates are ISO strings, a relation is `{ id }`, a container field is the container's id, and a file is `{ id, name, url, mimeType, size, width, height }`. A draft, a record scheduled for later, a hidden record and a typo all answer the same `404`. A record that belongs to a parent - its thread root, or its search context relation - is only as public as that parent: once the topic is hidden or unpublished, its comments drop out of the list and their detail answers `404` too. The list adds the hidden records a signed-in author, editor or moderator may see, marked `hidden: true`.

The list accepts `first` (up to 50), `page` or `cursor`, `search`, `orderBy`, `order` and one parameter per filterable field. [Public lists](/docs/content-engine/public-lists) turns it into a page with search, filters and pagination.

Expand Down
2 changes: 1 addition & 1 deletion apps/web/content/docs/dev/ai/actions.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -492,7 +492,7 @@ handler: async (c) => {
}
```

The stream also returns `result`, a promise that resolves to the validated output once the stream ends, and rejects with an `AiError` if the full text is invalid. A stream makes one attempt on the primary model, with no retries and no fallback. If the reader cancels, the run settles as `canceled` and the user is not charged.
The stream also returns `result`, a promise that resolves to the validated output once the stream ends, and rejects with an `AiError` if the full text is invalid. A stream makes one attempt on the primary model, with no retries and no fallback. If the reader cancels, the run settles as `canceled`. The user is charged nothing if no text had reached them yet; once text was sent, their points and daily count are charged as for a delivered run.

## Error codes

Expand Down
5 changes: 3 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,11 @@ 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 | Full reservation (unknown) | 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 |
| Process died mid-call (lease expired) | Full reservation (unknown) | Nothing |

**A user is charged only when a valid result is delivered.** 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.
**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/fields-and-editor.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -241,7 +241,7 @@ Quick Ask is the **Quick Ask (AI)** button in the Tiptap rich text editor toolba
How it behaves:

- **Bounded context.** It sends the selection (up to 8,000 characters) and up to 1,500 characters before and after it. It never sends the whole document.
- **Streamed and previewed.** The answer streams into a preview. **Stop** cancels it; a canceled run charges the member no points.
- **Streamed and previewed.** The answer streams into a preview. **Stop** cancels it; stopping before any text arrives charges the member nothing, while stopping after text has streamed in counts as a run.
- **Cost shown first.** With a selection, the panel shows the reservation bound, not a price: _"One request costs at most N AI points - usually much less."_
- **Plain text only.** The answer becomes paragraphs and hard breaks. It is never parsed as HTML, so it cannot inject markup, links or scripts.
- **Stale selection.** If the selected text changed while the answer was written, **Replace selection** is disabled; **Insert below** still works.
Expand Down
4 changes: 3 additions & 1 deletion apps/web/content/docs/dev/fetcher.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -284,7 +284,9 @@ via `configureInProcessApi` from
request straight to it - no HTTP round trip, no trip through your proxy or
CDN, same cookies, headers and signed visitor IP. Set `VITNODE_API_URL` to a
different origin, or pass `origin` on a call, and the request goes over the
network as before.
network as before. Without `VITNODE_API_URL` and without an in-process API,
that network call goes to `VITNODE_WEB_URL` - never to the incoming request's
`Host`, which a visitor controls.

## Browser-only requests with fetcherClient

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 @@ -141,7 +141,7 @@ A **GitHub** login button automatically renders on `/login` and `/register`, and
type: "(code: string) => Promise<{ access_token: string; token_type: string }>",
},
fetchUser: {
description: "Fetches the user profile: id, email, username, and optionally avatarUrl, firstName and lastName. Return only an email the provider has verified - VitNode treats it as confirmed and will mark a matching unconfirmed account verified. The built-in adapters reject unverified addresses with a 400.",
description: "Fetches the user profile: id, email, username, and optionally emailVerified, avatarUrl, firstName and lastName. VitNode keys accounts on the email, so set emailVerified: false (or throw) for an address the provider has not confirmed - VitNode then refuses the sign-in with a 400 instead of creating, linking or verifying an account with it. Set it to true when the provider confirms the address. The built-in adapters only ever return confirmed addresses.",
required: true,
type: "(token: TokenResponse) => Promise<SSOProviderUser>",
},
Expand Down
1 change: 1 addition & 0 deletions apps/web/content/docs/dev/websocket/index.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -213,6 +213,7 @@ Without Redis, all of this works on a single instance with no extra setup: `toRo
- **Offline aware**: When the browser goes offline, the socket closes and stops retrying. It reconnects the moment the network comes back, or when a tab becomes visible again after sleep, without waiting for the backoff.
- **Heartbeat, both ways**: The server pings every socket every 30 seconds and closes the ones that stopped answering, so vanished clients do not linger (the standalone API's `ws` server and Nitro's crossws both do). The browser sends its own ping every 25 seconds and reconnects when no reply arrives within 10 seconds, so a half-dead connection after a Wi-Fi drop never pretends to be online.
- **No replay**: Messages sent while a socket is down are not queued. A feature that must not miss updates reloads its state when the socket opens again - see [Reconnects and missed messages](/docs/dev/websocket/reconnects).
- **Closed on revoke**: A socket is tied to the session and device that opened it. Revoking sessions (signing out a device, changing or resetting a password, an admin revoke) closes those sockets on every instance through `closeUserConnections`, so a revoked device stops receiving the user's pushes.
- **Multiple Instances**: With `REDIS_URL` set, `sendToUser`, `broadcast` and `toRoom` are relayed to every API instance over Redis pub/sub. Without Redis they only reach clients connected to the same instance.

## Learn More
Expand Down
2 changes: 1 addition & 1 deletion apps/web/content/docs/dev/working-with-users/users.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -277,7 +277,7 @@ A few rules differ from the member's own settings:
- **Setting a password signs the member out everywhere**, AdminCP sessions included.
- **Sync now isn't offered.** It needs the member to sign in with the provider themselves.

Every write needs `users:can_edit`, plus `users:can_edit_admin` when the target is staff. Reads need `users:can_view`.
Every write needs `users:can_edit`, plus `users:can_edit_admin` when the target is staff. A staff target must also sit within your own access: you need every admin and moderator permission they hold, and only a root administrator can edit a root or unrestricted account. So nobody can reset the password of, or demote, someone above them. Reads need `users:can_view`.

| Method | Path | Does |
| :------- | :------------------------------------------------------------------ | :--------------------------------------------------- |
Expand Down
1 change: 1 addition & 0 deletions packages/vitnode/src/api/adapters/sso/discord.ts
Original file line number Diff line number Diff line change
Expand Up @@ -143,6 +143,7 @@ export const DiscordSSOApiPlugin = ({
return {
avatarUrl: discordAvatarUrl(data),
email: data.email,
emailVerified: true,
id: data.id,
username: data.username,
};
Expand Down
1 change: 1 addition & 0 deletions packages/vitnode/src/api/adapters/sso/facebook.ts
Original file line number Diff line number Diff line change
Expand Up @@ -145,6 +145,7 @@ export const FacebookSSOApiPlugin = ({
avatarUrl:
picture?.is_silhouette === false ? (picture.url ?? null) : null,
email: userData.email,
emailVerified: true,
firstName: userData.first_name ?? null,
id: userData.id,
lastName: userData.last_name ?? null,
Expand Down
1 change: 1 addition & 0 deletions packages/vitnode/src/api/adapters/sso/google.ts
Original file line number Diff line number Diff line change
Expand Up @@ -130,6 +130,7 @@ export const GoogleSSOApiPlugin = ({
return {
avatarUrl: data.picture ?? null,
email: data.email,
emailVerified: true,
firstName: data.given_name ?? null,
id: data.id,
lastName: data.family_name ?? null,
Expand Down
3 changes: 3 additions & 0 deletions packages/vitnode/src/api/adapters/sso/profile.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,7 @@ describe("the Google adapter", () => {
expect(await google.fetchUser(TOKEN)).toEqual({
avatarUrl: "https://lh3.googleusercontent.com/a/photo=s96-c",
email: "alice@gmail.test",
emailVerified: true,
firstName: "Alice",
id: "123",
lastName: "Smith",
Expand Down Expand Up @@ -80,6 +81,7 @@ describe("the Discord adapter", () => {
avatarUrl:
"https://cdn.discordapp.com/avatars/80351110224678912/a_1269e74af4df7417b13759eae50c83dc.png?size=512",
email: "alice@discord.test",
emailVerified: true,
id: "80351110224678912",
username: "alice",
});
Expand Down Expand Up @@ -121,6 +123,7 @@ describe("the Facebook adapter", () => {
expect(await facebook.fetchUser(TOKEN)).toEqual({
avatarUrl: "https://platform-lookaside.fbsbx.com/photo.jpg",
email: "alice@facebook.test",
emailVerified: true,
firstName: "Alice",
id: "10",
lastName: "Smith",
Expand Down
8 changes: 6 additions & 2 deletions packages/vitnode/src/api/lib/ai/budget.ts
Original file line number Diff line number Diff line change
Expand Up @@ -129,16 +129,20 @@ export const settlementCharge = ({
chargedUsd,
delivered,
deliveredUsd,
outputSent = false,
unit,
}: {
chargedUsd: Decimal;
delivered: boolean;
deliveredUsd: Decimal;
outputSent?: boolean;
unit: AiBudgetUnit;
}): Decimal => {
if (unit === "usd") return chargedUsd;
if (!delivered) return 0n;
if (unit === "points") return usdToPoints(deliveredUsd);
if (!delivered && !outputSent) return 0n;
if (unit === "points") {
return usdToPoints(delivered ? deliveredUsd : chargedUsd);
}

return decimalFromInteger(1);
};
1 change: 1 addition & 0 deletions packages/vitnode/src/api/lib/ai/ledger.ts
Original file line number Diff line number Diff line change
Expand Up @@ -107,6 +107,7 @@ export interface AiSettlement {
delivered: boolean;
errorCode: null | string;
finishedAt: Date;
outputSent?: boolean;
status: "canceled" | "failed" | "succeeded" | "uncertain";
}

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 @@ -315,6 +315,7 @@ export class MemoryAiLedger implements AiLedger {
chargedUsd,
deliveredUsd,
delivered: settlement.delivered,
outputSent: settlement.outputSent,
unit: hold.unit,
});
if (hold.unit === "points") chargedPoints = charge;
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 @@ -603,6 +603,7 @@ export class PostgresAiLedger implements AiLedger {
chargedUsd,
deliveredUsd,
delivered: settlement.delivered,
outputSent: settlement.outputSent,
unit: hold.unit,
});
if (hold.unit === "points") chargedPoints = charge;
Expand Down
51 changes: 30 additions & 21 deletions packages/vitnode/src/api/lib/ai/runner.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -678,25 +678,34 @@ describe("AiRunner.stream", () => {
expect(ledger.runs[0].status).toBe("succeeded");
});

it("charges no points when the reader cancels, and still records the run", async () => {
const { ledger, runner } = setup({
models: [
textModel(streaming(["Hello", " there", " friend"]), {
capabilities: ["text", "streaming"],
}),
],
});

const { result, textStream } = await runner.stream({
action: SUMMARY,
input: { text: "x" },
});
const reader = textStream.getReader();
await reader.read();
await reader.cancel("user closed the panel");

expect(await codeOf(result)).toBe("AI_CANCELED");
expect(ledger.runs[0]).toMatchObject({ settled: true, status: "canceled" });
expect(ledger.spentPoints(7)).toBe(0n);
});
it.each([
["before any text arrived", 0, false],
["after text reached the reader", 1, true],
])(
"records a run the reader cancels %s, charging points only for text it got",
async (_label, reads, charged) => {
const { ledger, runner } = setup({
models: [
textModel(streaming(["Hello", " there", " friend"]), {
capabilities: ["text", "streaming"],
}),
],
});

const { result, textStream } = await runner.stream({
action: SUMMARY,
input: { text: "x" },
});
const reader = textStream.getReader();
for (let index = 0; index < reads; index++) await reader.read();
await reader.cancel("user closed the panel");

expect(await codeOf(result)).toBe("AI_CANCELED");
expect(ledger.runs[0]).toMatchObject({
settled: true,
status: "canceled",
});
expect(ledger.spentPoints(7) > 0n).toBe(charged);
},
);
});
2 changes: 2 additions & 0 deletions packages/vitnode/src/api/lib/ai/runner.ts
Original file line number Diff line number Diff line change
Expand Up @@ -779,6 +779,7 @@ export class AiRunner {
) => {
if (finished) return;
finished = true;
const outputSent = !delivered && text.length > 0;
signal?.removeEventListener("abort", forward);
if (callId !== null) {
await this.ledger.finishCall(callId, {
Expand All @@ -790,6 +791,7 @@ export class AiRunner {
const settled = await this.settle(runId, {
delivered,
errorCode,
outputSent,
status: delivered
? "succeeded"
: errorCode === "AI_CANCELED"
Expand Down
24 changes: 24 additions & 0 deletions packages/vitnode/src/api/lib/recovery-tokens.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
import type { Context } from "hono";

import { eq } from "drizzle-orm";

import {
core_users_confirm_emails,
core_users_forgot_password,
} from "@/database/users";

export const revokeAccountRecoveryTokens = async (
c: Context,
userId: number,
): Promise<void> => {
const db = c.get("db");

await Promise.all([
db
.delete(core_users_forgot_password)
.where(eq(core_users_forgot_password.userId, userId)),
db
.delete(core_users_confirm_emails)
.where(eq(core_users_confirm_emails.userId, userId)),
]);
};
2 changes: 2 additions & 0 deletions packages/vitnode/src/api/models/session-revoke.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import { and, eq } from "drizzle-orm";

import { core_admin_sessions } from "@/database/admins";
import { core_sessions } from "@/database/sessions";
import { realtime } from "@/ws/registry";

import { adminSessionCacheKey, sessionCacheKey } from "./session-cache";

Expand Down Expand Up @@ -116,6 +117,7 @@ export const revokeSessions = async (
]);

await deleteSessionCacheKeys(c, { adminSessions, sessions });
realtime.closeUserConnections(scope.userId, scope.deviceId ?? null);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Close sockets on the normal sign-out path

This close notification runs only through revokeSessions, but DELETE /users/sign_out still calls SessionModel.deleteSession(), which deletes the current session directly and never reaches this hook. If the browser has an open socket when the user signs out, that socket therefore remains indexed as the authenticated user and can continue receiving private sendToUser or room messages until it disconnects independently. Route ordinary sign-out through the same revocation/close mechanism or explicitly close that device's sockets there.

Useful? React with 👍 / 👎.

await c.get("events").emit("user.sessions.revoked", {
deviceId: scope.deviceId ?? null,
sessions: sessions.length + adminSessions.length,
Expand Down
2 changes: 1 addition & 1 deletion packages/vitnode/src/api/models/sso-connection.ts
Original file line number Diff line number Diff line change
Expand Up @@ -811,7 +811,7 @@ export const ssoIdentityOf = (
adapter: Pick<SSOApiPlugin, "profileFields">,
user: Awaited<ReturnType<SSOApiPlugin["fetchUser"]>>,
): SsoProviderIdentity => {
const email = boundedText(user.email);
const email = user.emailVerified === false ? null : boundedText(user.email);

return {
email: email ? normalizeEmailAddress(email) : null,
Expand Down
16 changes: 15 additions & 1 deletion packages/vitnode/src/api/models/sso.ts
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,13 @@ export interface SSOProviderUser {
avatarUrl?: null | string;
/** The account's email. Matched against existing VitNode accounts on first sign-in. */
email: string;
/**
* Whether the provider confirmed the user owns `email`. Core keys accounts on
* the email, so `false` refuses the sign-in (`400`) instead of creating,
* linking or verifying an account with it. Leave it unset only when the
* provider never returns an unconfirmed address.
*/
emailVerified?: boolean;
firstName?: null | string;
/** The provider's stable account id, stored to recognise the account next time. */
id: string;
Expand All @@ -64,7 +71,11 @@ export interface SSOApiPlugin {
fetchToken: (
code: string,
) => Promise<{ access_token: string; token_type: string }>;
/** Reads the provider account with the token. Throw an `HTTPException` (e.g. `400` for an unverified email) to refuse it. */
/**
* Reads the provider account with the token. Report an address the provider
* has not confirmed with `emailVerified: false` (or throw an `HTTPException`
* yourself) - an unconfirmed email must never reach core as a trusted one.
*/
fetchUser: (args: {
access_token: string;
token_type: string;
Expand Down Expand Up @@ -174,6 +185,9 @@ export class SSOModel {

const ssoToken = await provider.fetchToken(code);
const userFromProvider = await provider.fetchUser(ssoToken);
if (userFromProvider.emailVerified === false) {
throw new HTTPException(400, { message: "Email not verified" });
}
const userFromSSO = {
email: normalizeEmailAddress(userFromProvider.email),
id: userFromProvider.id,
Expand Down
Loading
Loading