diff --git a/rivetkit-typescript/packages/rivetkit/fixtures/driver-test-suite/access-control.ts b/rivetkit-typescript/packages/rivetkit/fixtures/driver-test-suite/access-control.ts new file mode 100644 index 0000000000..57fa06b9c3 --- /dev/null +++ b/rivetkit-typescript/packages/rivetkit/fixtures/driver-test-suite/access-control.ts @@ -0,0 +1,95 @@ +import { actor, event, queue } from "rivetkit"; + +interface AccessControlConnParams { + allowRequest?: boolean; + allowWebSocket?: boolean; + invalidCanInvokeReturn?: boolean; +} + +export const accessControlActor = actor({ + state: { + lastCanInvokeConnId: "", + }, + events: { + allowedEvent: event<{ value: string }>(), + blockedEvent: event<{ value: string }>(), + }, + queues: { + allowedQueue: queue<{ value: string }>(), + blockedQueue: queue<{ value: string }>(), + }, + canInvoke: (c, invoke) => { + c.state.lastCanInvokeConnId = c.conn.id; + const params = c.conn.params as AccessControlConnParams | undefined; + if (params?.invalidCanInvokeReturn) { + return undefined as never; + } + + if (invoke.kind === "action") { + if (invoke.name.startsWith("allowed")) { + return true; + } + return false; + } + + if (invoke.kind === "queue") { + if (invoke.name === "allowedQueue") { + return true; + } + return false; + } + + if (invoke.kind === "subscribe") { + if (invoke.name === "allowedEvent") { + return true; + } + return false; + } + + if (invoke.kind === "request") { + if (params?.allowRequest === true) { + return true; + } + return false; + } + + if (invoke.kind === "websocket") { + if (params?.allowWebSocket === true) { + return true; + } + return false; + } + + return false; + }, + onRequest(_c, request) { + const url = new URL(request.url); + if (url.pathname === "/status") { + return Response.json({ ok: true }); + } + return new Response("Not Found", { status: 404 }); + }, + onWebSocket(_c, websocket) { + websocket.send(JSON.stringify({ type: "welcome" })); + }, + actions: { + allowedAction: (_c, value: string) => { + return `allowed:${value}`; + }, + blockedAction: () => { + return "blocked"; + }, + allowedGetLastCanInvokeConnId: (c) => { + return c.state.lastCanInvokeConnId; + }, + allowedReceiveQueue: async (c) => { + const [message] = await c.queue.tryNext({ + names: ["allowedQueue"], + }); + return message?.body ?? null; + }, + allowedBroadcastAllowedEvent: (c, value: string) => { + c.broadcast("allowedEvent", { value }); + }, + }, +}); diff --git a/rivetkit-typescript/packages/rivetkit/fixtures/driver-test-suite/registry.ts b/rivetkit-typescript/packages/rivetkit/fixtures/driver-test-suite/registry.ts index 38b5f89132..72fcf28612 100644 --- a/rivetkit-typescript/packages/rivetkit/fixtures/driver-test-suite/registry.ts +++ b/rivetkit-typescript/packages/rivetkit/fixtures/driver-test-suite/registry.ts @@ -1,4 +1,5 @@ import { setup } from "rivetkit"; +import { accessControlActor } from "./access-control"; import { inputActor } from "./action-inputs"; import { @@ -160,5 +161,7 @@ export const registry = setup({ dbActorDrizzle, // From stateless.ts statelessActor, + // From access-control.ts + accessControlActor, }, }); diff --git a/rivetkit-typescript/packages/rivetkit/src/actor/config.ts b/rivetkit-typescript/packages/rivetkit/src/actor/config.ts index 152d2c563e..e772dd5680 100644 --- a/rivetkit-typescript/packages/rivetkit/src/actor/config.ts +++ b/rivetkit-typescript/packages/rivetkit/src/actor/config.ts @@ -6,6 +6,7 @@ import type { ActorContext, BeforeActionResponseContext, BeforeConnectContext, + ConnContext, ConnectContext, CreateConnStateContext, CreateContext, @@ -184,6 +185,7 @@ export const ActorConfigSchema = z run: zRunHandler, onStateChange: zFunction().optional(), onBeforeConnect: zFunction().optional(), + canInvoke: zFunction().optional(), onConnect: zFunction().optional(), onDisconnect: zFunction().optional(), onBeforeActionResponse: zFunction().optional(), @@ -399,6 +401,60 @@ export interface Actions< */ export type AuthIntent = "get" | "create" | "connect" | "action" | "message"; +type CanInvokeActionName = keyof TActions extends never + ? string + : keyof TActions & string; + +type CanInvokeSubscribeName = + keyof TEvents extends never ? string : keyof TEvents & string; + +type CanInvokeQueueName = + keyof TQueues extends never ? string : keyof TQueues & string; + +export type CanInvokeTarget< + TActions, + TEvents extends EventSchemaConfig, + TQueues extends QueueSchemaConfig, +> = + | { + kind: "action"; + name: CanInvokeActionName; + } + | { + kind: "subscribe"; + name: CanInvokeSubscribeName; + } + | { + kind: "queue"; + name: CanInvokeQueueName; + } + | { + kind: "request"; + } + | { + kind: "websocket"; + }; + +export type AnyCanInvokeTarget = + | { + kind: "action"; + name: string; + } + | { + kind: "subscribe"; + name: string; + } + | { + kind: "queue"; + name: string; + } + | { + kind: "request"; + } + | { + kind: "websocket"; + }; + interface BaseActorConfig< TState, TConnParams, @@ -584,6 +640,29 @@ interface BaseActorConfig< params: TConnParams, ) => void | Promise; + /** + * Called before inbound invocations are processed. + * + * Return `true` to allow and `false` to deny. + * Returning any non-boolean value throws an error. + * + * This hook runs for inbound actions, queue sends, subscriptions, + * raw HTTP requests, and raw WebSocket connections. + */ + canInvoke?: ( + c: ConnContext< + TState, + TConnParams, + TConnState, + TVars, + TInput, + TDatabase, + TEvents, + TQueues + >, + invoke: CanInvokeTarget, + ) => boolean | Promise; + /** * Called when a client successfully connects to the actor. * @@ -771,6 +850,7 @@ export type ActorConfig< | "run" | "onStateChange" | "onBeforeConnect" + | "canInvoke" | "onConnect" | "onDisconnect" | "onBeforeActionResponse" @@ -877,6 +957,7 @@ export type ActorConfigInput< | "run" | "onStateChange" | "onBeforeConnect" + | "canInvoke" | "onConnect" | "onDisconnect" | "onBeforeActionResponse" @@ -1175,6 +1256,12 @@ export const DocActorConfigSchema = z .describe( "Called before a client connects. Throw an error to reject the connection.", ), + canInvoke: z + .unknown() + .optional() + .describe( + "Called before inbound invocation entrypoints. Return true to allow or false to deny.", + ), onConnect: z .unknown() .optional() diff --git a/rivetkit-typescript/packages/rivetkit/src/actor/errors.ts b/rivetkit-typescript/packages/rivetkit/src/actor/errors.ts index d31f3cc339..0a33cb86d2 100644 --- a/rivetkit-typescript/packages/rivetkit/src/actor/errors.ts +++ b/rivetkit-typescript/packages/rivetkit/src/actor/errors.ts @@ -449,6 +449,17 @@ export class InvalidRequestHandlerResponse extends ActorError { } } +export class InvalidCanInvokeResponse extends ActorError { + constructor() { + super( + "handler", + "invalid_can_invoke_response", + "Actor's canInvoke hook must return a boolean value.", + ); + this.statusCode = 500; + } +} + // Manager-specific errors export class MissingActorHeader extends ActorError { constructor() { diff --git a/rivetkit-typescript/packages/rivetkit/src/actor/instance/mod.ts b/rivetkit-typescript/packages/rivetkit/src/actor/instance/mod.ts index 78a6fa97d4..ef794abb9a 100644 --- a/rivetkit-typescript/packages/rivetkit/src/actor/instance/mod.ts +++ b/rivetkit-typescript/packages/rivetkit/src/actor/instance/mod.ts @@ -18,7 +18,11 @@ import { CONN_VERSIONED, } from "@/schemas/actor-persist/versioned"; import { EXTRA_ERROR_LOG } from "@/utils"; -import { type ActorConfig, getRunFunction } from "../config"; +import { + type AnyCanInvokeTarget, + type ActorConfig, + getRunFunction, +} from "../config"; import type { ConnDriver } from "../conn/driver"; import { createHttpDriver } from "../conn/drivers/http"; import { @@ -32,8 +36,9 @@ import { type PersistedConn, } from "../conn/persisted"; import { - type ActionContext, + ActionContext, ActorContext, + type ConnContext, RequestContext, WebSocketContext, } from "../contexts"; @@ -609,6 +614,32 @@ export class ActorInstance< }); } + async assertCanInvoke( + ctx: ConnContext, + invoke: AnyCanInvokeTarget, + ): Promise { + const canInvoke = this.#config.canInvoke; + if (!canInvoke) { + return; + } + + const result = await canInvoke(ctx, invoke); + if (typeof result !== "boolean") { + throw new errors.InvalidCanInvokeResponse(); + } + if (!result) { + throw new errors.Forbidden(); + } + } + + async assertCanInvokeWebSocket( + conn: Conn, + ): Promise { + await this.assertCanInvoke(new ActionContext(this, conn), { + kind: "websocket", + }); + } + // MARK: - Action Execution async executeAction( ctx: ActionContext, @@ -616,6 +647,10 @@ export class ActorInstance< args: unknown[], ): Promise { this.assertReady(); + await this.assertCanInvoke(ctx, { + kind: "action", + name: actionName, + }); const actions = this.#config.actions ?? {}; if (!(actionName in actions)) { @@ -739,8 +774,11 @@ export class ActorInstance< "rivet.conn.id": conn.id, }, async () => { + const ctx = new RequestContext(this, conn, request); try { - const ctx = new RequestContext(this, conn, request); + await this.assertCanInvoke(ctx, { + kind: "request", + }); const response = await onRequest(ctx, request); if (!response) { throw new errors.InvalidRequestHandlerResponse(); diff --git a/rivetkit-typescript/packages/rivetkit/src/actor/protocol/old.ts b/rivetkit-typescript/packages/rivetkit/src/actor/protocol/old.ts index 85c19fcb39..6dd98a63f3 100644 --- a/rivetkit-typescript/packages/rivetkit/src/actor/protocol/old.ts +++ b/rivetkit-typescript/packages/rivetkit/src/actor/protocol/old.ts @@ -274,6 +274,16 @@ export async function processMessage< }); if (subscribe) { + await actor.assertCanInvoke( + new ActionContext( + actor, + conn, + ), + { + kind: "subscribe", + name: eventName, + }, + ); await handler.onSubscribe(eventName, conn); } else { await handler.onUnsubscribe(eventName, conn); diff --git a/rivetkit-typescript/packages/rivetkit/src/actor/router-endpoints.ts b/rivetkit-typescript/packages/rivetkit/src/actor/router-endpoints.ts index d81c81c453..df2dd71534 100644 --- a/rivetkit-typescript/packages/rivetkit/src/actor/router-endpoints.ts +++ b/rivetkit-typescript/packages/rivetkit/src/actor/router-endpoints.ts @@ -235,6 +235,12 @@ export async function handleQueueSend( status: "completed", }; try { + const ctx = new ActionContext(actor, conn); + await actor.assertCanInvoke(ctx, { + kind: "queue", + name, + }); + if (request.wait) { result = await actor.queueManager.enqueueAndWait( name, diff --git a/rivetkit-typescript/packages/rivetkit/src/actor/router-websocket-endpoints.ts b/rivetkit-typescript/packages/rivetkit/src/actor/router-websocket-endpoints.ts index c5851dd69f..3f5e3982a3 100644 --- a/rivetkit-typescript/packages/rivetkit/src/actor/router-websocket-endpoints.ts +++ b/rivetkit-typescript/packages/rivetkit/src/actor/router-websocket-endpoints.ts @@ -96,6 +96,7 @@ export async function routeWebSocket( // Route WebSocket & create driver let handler: WebSocketHandler; let connDriver: ConnDriver; + let isRawWebSocketRoute = false; if (requestPathWithoutQuery === PATH_CONNECT) { const { driver, setWebSocket } = createWebSocketDriver( isHibernatable @@ -111,6 +112,7 @@ export async function routeWebSocket( requestPathWithoutQuery === PATH_WEBSOCKET_BASE || requestPathWithoutQuery.startsWith(PATH_WEBSOCKET_PREFIX) ) { + isRawWebSocketRoute = true; const { driver, setWebSocket } = createRawWebSocketDriver( isHibernatable ? { gatewayId: gatewayId!, requestId: requestId! } @@ -158,6 +160,10 @@ export async function routeWebSocket( ); createdConn = conn; + if (isRawWebSocketRoute) { + await actor.assertCanInvokeWebSocket(conn); + } + // Create handler // // This must call actor.connectionManager.connectConn in onOpen. diff --git a/rivetkit-typescript/packages/rivetkit/src/driver-test-suite/mod.ts b/rivetkit-typescript/packages/rivetkit/src/driver-test-suite/mod.ts index f70cdf941b..cf3590672e 100644 --- a/rivetkit-typescript/packages/rivetkit/src/driver-test-suite/mod.ts +++ b/rivetkit-typescript/packages/rivetkit/src/driver-test-suite/mod.ts @@ -12,6 +12,7 @@ import { } from "@/registry/config"; import { logger } from "./log"; import { runActionFeaturesTests } from "./tests/action-features"; +import { runAccessControlTests } from "./tests/access-control"; import { runActorConnTests } from "./tests/actor-conn"; import { runActorConnHibernationTests } from "./tests/actor-conn-hibernation"; import { runActorConnStateTests } from "./tests/actor-conn-state"; @@ -120,6 +121,8 @@ export function runDriverTests( runActionFeaturesTests(driverTestConfig); + runAccessControlTests(driverTestConfig); + runActorVarsTests(driverTestConfig); runActorMetadataTests(driverTestConfig); diff --git a/rivetkit-typescript/packages/rivetkit/src/driver-test-suite/tests/access-control.ts b/rivetkit-typescript/packages/rivetkit/src/driver-test-suite/tests/access-control.ts new file mode 100644 index 0000000000..85e319776d --- /dev/null +++ b/rivetkit-typescript/packages/rivetkit/src/driver-test-suite/tests/access-control.ts @@ -0,0 +1,176 @@ +import { describe, expect, test } from "vitest"; +import type { DriverTestConfig } from "../mod"; +import { setupDriverTest } from "../utils"; + +export function runAccessControlTests(driverTestConfig: DriverTestConfig) { + describe("access control", () => { + test("allows configured actions and denies others", async (c) => { + const { client } = await setupDriverTest(c, driverTestConfig); + const handle = client.accessControlActor.getOrCreate(["actions"]); + + const allowed = await handle.allowedAction("ok"); + expect(allowed).toBe("allowed:ok"); + + await expect(handle.blockedAction()).rejects.toMatchObject({ + code: "forbidden", + }); + }); + + test("passes connection id into canInvoke context", async (c) => { + const { client } = await setupDriverTest(c, driverTestConfig); + const handle = client.accessControlActor.getOrCreate(["conn-id"]); + + const connId = await handle.allowedGetLastCanInvokeConnId(); + expect(typeof connId).toBe("string"); + expect(connId.length).toBeGreaterThan(0); + }); + + test("allows and denies queue sends", async (c) => { + const { client } = await setupDriverTest(c, driverTestConfig); + const handle = client.accessControlActor.getOrCreate(["queue"]); + + await handle.send("allowedQueue", { value: "one" }); + await expect( + handle.send("blockedQueue", { value: "two" }), + ).rejects.toMatchObject({ + code: "forbidden", + }); + + const message = await handle.allowedReceiveQueue(); + expect(message).toEqual({ value: "one" }); + }); + + test("allows and denies subscriptions", async (c) => { + const { client } = await setupDriverTest(c, driverTestConfig); + const handle = client.accessControlActor.getOrCreate([ + "subscription", + ]); + const conn = handle.connect(); + + const allowedEventPromise = new Promise<{ value: string }>( + (resolve, reject) => { + const unsubscribeError = conn.onError((error) => { + reject(error); + }); + const unsubscribeEvent = conn.on( + "allowedEvent", + (payload) => { + unsubscribeError(); + unsubscribeEvent(); + resolve(payload as { value: string }); + }, + ); + }, + ); + + await conn.allowedBroadcastAllowedEvent("hello"); + expect(await allowedEventPromise).toEqual({ value: "hello" }); + + const blockedErrorPromise = new Promise<{ code: string }>( + (resolve) => { + const unsubscribe = conn.onError((error) => { + unsubscribe(); + resolve(error as { code: string }); + }); + conn.on("blockedEvent", () => { }); + }, + ); + + const blockedError = await blockedErrorPromise; + expect(blockedError.code).toBe("forbidden"); + + await conn.dispose(); + }); + + test("allows and denies raw request handlers", async (c) => { + const { client } = await setupDriverTest(c, driverTestConfig); + + const allowedHandle = client.accessControlActor.getOrCreate( + ["raw-request-allow"], + { + params: { allowRequest: true }, + }, + ); + const deniedHandle = client.accessControlActor.getOrCreate( + ["raw-request-deny"], + { + params: { allowRequest: false }, + }, + ); + + const allowedResponse = await allowedHandle.fetch("/status"); + expect(allowedResponse.status).toBe(200); + expect(await allowedResponse.json()).toEqual({ ok: true }); + + const deniedResponse = await deniedHandle.fetch("/status"); + expect(deniedResponse.status).toBe(403); + }); + + test("allows and denies raw websocket handlers", async (c) => { + const { client } = await setupDriverTest(c, driverTestConfig); + + const allowedHandle = client.accessControlActor.getOrCreate( + ["raw-websocket-allow"], + { + params: { allowWebSocket: true }, + }, + ); + const ws = await allowedHandle.webSocket(); + const welcome = await new Promise<{ type: string }>((resolve) => { + ws.addEventListener( + "message", + (event: any) => { + resolve( + JSON.parse(event.data as string) as { + type: string; + }, + ); + }, + { once: true }, + ); + }); + expect(welcome.type).toBe("welcome"); + ws.close(); + + const deniedHandle = client.accessControlActor.getOrCreate( + ["raw-websocket-deny"], + { + params: { allowWebSocket: false }, + }, + ); + + let denied = false; + try { + const deniedWs = await deniedHandle.webSocket(); + const closeEvent = await new Promise((resolve) => { + deniedWs.addEventListener( + "close", + (event: any) => { + resolve(event); + }, + { once: true }, + ); + }); + expect(closeEvent.code).toBe(1011); + denied = true; + } catch { + denied = true; + } + expect(denied).toBe(true); + }); + + test("throws when canInvoke does not return boolean", async (c) => { + const { client } = await setupDriverTest(c, driverTestConfig); + const handle = client.accessControlActor.getOrCreate( + ["invalid-return"], + { + params: { invalidCanInvokeReturn: true }, + }, + ); + + await expect(handle.allowedAction("x")).rejects.toMatchObject({ + code: "internal_error", + }); + }); + }); +} diff --git a/website/src/content/docs/actors/access-control.mdx b/website/src/content/docs/actors/access-control.mdx new file mode 100644 index 0000000000..8d79d27289 --- /dev/null +++ b/website/src/content/docs/actors/access-control.mdx @@ -0,0 +1,97 @@ +--- +title: "Access Control" +description: "Authorize inbound actor entrypoints with the canInvoke hook." +skill: true +--- + +Use `canInvoke` to allow or deny inbound actor entrypoints. + +This is authorization, not authentication: + +- Use [authentication](/docs/actors/authentication) to identify who is calling. +- Use `canInvoke` to decide what they are allowed to do. + +## Supported Entrypoints + +`canInvoke` runs for inbound: + +- Actions (`kind: "action"`) +- Queue sends (`kind: "queue"`) +- Event subscriptions (`kind: "subscribe"`) +- Raw HTTP handler requests (`kind: "request"`) +- Raw WebSocket handler connections (`kind: "websocket"`) + +## Fail By Default + +Structure `canInvoke` as fail-by-default: + +1. Add explicit allow rules with `if` statements. +2. End with `return false`. + +```ts +import { actor } from "rivetkit"; + +export const chatRoom = actor({ + canInvoke: (c, invoke) => { + // Example: block a specific connection. + if (c.conn.id === "blocked-conn-id") { + return false; + } + + if (invoke.kind === "action" && invoke.name === "sendMessage") { + return true; + } + + if (invoke.kind === "queue" && invoke.name === "jobs") { + return true; + } + + if (invoke.kind === "subscribe" && invoke.name === "messages") { + return true; + } + + if (invoke.kind === "request") { + return true; + } + + if (invoke.kind === "websocket") { + return true; + } + + return false; + }, + actions: { + sendMessage: () => {}, + }, +}); +``` + +## Return Value Contract + +`canInvoke` must return a boolean: + +- `true`: allow invocation +- `false`: deny invocation with `forbidden` + +Returning `undefined`, `null`, or any non-boolean throws an internal error. + +## Hook Shape + +```ts +type InvokeTarget = + | { kind: "action"; name: string } + | { kind: "queue"; name: string } + | { kind: "subscribe"; name: string } + | { kind: "request" } + | { kind: "websocket" }; + +canInvoke?: ( + c: ConnContext<...>, + invoke: InvokeTarget, +) => boolean | Promise; +``` + +## Notes + +- This hook applies to inbound client invocations. +- Denied invocations return `forbidden` to the client. diff --git a/website/src/content/docs/actors/authentication.mdx b/website/src/content/docs/actors/authentication.mdx index 864d534ed4..6af9eb6674 100644 --- a/website/src/content/docs/actors/authentication.mdx +++ b/website/src/content/docs/actors/authentication.mdx @@ -28,6 +28,10 @@ Authentication is configured through either: - `onBeforeConnect` for simple pass/fail validation - `createConnState` when you need to access user data in your actions via `c.conn.state` +## Access Control + +After a connection is authenticated, use [Access Control](/docs/actors/access-control) to enforce per-entrypoint permissions with `canInvoke`. + ### `onBeforeConnect` The `onBeforeConnect` hook validates credentials before allowing a connection. Throw an error to reject the connection. @@ -584,4 +588,3 @@ const cachedAuthActor = actor({ - [`AuthIntent`](/typedoc/types/rivetkit.mod.AuthIntent.html) - Authentication intent type - [`BeforeConnectContext`](/typedoc/interfaces/rivetkit.mod.BeforeConnectContext.html) - Context for auth checks - [`ConnectContext`](/typedoc/interfaces/rivetkit.mod.ConnectContext.html) - Context after connection - diff --git a/website/src/content/docs/actors/lifecycle.mdx b/website/src/content/docs/actors/lifecycle.mdx index e99c265225..807273bc07 100644 --- a/website/src/content/docs/actors/lifecycle.mdx +++ b/website/src/content/docs/actors/lifecycle.mdx @@ -45,6 +45,11 @@ Actors transition through several states during their lifetime. Each transition 1. `onDisconnect` +**On Inbound Invoke** (per inbound action/queue/subscribe/request/websocket) + +1. `canInvoke` +2. Entry point handler executes (`action`, queue send, subscription, `onRequest`, or `onWebSocket`) + ## Lifecycle Hooks Actor lifecycle hooks are defined as functions in the actor configuration. @@ -436,6 +441,47 @@ const chatRoom = actor({ Messages will not be processed for this actor until this hook succeeds. Errors thrown from this hook will cause the client to disconnect. +### `canInvoke` + +[API Reference](/typedoc/interfaces/rivetkit.mod.ActorDefinition.html) + +The `canInvoke` hook authorizes inbound invocations. Can be async. + +It runs for: + +- Actions +- Queue sends +- Subscriptions +- Raw request handler invocations (`onRequest`) +- Raw WebSocket handler invocations (`onWebSocket`) + +Return `true` to allow and `false` to deny. This hook must return a boolean. + +```typescript +import { actor } from "rivetkit"; + +const securedActor = actor({ + canInvoke: (c, invoke) => { + if (invoke.kind === "action" && invoke.name === "publicAction") { + return true; + } + + if (invoke.kind === "request") { + return true; + } + + return false; + }, + + actions: { + publicAction: () => "ok", + privateAction: () => "secret", + }, +}); +``` + +Use this hook as fail-by-default. Add explicit allow rules, then end with `return false`. See [Access Control](/docs/actors/access-control) for full guidance. + ### `onDisconnect` [API Reference](/typedoc/interfaces/rivetkit.mod.ActorDefinition.html) diff --git a/website/src/sitemap/mod.ts b/website/src/sitemap/mod.ts index cb4969c40c..f127efc8cc 100644 --- a/website/src/sitemap/mod.ts +++ b/website/src/sitemap/mod.ts @@ -164,6 +164,10 @@ export const sitemap = [ href: "/docs/actors/authentication", //icon: faFingerprint, }, + { + title: "Access Control", + href: "/docs/actors/access-control", + }, { title: "Connections", href: "/docs/actors/connections",