diff --git a/apps/web/content/docs/content-engine/public-api.mdx b/apps/web/content/docs/content-engine/public-api.mdx index 1bf95a2f2..812cc1562 100644 --- a/apps/web/content/docs/content-engine/public-api.mdx +++ b/apps/web/content/docs/content-engine/public-api.mdx @@ -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. diff --git a/apps/web/content/docs/dev/ai/actions.mdx b/apps/web/content/docs/dev/ai/actions.mdx index 1d6bea60e..f501416f7 100644 --- a/apps/web/content/docs/dev/ai/actions.mdx +++ b/apps/web/content/docs/dev/ai/actions.mdx @@ -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 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 2cc2b02e0..f7a75e7ab 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,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). diff --git a/apps/web/content/docs/dev/ai/fields-and-editor.mdx b/apps/web/content/docs/dev/ai/fields-and-editor.mdx index 4b14742b9..4dc8111f8 100644 --- a/apps/web/content/docs/dev/ai/fields-and-editor.mdx +++ b/apps/web/content/docs/dev/ai/fields-and-editor.mdx @@ -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. diff --git a/apps/web/content/docs/dev/fetcher.mdx b/apps/web/content/docs/dev/fetcher.mdx index f20780284..d9c700cd0 100644 --- a/apps/web/content/docs/dev/fetcher.mdx +++ b/apps/web/content/docs/dev/fetcher.mdx @@ -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 diff --git a/apps/web/content/docs/dev/sso/custom-adapter.mdx b/apps/web/content/docs/dev/sso/custom-adapter.mdx index a22e1df3a..629c3508e 100644 --- a/apps/web/content/docs/dev/sso/custom-adapter.mdx +++ b/apps/web/content/docs/dev/sso/custom-adapter.mdx @@ -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", }, diff --git a/apps/web/content/docs/dev/websocket/index.mdx b/apps/web/content/docs/dev/websocket/index.mdx index feaaedd53..fe9ecb3bd 100644 --- a/apps/web/content/docs/dev/websocket/index.mdx +++ b/apps/web/content/docs/dev/websocket/index.mdx @@ -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 diff --git a/apps/web/content/docs/dev/working-with-users/users.mdx b/apps/web/content/docs/dev/working-with-users/users.mdx index 005fd4df0..61df7d1c2 100644 --- a/apps/web/content/docs/dev/working-with-users/users.mdx +++ b/apps/web/content/docs/dev/working-with-users/users.mdx @@ -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 | | :------- | :------------------------------------------------------------------ | :--------------------------------------------------- | diff --git a/packages/vitnode/src/api/adapters/sso/discord.ts b/packages/vitnode/src/api/adapters/sso/discord.ts index 9ecfd0834..3e87cc874 100644 --- a/packages/vitnode/src/api/adapters/sso/discord.ts +++ b/packages/vitnode/src/api/adapters/sso/discord.ts @@ -143,6 +143,7 @@ export const DiscordSSOApiPlugin = ({ return { avatarUrl: discordAvatarUrl(data), email: data.email, + emailVerified: true, id: data.id, username: data.username, }; diff --git a/packages/vitnode/src/api/adapters/sso/facebook.ts b/packages/vitnode/src/api/adapters/sso/facebook.ts index 6a899e2f2..f76cf8978 100644 --- a/packages/vitnode/src/api/adapters/sso/facebook.ts +++ b/packages/vitnode/src/api/adapters/sso/facebook.ts @@ -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, diff --git a/packages/vitnode/src/api/adapters/sso/google.ts b/packages/vitnode/src/api/adapters/sso/google.ts index 89af62a7b..51b93c25f 100644 --- a/packages/vitnode/src/api/adapters/sso/google.ts +++ b/packages/vitnode/src/api/adapters/sso/google.ts @@ -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, diff --git a/packages/vitnode/src/api/adapters/sso/profile.test.ts b/packages/vitnode/src/api/adapters/sso/profile.test.ts index 73e24b580..38534754c 100644 --- a/packages/vitnode/src/api/adapters/sso/profile.test.ts +++ b/packages/vitnode/src/api/adapters/sso/profile.test.ts @@ -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", @@ -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", }); @@ -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", diff --git a/packages/vitnode/src/api/lib/ai/budget.ts b/packages/vitnode/src/api/lib/ai/budget.ts index 6da6f286a..62291e76e 100644 --- a/packages/vitnode/src/api/lib/ai/budget.ts +++ b/packages/vitnode/src/api/lib/ai/budget.ts @@ -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); }; diff --git a/packages/vitnode/src/api/lib/ai/ledger.ts b/packages/vitnode/src/api/lib/ai/ledger.ts index 365089b69..e1b7441f7 100644 --- a/packages/vitnode/src/api/lib/ai/ledger.ts +++ b/packages/vitnode/src/api/lib/ai/ledger.ts @@ -107,6 +107,7 @@ export interface AiSettlement { delivered: boolean; errorCode: null | string; finishedAt: Date; + outputSent?: boolean; status: "canceled" | "failed" | "succeeded" | "uncertain"; } diff --git a/packages/vitnode/src/api/lib/ai/memory-ledger.ts b/packages/vitnode/src/api/lib/ai/memory-ledger.ts index 0c53bfb7a..b1209e113 100644 --- a/packages/vitnode/src/api/lib/ai/memory-ledger.ts +++ b/packages/vitnode/src/api/lib/ai/memory-ledger.ts @@ -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; diff --git a/packages/vitnode/src/api/lib/ai/postgres-ledger.ts b/packages/vitnode/src/api/lib/ai/postgres-ledger.ts index f6080e522..fffd7ba62 100644 --- a/packages/vitnode/src/api/lib/ai/postgres-ledger.ts +++ b/packages/vitnode/src/api/lib/ai/postgres-ledger.ts @@ -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; diff --git a/packages/vitnode/src/api/lib/ai/runner.test.ts b/packages/vitnode/src/api/lib/ai/runner.test.ts index 1e9332a3a..2e3c1a3a8 100644 --- a/packages/vitnode/src/api/lib/ai/runner.test.ts +++ b/packages/vitnode/src/api/lib/ai/runner.test.ts @@ -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); + }, + ); }); diff --git a/packages/vitnode/src/api/lib/ai/runner.ts b/packages/vitnode/src/api/lib/ai/runner.ts index 3611ad022..d27294b99 100644 --- a/packages/vitnode/src/api/lib/ai/runner.ts +++ b/packages/vitnode/src/api/lib/ai/runner.ts @@ -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, { @@ -790,6 +791,7 @@ export class AiRunner { const settled = await this.settle(runId, { delivered, errorCode, + outputSent, status: delivered ? "succeeded" : errorCode === "AI_CANCELED" diff --git a/packages/vitnode/src/api/lib/recovery-tokens.ts b/packages/vitnode/src/api/lib/recovery-tokens.ts new file mode 100644 index 000000000..f8c7a6ceb --- /dev/null +++ b/packages/vitnode/src/api/lib/recovery-tokens.ts @@ -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 => { + 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)), + ]); +}; diff --git a/packages/vitnode/src/api/models/session-revoke.ts b/packages/vitnode/src/api/models/session-revoke.ts index 9756df0c8..3f85f98a6 100644 --- a/packages/vitnode/src/api/models/session-revoke.ts +++ b/packages/vitnode/src/api/models/session-revoke.ts @@ -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"; @@ -116,6 +117,7 @@ export const revokeSessions = async ( ]); await deleteSessionCacheKeys(c, { adminSessions, sessions }); + realtime.closeUserConnections(scope.userId, scope.deviceId ?? null); await c.get("events").emit("user.sessions.revoked", { deviceId: scope.deviceId ?? null, sessions: sessions.length + adminSessions.length, diff --git a/packages/vitnode/src/api/models/sso-connection.ts b/packages/vitnode/src/api/models/sso-connection.ts index 8317bccb2..5bb947d24 100644 --- a/packages/vitnode/src/api/models/sso-connection.ts +++ b/packages/vitnode/src/api/models/sso-connection.ts @@ -811,7 +811,7 @@ export const ssoIdentityOf = ( adapter: Pick, user: Awaited>, ): SsoProviderIdentity => { - const email = boundedText(user.email); + const email = user.emailVerified === false ? null : boundedText(user.email); 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 f70156ac6..05db33cec 100644 --- a/packages/vitnode/src/api/models/sso.ts +++ b/packages/vitnode/src/api/models/sso.ts @@ -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; @@ -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; @@ -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, diff --git a/packages/vitnode/src/api/modules/admin/roles/lib/assert-manage-admin-role.ts b/packages/vitnode/src/api/modules/admin/roles/lib/assert-manage-admin-role.ts index 151544e42..b3edc27b1 100644 --- a/packages/vitnode/src/api/modules/admin/roles/lib/assert-manage-admin-role.ts +++ b/packages/vitnode/src/api/modules/admin/roles/lib/assert-manage-admin-role.ts @@ -1,8 +1,15 @@ import type { Context } from "hono"; import { and, eq } from "drizzle-orm"; +import { HTTPException } from "hono/http-exception"; + +import type { PermissionStaffType } from "@/api/lib/permission-staff"; import { assertStaffPermission } from "@/api/lib/check-staff-permission"; +import { + assertCanManageStaffEntry, + assertNotOwnStaffEntry, +} from "@/api/modules/admin/staff/lib/staff-entry-guards"; import { core_admin_permissions } from "@/database/admins"; import { core_moderators_permissions } from "@/database/moderators"; import { core_roles } from "@/database/roles"; @@ -55,3 +62,53 @@ export const assertCanManageAdminRole = async ( permission, }); }; + +const staffTables = { + admin: core_admin_permissions, + moderator: core_moderators_permissions, +} as const; + +export const assertCanRemoveRoleStaffEntries = async ( + c: Context, + roleId: number, +): Promise => { + const types: PermissionStaffType[] = ["admin", "moderator"]; + + const entriesByType = await Promise.all( + types.map(async type => { + const table = staffTables[type]; + const entries = await c + .get("db") + .select({ + permissions: table.permissions, + protected: table.protected, + roleId: table.roleId, + unrestricted: table.unrestricted, + userId: table.userId, + }) + .from(table) + .where(eq(table.roleId, roleId)); + + return entries.map(entry => ({ entry, type })); + }), + ); + const staffEntries = entriesByType.flat(); + + if (staffEntries.some(({ entry }) => entry.protected)) { + throw new HTTPException(403, { message: "Forbidden" }); + } + + await Promise.all( + staffEntries.map(async ({ entry, type }) => { + await assertNotOwnStaffEntry( + c, + entry, + "You cannot remove your own staff permissions.", + ); + await assertCanManageStaffEntry(c, { + entry: { ...entry, permissions: entry.permissions ?? [] }, + type, + }); + }), + ); +}; diff --git a/packages/vitnode/src/api/modules/admin/roles/routes/delete.route.ts b/packages/vitnode/src/api/modules/admin/roles/routes/delete.route.ts index 187074882..e88620942 100644 --- a/packages/vitnode/src/api/modules/admin/roles/routes/delete.route.ts +++ b/packages/vitnode/src/api/modules/admin/roles/routes/delete.route.ts @@ -9,7 +9,10 @@ import { core_languages_words } from "@/database/languages"; import { core_roles } from "@/database/roles"; import { core_users, core_users_secondary_roles } from "@/database/users"; -import { assertCanManageAdminRole } from "../lib/assert-manage-admin-role"; +import { + assertCanManageAdminRole, + assertCanRemoveRoleStaffEntries, +} from "../lib/assert-manage-admin-role"; export const deleteRoleAdminRoute = buildRoute({ adminStaffPermission: { module: "roles", permission: "can_delete" }, @@ -94,6 +97,7 @@ export const deleteRoleAdminRoute = buildRoute({ roleId, permission: "can_delete_admin", }); + await assertCanRemoveRoleStaffEntries(c, roleId); // Members point at their primary role through a NOT NULL, RESTRICT foreign // key, so anyone in this role has to be moved elsewhere first. Secondary diff --git a/packages/vitnode/src/api/modules/admin/users/lib/assert-edit-user-permission.test.ts b/packages/vitnode/src/api/modules/admin/users/lib/assert-edit-user-permission.test.ts index 00c1d10be..56a36f866 100644 --- a/packages/vitnode/src/api/modules/admin/users/lib/assert-edit-user-permission.test.ts +++ b/packages/vitnode/src/api/modules/admin/users/lib/assert-edit-user-permission.test.ts @@ -301,12 +301,71 @@ describe("assertCanEditAdminTarget", () => { TARGET_ID, ), ).rejects.toMatchObject({ status: 403 }); - await expect( - assertCanEditAdminTarget( - await contextFor(world, ELEVATED_ADMIN), - TARGET_ID, - ), - ).resolves.toBeUndefined(); + }, + ); + + const ceilingCases: [string, World, Caller, boolean][] = [ + [ + "a zero-permission admin, for an elevated admin", + { admin: [{ userId: TARGET_ID }] }, + ELEVATED_ADMIN, + true, + ], + [ + "a root target, for an elevated admin", + { secondaryRoleIds: [ROOT_ROLE] }, + ELEVATED_ADMIN, + false, + ], + [ + "a root target, for root", + { secondaryRoleIds: [ROOT_ROLE] }, + ROOT_CALLER, + true, + ], + [ + "an unrestricted admin, for an elevated admin", + { admin: [{ unrestricted: true, userId: TARGET_ID }] }, + ELEVATED_ADMIN, + false, + ], + [ + "an admin holding a permission the caller lacks", + { admin: [{ permissions: [CAN_MANAGE_PLUGINS], userId: TARGET_ID }] }, + ELEVATED_ADMIN, + false, + ], + [ + "an admin whose permissions the caller holds", + { admin: [{ permissions: [CAN_MANAGE_PLUGINS], userId: TARGET_ID }] }, + { admin: [CAN_EDIT, CAN_EDIT_ADMIN, CAN_MANAGE_PLUGINS] }, + true, + ], + [ + "a moderator holding a permission the caller lacks", + { moderator: [{ permissions: [CAN_DELETE_POSTS], userId: TARGET_ID }] }, + ELEVATED_ADMIN, + false, + ], + [ + "a moderator whose permissions the caller holds", + { moderator: [{ permissions: [CAN_DELETE_POSTS], userId: TARGET_ID }] }, + { ...ELEVATED_ADMIN, moderator: [CAN_DELETE_POSTS] }, + true, + ], + ]; + + it.each(ceilingCases)( + "keeps the edit within the caller's ceiling: %s", + async (_label, world, caller, allowed) => { + const result = assertCanEditAdminTarget( + await contextFor(world, caller), + TARGET_ID, + ); + + await (allowed + ? expect(result).resolves.toBeUndefined() + : expect(result).rejects.toMatchObject({ status: 403 })); }, ); diff --git a/packages/vitnode/src/api/modules/admin/users/lib/assert-edit-user-permission.ts b/packages/vitnode/src/api/modules/admin/users/lib/assert-edit-user-permission.ts index b36b45737..95fb3bd5b 100644 --- a/packages/vitnode/src/api/modules/admin/users/lib/assert-edit-user-permission.ts +++ b/packages/vitnode/src/api/modules/admin/users/lib/assert-edit-user-permission.ts @@ -15,9 +15,11 @@ import { resolveStaffPermissions, } from "@/api/lib/check-staff-permission"; import { hasStaffPermission } from "@/api/lib/staff-permission"; +import { staffGrantsWithinCeiling } from "@/api/modules/admin/staff/lib/scoped-grants"; import { core_admin_permissions } from "@/database/admins"; import { core_moderators_permissions } from "@/database/moderators"; import { core_roles } from "@/database/roles"; +import { core_users } from "@/database/users"; import { manifest } from "@/manifest"; const STAFF_TYPES: PermissionStaffType[] = ["admin", "moderator"]; @@ -44,6 +46,50 @@ const assertCanEditAdmin = async (c: Context): Promise => { }); }; +const loadTargetUser = async (c: Context, userId: number) => { + const [target] = await c + .get("db") + .select({ id: core_users.id, roleId: core_users.roleId }) + .from(core_users) + .where(eq(core_users.id, userId)) + .limit(1); + if (!target) throw new HTTPException(404, { message: "User not found" }); + + return target; +}; + +const assertTargetWithinCallerCeiling = async ( + c: Context, + userId: number, + staffTypes: PermissionStaffType[], +): Promise => { + const caller = c.get("admin")?.user; + if (!caller) throw forbidden(); + + const callerAdmin = await resolveStaffPermissions(c, { + type: "admin", + user: caller, + }); + if (callerAdmin.root) return; + + const target = await loadTargetUser(c, userId); + for (const type of staffTypes) { + const [held, targetSet] = await Promise.all([ + type === "admin" + ? callerAdmin + : resolveStaffPermissions(c, { type, user: caller }), + resolveStaffPermissions(c, { type, user: target }), + ]); + + if ( + targetSet.root || + !staffGrantsWithinCeiling(targetSet.permissions, held.permissions) + ) { + throw forbidden(); + } + } +}; + export const assertCanEditAdminTarget = async ( c: Context, userId: number, @@ -51,9 +97,11 @@ export const assertCanEditAdminTarget = async ( const staffByType = await Promise.all( STAFF_TYPES.map(async type => await isStaff(c, { type, userId })), ); - if (!staffByType.some(Boolean)) return; + const staffTypes = STAFF_TYPES.filter((_, index) => staffByType[index]); + if (staffTypes.length === 0) return; await assertCanEditAdmin(c); + await assertTargetWithinCallerCeiling(c, userId, staffTypes); }; const loadRoleStaffGrants = async ( diff --git a/packages/vitnode/src/api/modules/admin/users/routes/password.route.ts b/packages/vitnode/src/api/modules/admin/users/routes/password.route.ts index 69de43dbc..c7250761f 100644 --- a/packages/vitnode/src/api/modules/admin/users/routes/password.route.ts +++ b/packages/vitnode/src/api/modules/admin/users/routes/password.route.ts @@ -3,6 +3,7 @@ import { eq } from "drizzle-orm"; import { buildRoute } from "@/api/builders"; import { assertPasswordSignInEnabled } from "@/api/lib/password-sign-in"; +import { revokeAccountRecoveryTokens } from "@/api/lib/recovery-tokens"; import { PasswordModel } from "@/api/models/password"; import { revokeSessions } from "@/api/models/session-revoke"; import { USER_PASSWORD_MAX_LENGTH } from "@/api/modules/users/credential-limits"; @@ -93,6 +94,7 @@ export const setPasswordUserAdminRoute = buildRoute({ .where(eq(core_users.id, user.id)); await c.get("events").emit("user.password.updated", { userId: user.id }); + await revokeAccountRecoveryTokens(c, user.id); await revokeSessions(c, { userId: user.id }); return c.json({ ok: true as const }, 200); diff --git a/packages/vitnode/src/api/modules/admin/users/routes/update.route.test.ts b/packages/vitnode/src/api/modules/admin/users/routes/update.route.test.ts index 4f25bb58e..7caab920a 100644 --- a/packages/vitnode/src/api/modules/admin/users/routes/update.route.test.ts +++ b/packages/vitnode/src/api/modules/admin/users/routes/update.route.test.ts @@ -177,15 +177,29 @@ describe("PATCH /admin/users/{id} - staff targets", () => { }, ); - it.each(["root via a secondary role", "moderator"])( - "changes the email of a target who is %s with users:can_edit_admin", - async target => { - const h = await harness({ editor: [CAN_EDIT, CAN_EDIT_ADMIN], target }); + it("changes the email of a moderator within reach with users:can_edit_admin", async () => { + const h = await harness({ + editor: [CAN_EDIT, CAN_EDIT_ADMIN], + target: "moderator", + }); - const response = await h.patch(TARGET_ID, NEW_EMAIL); + const response = await h.patch(TARGET_ID, NEW_EMAIL); + + expect(response.status).toBe(200); + expect(h.userRow(TARGET_ID)?.email).toBe(NEW_EMAIL.email); + }); + + it.each< + [string, PermissionsStaffArgs[] | typeof ROOT_STAFF_PERMISSIONS, number] + >([ + ["refuses a non-root admin", [CAN_EDIT, CAN_EDIT_ADMIN], 403], + ["lets root", ROOT_STAFF_PERMISSIONS, 200], + ])( + "%s changing the email of a root target", + async (_label, editor, status) => { + const h = await harness({ editor, target: "root via a secondary role" }); - expect(response.status).toBe(200); - expect(h.userRow(TARGET_ID)?.email).toBe(NEW_EMAIL.email); + expect((await h.patch(TARGET_ID, NEW_EMAIL)).status).toBe(status); }, ); 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 18fb08581..a486e06e9 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 @@ -3,6 +3,7 @@ import { and, eq, inArray, ne } from "drizzle-orm"; import { buildRoute } from "@/api/builders"; import { isValidTimeZone } from "@/api/lib/notifications/digest-period"; +import { revokeAccountRecoveryTokens } from "@/api/lib/recovery-tokens"; import { invalidateStaffPermissionsForUser } from "@/api/lib/staff-permission-cache"; import { matchesEmail } from "@/api/lib/user-email-lookup"; import { invalidateSessionCacheForUser } from "@/api/models/session-revoke"; @@ -341,6 +342,10 @@ export const updateUserAdminRoute = buildRoute({ nameCode: core_users.nameCode, }); + if (values.email !== undefined) { + await revokeAccountRecoveryTokens(c, user.id); + } + await new SsoConnectionModel(c).clearSourcesAfterManualEdit({ fields: (["firstName", "lastName"] as const).filter( field => field in personalValues, diff --git a/packages/vitnode/src/api/modules/ai/routes/stream.route.test.ts b/packages/vitnode/src/api/modules/ai/routes/stream.route.test.ts index 8a2f95209..d0a27f9b0 100644 --- a/packages/vitnode/src/api/modules/ai/routes/stream.route.test.ts +++ b/packages/vitnode/src/api/modules/ai/routes/stream.route.test.ts @@ -134,7 +134,7 @@ describe("POST /stream", () => { expect(ledger.spentPoints(USER_ID)).toBe(20_000_000_000_000n); }); - it("cancels the provider call and charges nothing when the client goes away", async () => { + it("cancels the provider call and charges the text already sent when the client goes away", async () => { const { app, ledger } = createApp( streamingModel(["One", " two", " three", " four"]), ); @@ -145,7 +145,7 @@ describe("POST /stream", () => { await reader?.cancel("closed the panel"); await expect.poll(() => ledger.runs[0]?.status).toBe("canceled"); - expect(ledger.spentPoints(USER_ID)).toBe(0n); + expect(ledger.spentPoints(USER_ID)).toBeGreaterThan(0n); }); it("refuses before streaming when the editor permission is not granted", async () => { diff --git a/packages/vitnode/src/api/modules/moderation/reports.test.ts b/packages/vitnode/src/api/modules/moderation/reports.test.ts index 1a03da25f..044a8fbe1 100644 --- a/packages/vitnode/src/api/modules/moderation/reports.test.ts +++ b/packages/vitnode/src/api/modules/moderation/reports.test.ts @@ -16,6 +16,7 @@ import { import { core_roles } from "@/database/roles"; import { core_users } from "@/database/users"; import { MODERATION_LIMITS } from "@/lib/moderation/const"; +import { resolvePersonalInformationFields } from "@/lib/user-personal-information"; import { createTestCache } from "@/tests/cache"; import { createMemoryDb } from "@/tests/memory-db"; import { @@ -194,6 +195,7 @@ const harness = async () => { targets: [{ definition: target, pluginId: FORUM }], }, permissionStaff: [], + personalInformationFields: resolvePersonalInformationFields(), } as unknown as Context["var"]["core"]); await next(); }); diff --git a/packages/vitnode/src/components/rich-text/rich-text-content.tsx b/packages/vitnode/src/components/rich-text/rich-text-content.tsx index 874e57e9e..b805c776d 100644 --- a/packages/vitnode/src/components/rich-text/rich-text-content.tsx +++ b/packages/vitnode/src/components/rich-text/rich-text-content.tsx @@ -107,6 +107,12 @@ const renderElement = ( return React.createElement(element.tag, props, ...children); }; +const ownEntry = ( + record: Readonly> | undefined, + key: string, +): T | undefined => + record && Object.hasOwn(record, key) ? record[key] : undefined; + const renderText = ( node: RichTextNode, key: number, @@ -115,7 +121,7 @@ const renderText = ( let content: React.ReactNode = typeof node.text === "string" ? node.text : ""; for (const mark of [...(node.marks ?? [])].reverse()) { - const Override = context.marks?.[mark.type]; + const Override = ownEntry(context.marks, mark.type); if (Override) { content = ( @@ -147,7 +153,7 @@ const renderNode = ( ) : null; - const Override = context.nodes?.[node.type]; + const Override = ownEntry(context.nodes, node.type); if (Override) { return ( diff --git a/packages/vitnode/src/content/rich-text/elements.ts b/packages/vitnode/src/content/rich-text/elements.ts index 411227fc0..8ad063396 100644 --- a/packages/vitnode/src/content/rich-text/elements.ts +++ b/packages/vitnode/src/content/rich-text/elements.ts @@ -464,8 +464,9 @@ const SIMPLE_MARKS: Readonly> = { export const richTextMarkElement = ( mark: RichTextMark, ): null | RichTextElement => { - const simple = SIMPLE_MARKS[mark.type]; - if (simple !== undefined) return { children: [RICH_TEXT_SLOT], tag: simple }; + if (Object.hasOwn(SIMPLE_MARKS, mark.type)) { + return { children: [RICH_TEXT_SLOT], tag: SIMPLE_MARKS[mark.type] }; + } if (mark.type === "link") { const href = sanitizeRichTextHref(mark.attrs?.href); diff --git a/packages/vitnode/src/content/server/page-cache.ts b/packages/vitnode/src/content/server/page-cache.ts index ec6bf2dbe..3fddf45aa 100644 --- a/packages/vitnode/src/content/server/page-cache.ts +++ b/packages/vitnode/src/content/server/page-cache.ts @@ -8,6 +8,7 @@ import type { AnyContentTypeDefinition } from "../types"; import { invalidatePageCacheTags } from "../../page-cache/store.server"; import { contentPublicItemTag, contentPublicListTag } from "../cache"; +import { contentDependentDefinitions } from "./parent-visibility"; import { bumpContentPublicGeneration } from "./public-cache"; import { isContentBaseRowPublic } from "./visibility"; @@ -175,15 +176,21 @@ export const invalidateContentPages = async ( // The API's own cached public reads go with the pages, on the same rule. This // runs from `emitContentEvent`, after the write committed - a bump before the // commit would let a reader store the old row under the new generation. + const dependents = contentDependentDefinitions(c, definition); const [result, generation] = await Promise.all([ invalidatePageCacheTags( [ ...contentRecordPageCacheTags(definition, recordIdOf(payload)), ...contentListPageCacheTags(definition), + ...dependents.flatMap(dependent => contentListPageCacheTags(dependent)), ], { mode }, ), - bumpContentPublicGeneration(c, definition.id).then( + Promise.all( + [definition, ...dependents].map( + async target => await bumpContentPublicGeneration(c, target.id), + ), + ).then( () => null, (error: unknown) => error, ), diff --git a/packages/vitnode/src/content/server/parent-visibility.ts b/packages/vitnode/src/content/server/parent-visibility.ts new file mode 100644 index 000000000..713195ef1 --- /dev/null +++ b/packages/vitnode/src/content/server/parent-visibility.ts @@ -0,0 +1,126 @@ +import type { PgColumn } from "drizzle-orm/pg-core"; +import type { Context } from "hono"; + +import { and, inArray } from "drizzle-orm"; + +import type { AnyContentTypeDefinition } from "../types"; + +import { contentSearchContextSource } from "../search"; +import { findContentModel } from "./model"; +import { publicationColumns, publishedCondition } from "./publication"; + +const CONTENT_PARENT_KEY = "__vitnodeParent"; + +interface ContentParentRelation { + field: string; + target: AnyContentTypeDefinition; +} + +interface ContentParentVisibility { + selection: Record; + visible: >( + rows: TRow[], + ) => Promise; +} + +const contentParentRelation = ( + definition: AnyContentTypeDefinition, +): ContentParentRelation | null => { + if (definition.thread.enabled) { + const descriptor = definition.fields[definition.thread.rootField]; + if (descriptor?.kind === "relation") { + return { + field: definition.thread.rootField, + target: descriptor.target(), + }; + } + } + + const source = contentSearchContextSource(definition); + + return source?.kind === "relation" + ? { field: source.field, target: source.target } + : null; +}; + +const isParentReference = (value: unknown): value is number | string => + typeof value === "number" || typeof value === "string"; + +export const createContentParentVisibility = ( + c: Context, + definition: AnyContentTypeDefinition, + columns: Record, +): ContentParentVisibility | null => { + const parent = contentParentRelation(definition); + if (!parent?.target.publication.enabled) return null; + + const parentModel = findContentModel( + c.get("core")?.contentModels ?? [], + parent.target.id, + )?.model; + if (!parentModel) { + throw new Error( + `Content type "${definition.id}" has parent "${parent.target.id}", which is not registered.`, + ); + } + + const parentColumns = parentModel.columns as Record; + const parentId = parentColumns.id; + + return { + selection: { [CONTENT_PARENT_KEY]: columns[parent.field] }, + visible: async >( + rows: TRow[], + ): Promise => { + const ids = [ + ...new Set( + rows.map(row => row[CONTENT_PARENT_KEY]).filter(isParentReference), + ), + ]; + const visibleIds = new Set( + ids.length === 0 + ? [] + : ( + await c + .get("db") + .select({ id: parentId }) + .from(parentModel.table) + .where( + and( + inArray(parentId, ids), + publishedCondition( + publicationColumns(parent.target, parentColumns), + ), + ), + ) + ).map(row => String(row.id)), + ); + + return rows + .filter(row => { + const id = row[CONTENT_PARENT_KEY]; + + return isParentReference(id) + ? visibleIds.has(String(id)) + : id === null || id === undefined; + }) + .map(row => { + const { [CONTENT_PARENT_KEY]: _parent, ...rest } = row; + + return rest as TRow; + }); + }, + }; +}; + +export const contentDependentDefinitions = ( + c: Context, + definition: AnyContentTypeDefinition, +): AnyContentTypeDefinition[] => + (c.get("core")?.contentModels ?? []) + .map(entry => entry.model.definition as AnyContentTypeDefinition) + .filter( + candidate => + candidate.publicApi.enabled && + contentParentRelation(candidate)?.target.id === definition.id, + ); diff --git a/packages/vitnode/src/content/server/public-service.ts b/packages/vitnode/src/content/server/public-service.ts index b5296d89a..74cbc510d 100644 --- a/packages/vitnode/src/content/server/public-service.ts +++ b/packages/vitnode/src/content/server/public-service.ts @@ -39,6 +39,7 @@ import { contentPublicListCondition, flagHiddenContentRows, } from "./hidden-list"; +import { createContentParentVisibility } from "./parent-visibility"; import { createContentPublicRowHydrator, isContentPublicPeopleList, @@ -425,6 +426,12 @@ export const createContentPublicService = < // here - it is what turns the erased column map into the two columns the // predicate needs. const published = publicationColumns(definition, columns); + const parent = createContentParentVisibility(c, definition, columns); + const withParent = (selection: TSelection) => + parent ? { ...selection, ...parent.selection } : selection; + const keepVisible = async >( + rows: TRow[], + ): Promise => (parent ? await parent.visible(rows) : rows); const primaryCursor = columns.id as PaginationCursorColumn; const searchColumns = publicApi.searchableFields.map(name => columns[name]); const orderable = publicOrderableColumns(definition); @@ -462,12 +469,14 @@ export const createContentPublicService = < const { hydrate, project, selection } = options.select ? reader(contentPublicSelectProjection(definition, options.select)) : detail; - const [row] = await c - .get("db") - .select(selection) - .from(table) - .where(and(contentPublicReadCondition(published, options), condition)) - .limit(1); + const [row] = await keepVisible( + await c + .get("db") + .select(withParent(selection)) + .from(table) + .where(and(contentPublicReadCondition(published, options), condition)) + .limit(1), + ); if (!row) return null; @@ -486,13 +495,18 @@ export const createContentPublicService = < findManyByIds: async ids => { if (ids.length === 0) return []; - const rows = await c - .get("db") - .select(detail.selection) - .from(table) - .where( - and(publishedCondition(published), inArray(primaryCursor, [...ids])), - ); + const rows = await keepVisible( + await c + .get("db") + .select(withParent(detail.selection)) + .from(table) + .where( + and( + publishedCondition(published), + inArray(primaryCursor, [...ids]), + ), + ), + ); return (await detail.hydrate(rows)).map(detail.project); }, @@ -548,7 +562,7 @@ export const createContentPublicService = < // The cursor value is projected by this statement and stripped from // the row before `project` ever sees it, so the public allowlist is // unchanged: it is pagination's own column, not a field. - .select({ ...list.selection, ...cursorSelection }) + .select({ ...withParent(list.selection), ...cursorSelection }) .from(table) .where(where) .orderBy(order) @@ -560,7 +574,9 @@ export const createContentPublicService = < .offset(offset), }); - const edges = (await list.hydrate(data.edges)).map(list.project); + const edges = (await list.hydrate(await keepVisible(data.edges))).map( + list.project, + ); return { edges: hidden diff --git a/packages/vitnode/src/content/server/schedule-effects.test.ts b/packages/vitnode/src/content/server/schedule-effects.test.ts index 4ec0778a5..11c9f0332 100644 --- a/packages/vitnode/src/content/server/schedule-effects.test.ts +++ b/packages/vitnode/src/content/server/schedule-effects.test.ts @@ -90,10 +90,15 @@ interface EffectsErrorWrite { const harness = ({ event = eventDelivered, origins = { [WEB]: 200 }, + live = { + ...payload().row, + publishedAt: new Date("2026-08-05T12:00:00.000Z"), + }, registered = true, searchError, }: { event?: typeof eventDelivered | typeof eventFailed; + live?: null | Record; origins?: Record; registered?: boolean; searchError?: Error; @@ -129,7 +134,17 @@ const harness = ({ const store: Record = { core: { contentModels: registered - ? [{ model: { definition }, pluginId: PLUGIN_ID }] + ? [ + { + model: { + definition, + service: () => ({ + findRowById: async () => Promise.resolve(live), + }), + }, + pluginId: PLUGIN_ID, + }, + ] : [], contentRevalidateOrigins: Object.keys(origins), cronSecret: "test-secret", diff --git a/packages/vitnode/src/content/server/schedule-effects.ts b/packages/vitnode/src/content/server/schedule-effects.ts index 087a807ef..f7afae8a4 100644 --- a/packages/vitnode/src/content/server/schedule-effects.ts +++ b/packages/vitnode/src/content/server/schedule-effects.ts @@ -99,10 +99,13 @@ const reviveDates = ( return revived; }; +const rowVersion = (row: Record): number => + typeof row.version === "number" ? row.version : 1; + export interface ContentScheduleEffectsOutcome { /** Why this run failed, when it did. Also written to the schedule row. */ error?: string; - status: "delivered" | "unregistered"; + status: "delivered" | "superseded" | "unregistered"; } export const runContentScheduleEffects = async ( @@ -144,6 +147,21 @@ export const runContentScheduleEffects = async ( const isPublic = isContentRowPublic(row); + const live = await entry.model.service(c).findRowById(itemId); + if ( + !live || + rowVersion(live) !== payload.version || + isContentRowPublic(live) !== isPublic + ) { + await recordContentScheduleEffectsError( + c.get("db"), + payload.scheduleId, + null, + ); + + return { status: "superseded" }; + } + const outcome: ContentEditorialOutcome = { changed: true, changedFields: payload.changedFields, diff --git a/packages/vitnode/src/tanstack/fetcher/server.ts b/packages/vitnode/src/tanstack/fetcher/server.ts index 030534e88..1f2cd59eb 100644 --- a/packages/vitnode/src/tanstack/fetcher/server.ts +++ b/packages/vitnode/src/tanstack/fetcher/server.ts @@ -3,7 +3,6 @@ import { getRequest, getRequestHeaders, getRequestIP, - getRequestUrl, setCookie, } from "@tanstack/react-start/server"; import { config } from "dotenv"; @@ -39,19 +38,18 @@ import { recordPageCacheApiResponse } from "@/page-cache/render-context.server"; config({ quiet: true }); /** - * The origin server-side API calls go to: `VITNODE_API_URL` when set (a - * separate API server), otherwise the origin of the request being handled. + * The origin server-side API calls go to over the network: `VITNODE_API_URL` + * when set (a separate API server), otherwise the web app's own origin, + * `VITNODE_WEB_URL`. Never the incoming request's `Host` - a visitor chooses + * it, and the call carries their cookies, so it would let anyone point the + * server's requests at a host of their choosing. */ export const resolveApiOrigin = (): string => { // Through `CONFIG` rather than the variable directly, so the empty-value // throw stays in one place - see the `??` note there. if (process.env.VITNODE_API_URL !== undefined) return CONFIG.api.origin; - try { - return getRequestUrl().origin; - } catch { - return CONFIG.api.origin; - } + return CONFIG.web.origin; }; // This app serves its own API at its web origin, so a `VITNODE_API_URL` naming diff --git a/packages/vitnode/src/tests/content-policies.ts b/packages/vitnode/src/tests/content-policies.ts index ec2d27a2a..31d02e2ae 100644 --- a/packages/vitnode/src/tests/content-policies.ts +++ b/packages/vitnode/src/tests/content-policies.ts @@ -16,6 +16,7 @@ import { core_content_submissions } from "@/database/content-submissions"; import { core_files } from "@/database/files"; import { core_roles } from "@/database/roles"; import { core_users } from "@/database/users"; +import { resolvePersonalInformationFields } from "@/lib/user-personal-information"; import { createTestCache } from "@/tests/cache"; import { testPolicyBoardContainers, @@ -253,6 +254,7 @@ export const createPolicyWorld = async ({ ], moderation: { scopes: [], targets: [] }, permissionStaff: [], + personalInformationFields: resolvePersonalInformationFields(), } as unknown as Context["var"]["core"]); await next(); }); diff --git a/packages/vitnode/src/ws/crossws.server.test.ts b/packages/vitnode/src/ws/crossws.server.test.ts index b39e4d253..8346dff5d 100644 --- a/packages/vitnode/src/ws/crossws.server.test.ts +++ b/packages/vitnode/src/ws/crossws.server.test.ts @@ -66,6 +66,7 @@ const buildApp = () => { const app = new Hono().basePath("/api"); app.use("*", async (c, next) => { c.set("core", { + authorization: { deviceCookieName: "vitnode_device" }, webSockets: [roomsSocket], } as unknown as EnvVariablesVitNode["core"]); c.set("db", db as unknown as EnvVariablesVitNode["db"]); diff --git a/packages/vitnode/src/ws/handle.ts b/packages/vitnode/src/ws/handle.ts index 4024ebbb2..34b2522f8 100644 --- a/packages/vitnode/src/ws/handle.ts +++ b/packages/vitnode/src/ws/handle.ts @@ -7,6 +7,7 @@ import type { EnvVitNode } from "@/api/middlewares/global.middleware"; import type { NotificationStateMessage } from "@/lib/notifications/types"; import type { VitNodeWSMessage } from "@/ws/types"; +import { DeviceModel } from "@/api/models/device"; import { getNotificationState } from "@/api/models/notifications/inbox"; import { notificationsStateChannel } from "@/ws/notifications"; import { @@ -102,11 +103,15 @@ export function handleVitNodeWebSocket( // Authenticated from the session cookie sent with the upgrade request, so // the server can target this connection's user (e.g. for notifications). const userId = c.get("user")?.id ?? null; + const deviceId = + userId === null + ? null + : ((await new DeviceModel(c).getExistingDeviceId())?.id ?? null); return { onOpen: (event, ws) => { // Track the connection so the server can push messages to it. - wsRegistry.add(ws, userId); + wsRegistry.add(ws, userId, deviceId); if (userId !== null) { sendNotificationState(c, ws, userId).catch((error: unknown) => { // oxlint-disable-next-line no-console diff --git a/packages/vitnode/src/ws/registry.ts b/packages/vitnode/src/ws/registry.ts index 8f1ea709b..8da3a113f 100644 --- a/packages/vitnode/src/ws/registry.ts +++ b/packages/vitnode/src/ws/registry.ts @@ -10,6 +10,7 @@ const connections = new Map(); // Reverse indexes, so per-user sends and closing a socket touch only what // belongs to that user/socket instead of scanning every connection or room. const connectionsByUser = new Map>(); +const deviceBySocket = new Map(); const roomsBySocket = new Map>(); const rooms = new Map>>(); @@ -61,11 +62,20 @@ const unindexUser = (ws: WSContext, userId: null | number | undefined) => { * Redis) use `c.get("realtime")`. A socket whose send fails is removed. */ export const wsRegistry = { - /** Tracks a new connection and the signed-in user who owns it (`null` for a guest). */ - add: (ws: WSContext, userId: null | number): void => { + /** + * Tracks a new connection and the signed-in user who owns it (`null` for a + * guest), with the device of the session that authenticated it. + */ + add: ( + ws: WSContext, + userId: null | number, + deviceId: null | number = null, + ): void => { unindexUser(ws, connections.get(ws)); connections.set(ws, userId); + deviceBySocket.delete(ws); if (userId === null) return; + if (deviceId !== null) deviceBySocket.set(ws, deviceId); const sockets = connectionsByUser.get(userId) ?? new Set(); connectionsByUser.set(userId, sockets); @@ -131,6 +141,7 @@ export const wsRegistry = { remove: (ws: WSContext): void => { unindexUser(ws, connections.get(ws)); connections.delete(ws); + deviceBySocket.delete(ws); const joined = roomsBySocket.get(ws); roomsBySocket.delete(ws); @@ -172,6 +183,34 @@ export const wsRegistry = { } } }, + /** + * Closes a user's connections on this instance - only those authenticated + * from `deviceId` when given, else all of them. Used when their sessions are + * revoked: the socket kept the identity of the session that opened it, so it + * must not outlive that session. The client reconnects as whoever it now is. + */ + closeUser: ( + userId: number, + deviceId: null | number, + code: number, + reason: string, + ): void => { + for (const ws of [...(connectionsByUser.get(userId) ?? [])]) { + const socketDevice = deviceBySocket.get(ws); + if ( + deviceId !== null && + socketDevice !== undefined && + socketDevice !== deviceId + ) { + continue; + } + try { + ws.close(code, reason); + } catch { + wsRegistry.remove(ws); + } + } + }, /** The rooms one socket is in. */ roomsOf: (ws: WSContext): string[] => [...(roomsBySocket.get(ws) ?? [])], /** Send `{ id, data }` to every socket in `room` on this instance. */ @@ -190,6 +229,9 @@ export const wsRegistry = { const REALTIME_PUBSUB_CHANNEL = "vitnode:ws"; +const SESSION_REVOKED_CLOSE_CODE = 1008; +const SESSION_REVOKED_CLOSE_REASON = "Session revoked"; + /** * Unique per process. Tagged onto every published message so the instance that * sent it can ignore its own echo (it already delivered to its local clients). @@ -209,7 +251,13 @@ interface RealtimePubSubMessage { id: string; origin: string; room?: string; - type: "broadcast" | "instance" | "room" | "sendToUser" | "sendToUsers"; + type: + | "broadcast" + | "closeUser" + | "instance" + | "room" + | "sendToUser" + | "sendToUsers"; userId?: number; } @@ -235,6 +283,17 @@ const deliverLocally = (message: RealtimePubSubMessage): void => { } } else if (message.type === "room" && typeof message.room === "string") { wsRegistry.toRoom(message.room, message.id, message.data); + } else if ( + message.type === "closeUser" && + typeof message.userId === "number" + ) { + const deviceId = typeof message.data === "number" ? message.data : null; + wsRegistry.closeUser( + message.userId, + deviceId, + SESSION_REVOKED_CLOSE_CODE, + SESSION_REVOKED_CLOSE_REASON, + ); } }; @@ -461,6 +520,14 @@ export interface VitNodeRealtime { channel: VitNodeWSChannel, data: Receive, ) => void; + /** + * Close a user's open connections on every instance - only the ones opened + * from `deviceId` when given. Core calls it whenever sessions are revoked + * (sign-out of a device, password change or reset, admin revoke), so a socket + * never keeps receiving a user's pushes after the session that opened it is + * gone. Clients reconnect with whatever session they still have. + */ + closeUserConnections: (userId: number, deviceId?: null | number) => void; /** * Push a payload only to the connections of a single user, across all of * their browsers/devices. Use for per-user data such as notifications. @@ -492,6 +559,15 @@ export const realtime: VitNodeRealtime = { wsRegistry.broadcast(channel.id, data); publish({ data, id: channel.id, type: "broadcast" }); }, + closeUserConnections: (userId, deviceId = null) => { + wsRegistry.closeUser( + userId, + deviceId, + SESSION_REVOKED_CLOSE_CODE, + SESSION_REVOKED_CLOSE_REASON, + ); + publish({ data: deviceId, id: "closeUser", type: "closeUser", userId }); + }, sendToUser: (userId, channel, data) => { wsRegistry.sendToUser(userId, channel.id, data); publish({ data, id: channel.id, type: "sendToUser", userId }); diff --git a/plugins/blog/src/pages/blog-card.tsx b/plugins/blog/src/pages/blog-card.tsx index ca0a57855..de6979769 100644 --- a/plugins/blog/src/pages/blog-card.tsx +++ b/plugins/blog/src/pages/blog-card.tsx @@ -1,4 +1,4 @@ -import type { CSSProperties } from "react"; +import type { CSSProperties, ReactNode } from "react"; import { Avatar } from "@vitnode/core/components/avatar"; import { HiddenIcon } from "@vitnode/core/components/hidden-icon"; @@ -126,7 +126,7 @@ const BlogPostAuthors = ({ }: { authors: BlogPostAuthor[]; onImage: boolean; -}) => { +}): ReactNode => { const t = useTranslations("@vitnode/blog.list"); const [first, second] = authors; const authorName = (author: BlogPostAuthor) => (