From 99fa53da6ea58ad6c5f4b113246f200bc82f4004 Mon Sep 17 00:00:00 2001 From: "rivet-docs-sync[bot]" Date: Thu, 24 Sep 2026 12:39:02 +0000 Subject: [PATCH] docs(actors): sync from rivet-dev/rivet@f1c460d --- .../docs/content/docs/access-control.mdx | 47 ------ .../docs/content/docs/authentication.mdx | 124 --------------- .../actors/docs/content/docs/connections.mdx | 2 +- .../actors/docs/content/docs/crash-course.mdx | 2 +- vendor/actors/docs/content/docs/lifecycle.mdx | 4 +- .../actors/docs/content/docs/permissions.mdx | 150 ++++++++++++++++++ .../content/docs/production-checklist.mdx | 9 +- vendor/actors/docs/content/docs/queues.mdx | 2 +- vendor/actors/docs/content/learn/ai-agent.mdx | 2 +- .../docs/content/learn/authentication.mdx | 84 ++++++++++ .../actors/docs/content/learn/chat-room.mdx | 2 +- .../learn/collaborative-text-editor.mdx | 2 +- .../content/learn/per-tenant-database.mdx | 6 +- vendor/actors/docs/sidebar.json | 16 +- .../docs/actors-access-control/chat-room.ts | 83 ---------- .../actors-authentication/caching-tokens.ts | 61 ------- .../create-conn-state.ts | 55 ------- .../external-auth-provider.ts | 37 ----- .../docs/actors-authentication/jwt.ts | 52 ------ .../on-before-connect.ts | 34 ---- .../actors-authentication/rate-limiting.ts | 45 ------ .../role-based-access-control.ts | 52 ------ .../docs/actors-authentication/using-state.ts | 23 --- .../docs/actors-permissions/caching-tokens.ts | 63 ++++++++ .../docs/actors-permissions/chat-room.ts | 81 ++++++++++ .../actors-permissions/create-conn-state.ts | 60 +++++++ .../external-auth-provider.ts | 42 +++++ .../handling-errors-connection.ts | 20 +-- .../handling-errors-stateless.ts | 25 +-- .../examples/docs/actors-permissions/jwt.ts | 55 +++++++ .../actors-permissions/on-before-connect.ts | 37 +++++ .../passing-credentials-connection.ts | 8 +- .../passing-credentials-headers.ts | 6 +- .../passing-credentials-stateless.ts | 2 +- .../actors-permissions/quickstart/client.ts | 20 +++ .../actors-permissions/quickstart/index.ts | 44 +++++ .../docs/actors-permissions/rate-limiting.ts | 47 ++++++ .../role-based-access-control.ts | 61 +++++++ .../docs/actors-permissions/using-state.ts | 25 +++ .../examples/docs/general-jwt/grants.ts | 36 +++++ .../docs/general-jwt/quickstart/client.ts | 27 ++++ .../docs/general-jwt/quickstart/registry.ts | 17 ++ .../docs/general-jwt/quickstart/server.ts | 35 ++++ .../packages/rivetkit/package.json | 1 + 44 files changed, 941 insertions(+), 665 deletions(-) delete mode 100644 vendor/actors/docs/content/docs/access-control.mdx delete mode 100644 vendor/actors/docs/content/docs/authentication.mdx create mode 100644 vendor/actors/docs/content/docs/permissions.mdx create mode 100644 vendor/actors/docs/content/learn/authentication.mdx delete mode 100644 vendor/actors/examples/docs/actors-access-control/chat-room.ts delete mode 100644 vendor/actors/examples/docs/actors-authentication/caching-tokens.ts delete mode 100644 vendor/actors/examples/docs/actors-authentication/create-conn-state.ts delete mode 100644 vendor/actors/examples/docs/actors-authentication/external-auth-provider.ts delete mode 100644 vendor/actors/examples/docs/actors-authentication/jwt.ts delete mode 100644 vendor/actors/examples/docs/actors-authentication/on-before-connect.ts delete mode 100644 vendor/actors/examples/docs/actors-authentication/rate-limiting.ts delete mode 100644 vendor/actors/examples/docs/actors-authentication/role-based-access-control.ts delete mode 100644 vendor/actors/examples/docs/actors-authentication/using-state.ts create mode 100644 vendor/actors/examples/docs/actors-permissions/caching-tokens.ts create mode 100644 vendor/actors/examples/docs/actors-permissions/chat-room.ts create mode 100644 vendor/actors/examples/docs/actors-permissions/create-conn-state.ts create mode 100644 vendor/actors/examples/docs/actors-permissions/external-auth-provider.ts rename vendor/actors/examples/docs/{actors-authentication => actors-permissions}/handling-errors-connection.ts (61%) rename vendor/actors/examples/docs/{actors-authentication => actors-permissions}/handling-errors-stateless.ts (51%) create mode 100644 vendor/actors/examples/docs/actors-permissions/jwt.ts create mode 100644 vendor/actors/examples/docs/actors-permissions/on-before-connect.ts rename vendor/actors/examples/docs/{actors-authentication => actors-permissions}/passing-credentials-connection.ts (75%) rename vendor/actors/examples/docs/{actors-authentication => actors-permissions}/passing-credentials-headers.ts (84%) rename vendor/actors/examples/docs/{actors-authentication => actors-permissions}/passing-credentials-stateless.ts (86%) create mode 100644 vendor/actors/examples/docs/actors-permissions/quickstart/client.ts create mode 100644 vendor/actors/examples/docs/actors-permissions/quickstart/index.ts create mode 100644 vendor/actors/examples/docs/actors-permissions/rate-limiting.ts create mode 100644 vendor/actors/examples/docs/actors-permissions/role-based-access-control.ts create mode 100644 vendor/actors/examples/docs/actors-permissions/using-state.ts create mode 100644 vendor/actors/examples/docs/general-jwt/grants.ts create mode 100644 vendor/actors/examples/docs/general-jwt/quickstart/client.ts create mode 100644 vendor/actors/examples/docs/general-jwt/quickstart/registry.ts create mode 100644 vendor/actors/examples/docs/general-jwt/quickstart/server.ts diff --git a/vendor/actors/docs/content/docs/access-control.mdx b/vendor/actors/docs/content/docs/access-control.mdx deleted file mode 100644 index 8c64d2ad..00000000 --- a/vendor/actors/docs/content/docs/access-control.mdx +++ /dev/null @@ -1,47 +0,0 @@ ---- -title: "Access Control" -description: "Authorize Rivet Actor actions, queue publishes, and event subscriptions with hooks that enforce access rules for each client connection." -skill: true ---- - -Use access control to decide what authenticated clients are allowed to do. - -This is authorization, not authentication: - -- Use [authentication](/actors/docs/authentication) to identify who is calling. -- Use access-control rules to decide what they can do after connecting. - -## Permission Surfaces - -RivetKit authorization is explicit per surface: - -- `onBeforeConnect` rejects unauthenticated or malformed connections. -- Action handlers (`actions.*`) enforce action permissions. -- `queues..canPublish` allows or denies inbound queue publishes. -- `events..canSubscribe` allows or denies event subscriptions. - -## Fail By Default - -Use deny-by-default rules everywhere: - -1. Keep `onBeforeConnect` strict and reject invalid credentials. -2. In each action, explicitly allow expected roles and throw `forbidden` otherwise. -3. In `canPublish` and `canSubscribe`, return `true` only for allowed roles and end with `return false`. - - - -## Return Value Contract - -`canPublish` and `canSubscribe` must return a boolean: - -- `true`: allow -- `false`: deny with `forbidden` - -Returning `undefined`, `null`, or any non-boolean throws an internal error. - -## Notes - -- `canPublish` only applies to queue names defined in `queues`. -- Incoming queue messages for undefined queues are ignored and the publish succeeds as completed. -- `canSubscribe` only applies to event names defined in `events`. -- Broadcasting an event not defined in `events` still publishes to subscribers. diff --git a/vendor/actors/docs/content/docs/authentication.mdx b/vendor/actors/docs/content/docs/authentication.mdx deleted file mode 100644 index 62f7f32f..00000000 --- a/vendor/actors/docs/content/docs/authentication.mdx +++ /dev/null @@ -1,124 +0,0 @@ ---- -title: "Authentication" -description: "Authenticate Rivet Actor connections, validate credentials before clients connect, and carry trusted identity into actor actions." -skill: true ---- - -## Do You Need Authentication? - - - - Actors are private by default on Rivet Cloud. Only requests with the publishable token can interact with actors. - - - **Backend-only actors**: If your publishable token is only included in your backend, then authentication is not necessary. - - **Frontend-accessible actors**: If your publishable token is included in your frontend, then implementing authentication is recommended. - - - Actors are public by default on self-hosted Rivet. Anyone can access them without a token. - - - **Only accessible within private network**: If Rivet is only accessible within your private network, then authentication is not necessary. - - **Rivet exposed to the public internet**: If Rivet is configured to accept traffic from the public internet, then implementing authentication is recommended. - - - -## Authentication Connections - -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](/actors/docs/access-control) to enforce authorization: - -- Check permissions in action handlers. -- Use `queues..canPublish` to gate inbound queue publishes. -- Use `events..canSubscribe` to gate event subscriptions. - -### `onBeforeConnect` - -The `onBeforeConnect` hook validates credentials before allowing a connection. Throw an error to reject the connection. - - - -### `createConnState` - -Use `createConnState` to extract user data from credentials and store it in connection state. This data is accessible in actions via `c.conn.state`. Like `onBeforeConnect`, throwing an error will reject the connection. See [connections](/actors/docs/connections) for more details. - - - -## Available Auth Data - -Authentication hooks have access to several properties: - -| Property | Description | -|----------|-------------| -| `params` | Custom data passed by the client when connecting (see [connection params](/actors/docs/connections#extracting-data-from-connection-params)) | -| `c.request` | The underlying HTTP request object | -| `c.request.headers` | Request headers for tokens, API keys (does not work for `.connect()`) | -| `c.state` | Actor state for authorization decisions (see [state](/actors/docs/state)) | -| `c.key` | The actor's key (see [keys](/actors/docs/keys)) | - -It's recommended to use `params` instead of `c.request.headers` whenever possible since it works for both HTTP & WebSocket connections. - -## Client Usage - -### Passing Credentials - -Pass authentication data when connecting. Use `getParams` when you need a fresh JWT for every connection or reconnect: - - - - - - - -### Handling Errors - -Authentication errors use the same system as regular errors. See [errors](/actors/docs/errors) for more details. - - - - - - -## Examples - -### JWT - -Validate JSON Web Tokens and extract user claims: - - - -### External Auth Provider - -Validate credentials against an external authentication service: - - - -### Using `c.state` In Authorization - -Access actor state via `c.state` and the actor's key via `c.key` to make authorization decisions: - - - -### Role-Based Access Control - -Create helper functions for common authorization patterns: - - - -### Rate Limiting - -Use `c.vars` to track connection attempts and rate limit by user: - - - -The limits in this example are [ephemeral](/actors/docs/state#ephemeral-variables). If you wish to persist rate limits, you can optionally replace `vars` with `state`. - -### Caching Tokens - -Cache validated tokens in `c.vars` to avoid redundant validation on repeated connections. See [ephemeral variables](/actors/docs/state#ephemeral-variables) for more details. - - diff --git a/vendor/actors/docs/content/docs/connections.mdx b/vendor/actors/docs/content/docs/connections.mdx index ef3ceaf9..074c214c 100644 --- a/vendor/actors/docs/content/docs/connections.mdx +++ b/vendor/actors/docs/content/docs/connections.mdx @@ -74,7 +74,7 @@ Connections are not visible in `c.conns` while `onBeforeConnect` is running. -Connections cannot interact with the actor until this method completes successfully. Throwing an error will abort the connection. This can be used for authentication, see [Authentication](/actors/docs/authentication) for details. +Connections cannot interact with the actor until this method completes successfully. Throwing an error will abort the connection. This can be used for authentication, see [Permissions](/actors/docs/permissions) for details. ### `onConnect` diff --git a/vendor/actors/docs/content/docs/crash-course.mdx b/vendor/actors/docs/content/docs/crash-course.mdx index 9b462af7..bdfb4bdf 100644 --- a/vendor/actors/docs/content/docs/crash-course.mdx +++ b/vendor/actors/docs/content/docs/crash-course.mdx @@ -277,7 +277,7 @@ Use this pattern for long-lived, durable workflows that initialize resources, pr - Use `c.conn.state` to securely identify users in actions rather than trusting action parameters. - For cross-origin access, validate the request origin in `onBeforeConnect`. -[Authentication Documentation](/actors/docs/authentication) · [CORS Documentation](/actors/docs/cors) +[Authentication Documentation](/docs/authentication) · [Permissions Documentation](/actors/docs/permissions) · [CORS Documentation](/actors/docs/cors) ### Versions & Upgrades diff --git a/vendor/actors/docs/content/docs/lifecycle.mdx b/vendor/actors/docs/content/docs/lifecycle.mdx index 8d06f75f..5e9ee0b8 100644 --- a/vendor/actors/docs/content/docs/lifecycle.mdx +++ b/vendor/actors/docs/content/docs/lifecycle.mdx @@ -191,7 +191,7 @@ The `onBeforeConnect` hook does NOT return connection state - it's used solely f -Connections cannot interact with the actor until this method completes successfully. Throwing an error will abort the connection. This can be used for authentication - see [Authentication](/actors/docs/authentication) for details. +Connections cannot interact with the actor until this method completes successfully. Throwing an error will abort the connection. This can be used for authentication, see [Permissions](/actors/docs/permissions) for details. ### `onConnect` @@ -212,7 +212,7 @@ For actions, enforce authorization directly inside each action handler. -Use deny-by-default rules for each hook and return `false` unless explicitly allowed. See [Access Control](/actors/docs/access-control) for full guidance. +Use deny-by-default rules for each hook and return `false` unless explicitly allowed. See [Permissions](/actors/docs/permissions) for full guidance. ### `onDisconnect` diff --git a/vendor/actors/docs/content/docs/permissions.mdx b/vendor/actors/docs/content/docs/permissions.mdx new file mode 100644 index 00000000..05b987bd --- /dev/null +++ b/vendor/actors/docs/content/docs/permissions.mdx @@ -0,0 +1,150 @@ +--- +title: "Permissions" +seoTitle: "Authorize Callers Inside Rivet Actors" +description: "Identify callers when they connect to a Rivet Actor, then authorize every action, queue publish, and event subscription with deny-by-default rules." +skill: true +--- + +Permissions are enforced inside your actor, on a caller that has already reached it. This is the only layer that can see `c.state`, `c.key`, and action arguments, so every domain rule lives here. + +The layer above it decides which actor a client may reach at all. See [Authentication](/docs/authentication) for that, and [JWTs](/docs/jwt) to scope a client to a single actor before it ever gets here. + +## Quickstart + + + + + +Use `createConnState` to turn a credential into connection state, then check that state in the actions that need it. Throwing from `createConnState` rejects the connection. + + + + + + + + + + +Running the client prints the allowed read, then the rejected edit: + +``` +(empty string) +ActorError: Admins only +``` + + + + + +## Identifying the Caller + +Two hooks run before a connection is usable. Both can be async, and throwing from either rejects the connection. + +### `onBeforeConnect` + +Use it for pass or fail validation when you do not need the result later. + + + +### `createConnState` + +Use it when actions need to know who is calling. The returned object becomes `c.conn.state`. See [Connections](/actors/docs/connections) for the full lifecycle. + + + +### Available Data + +Both hooks can read: + +| Property | Description | +| --- | --- | +| `params` | Data the client passed when connecting. See [connection params](/actors/docs/connections#extracting-data-from-connection-params). | +| `c.request` | The underlying HTTP request. | +| `c.request.headers` | Request headers. Not available for `.connect()`. | +| `c.state` | Actor state, for authorization decisions. See [state](/actors/docs/state). | +| `c.key` | The actor's key. See [keys](/actors/docs/keys). | + +Prefer `params` over `c.request.headers`. It works for both HTTP and WebSocket connections, while headers do not. + +## Passing Credentials from the Client + + + + + + + +Use `getParams` rather than `params` when the credential can change between attempts, such as a token that has to be fresh on every reconnect. + +### Handling Errors + +Rejections surface through the normal error system. See [errors](/actors/docs/errors). + + + + + + +## Permission Surfaces + +Authorization is explicit per surface. Nothing is checked implicitly. + +- `onBeforeConnect` and `createConnState` reject unauthenticated connections. +- Action handlers enforce per-action rules. +- `queues..canPublish` allows or denies an inbound queue publish. +- `events..canSubscribe` allows or denies an event subscription. + + + +### Fail by Default + +1. Keep connection hooks strict and reject invalid credentials. +2. In each action, allow the expected roles explicitly and throw `forbidden` otherwise. +3. In `canPublish` and `canSubscribe`, return `true` only for allowed roles and end with `return false`. + +### Return Value Contract + +`canPublish` and `canSubscribe` must return a boolean. `true` allows, `false` denies with `forbidden`. Returning `undefined`, `null`, or any non-boolean throws an internal error. + +`canPublish` applies only to queue names declared in `queues`, and messages for undeclared queues are ignored while the publish still reports as completed. `canSubscribe` applies only to event names declared in `events`, and broadcasting an undeclared event still reaches its subscribers. + +## Patterns + +### Verifying a JWT from Your Auth Provider + +Tokens from Clerk, Auth0, Supabase, or your own issuer are opaque to Rivet. Verify them here with your provider's SDK or a library such as `jose`, checking the signature, issuer, audience, and expiry. + + + +### Calling an External Auth Service + + + +### Authorizing Against Actor State + +`c.state` and `c.key` are both available, so an actor can decide access from its own data. + + + +### Role-Based Access Control + + + +### Rate Limiting + +Track attempts in `c.vars` and reject callers that exceed a limit. + + + +These counters are [ephemeral](/actors/docs/state#ephemeral-variables). Use `state` instead of `vars` to persist them. + +### Caching Validated Tokens + +Avoid revalidating the same token on every reconnect. + + + +## When You Need Less of This + +If you scope each user's [JWT](/docs/jwt) to a single actor, that actor has exactly one participant and needs little authorization of its own. These hooks earn their keep once an actor is shared, as in a chat room or a per-tenant database. diff --git a/vendor/actors/docs/content/docs/production-checklist.mdx b/vendor/actors/docs/content/docs/production-checklist.mdx index e29f3bf4..447afacd 100644 --- a/vendor/actors/docs/content/docs/production-checklist.mdx +++ b/vendor/actors/docs/content/docs/production-checklist.mdx @@ -53,7 +53,8 @@ actor, not before shipping it. ### Authentication -- **Validate connections in `createConnState` or `onBeforeConnect`.** Do not trust client input without validation. See [Authentication](/actors/docs/authentication). +- **Validate connections in `createConnState` or `onBeforeConnect`.** Do not trust client input without validation. See [Permissions](/actors/docs/permissions). +- **Never ship a secret to a public client.** Mint a short-lived, scoped [JWT](/docs/jwt) per user on your backend instead of embedding a token that reaches your whole namespace. See [Authentication](/docs/authentication). ### CORS @@ -66,11 +67,11 @@ actor, not before shipping it. - **Do not leak your secret token.** Never expose `sk_*` tokens in client-side code, public repositories, or browser environments. See [Endpoints](/docs/endpoints). - **Verify you're connecting to the correct region.** Use the nearest datacenter endpoint (e.g. `api-us-west-1.rivet.dev`) for lowest latency. -### Access Control +### Permissions -Access control is only needed if you want granular permissions for different clients. For most use cases, basic authentication in `onBeforeConnect` or `createConnState` is sufficient. +Granular permissions are only needed if different clients should be able to do different things. For most use cases, basic validation in `onBeforeConnect` or `createConnState` is sufficient. -- **Use deny-by-default rules.** Reject unknown roles in `onBeforeConnect`, action handlers, `canPublish`, and `canSubscribe`. See [Access Control](/actors/docs/access-control). +- **Use deny-by-default rules.** Reject unknown roles in `onBeforeConnect`, action handlers, `canPublish`, and `canSubscribe`. See [Permissions](/actors/docs/permissions). - **Authorize actions explicitly.** Check the caller's role in each action handler and throw `forbidden` for unauthorized access. - **Gate event subscriptions and queue publishes.** Use `canSubscribe` and `canPublish` hooks to restrict which clients can subscribe to events or publish to queues. diff --git a/vendor/actors/docs/content/docs/queues.mdx b/vendor/actors/docs/content/docs/queues.mdx index 9d7c23b5..9ea27e81 100644 --- a/vendor/actors/docs/content/docs/queues.mdx +++ b/vendor/actors/docs/content/docs/queues.mdx @@ -125,7 +125,7 @@ This means you can run normal code in `run` without worrying about sleep interru ## Recommendations - Actions are for getting data, queue entries are for mutating data. -- Implement connection auth in `onBeforeConnect`. See [Authentication](/actors/docs/authentication). +- Implement connection auth in `onBeforeConnect`. See [Permissions](/actors/docs/permissions). - Route most state changes through one queue loop so ordering stays predictable. - If you need more complex multi-step run loops, consider using workflows. - Use `c.aborted` and `c.abortSignal` for actor shutdown. Use your own `AbortController` for earlier loop cancellation. diff --git a/vendor/actors/docs/content/learn/ai-agent.mdx b/vendor/actors/docs/content/learn/ai-agent.mdx index 8049ffeb..51a6773b 100644 --- a/vendor/actors/docs/content/learn/ai-agent.mdx +++ b/vendor/actors/docs/content/learn/ai-agent.mdx @@ -126,7 +126,7 @@ sequenceDiagram The examples ship without auth so they stay minimal. Apply this baseline before exposing an agent backend. - **API keys stay server-side**: `OPENAI_API_KEY` (or `ANTHROPIC_API_KEY`) is read by the AI SDK inside the actor process. The key never reaches the browser; clients only talk to the actor over RivetKit. The sandbox variant forwards keys into the sandbox env, never to the client. -- **Add authentication**: The examples have no auth, so anyone who reaches the server can create agents, list them, and message any agent whose key they can guess. Add `onBeforeConnect` or `createConnState` checks with scoped tokens as a recommended extension. See [Authentication](/actors/docs/authentication). +- **Add authentication**: The examples have no auth, so anyone who reaches the server can create agents, list them, and message any agent whose key they can guess. Add `onBeforeConnect` or `createConnState` checks with scoped tokens as a recommended extension. See [Permissions](/actors/docs/permissions). - **Validate and rate-limit queue payloads**: The example only skips bodies without a string `text`. Enforce payload size limits, schema validation, and per-connection rate limits as a recommended extension. - **Derive sender identity server-side**: The example trusts the client-supplied `sender` field verbatim. Bind sender identity to the authenticated connection instead. - **Cap or trim message history**: The example sends the full transcript on every model call with no cap. Trim or summarize old messages as a recommended extension so prompts and state stay bounded. diff --git a/vendor/actors/docs/content/learn/authentication.mdx b/vendor/actors/docs/content/learn/authentication.mdx new file mode 100644 index 00000000..025b4b1c --- /dev/null +++ b/vendor/actors/docs/content/learn/authentication.mdx @@ -0,0 +1,84 @@ +--- +title: "Authenticate Users End to End" +description: "Wire a login endpoint to Rivet Actors: authenticate the user, mint a scoped JWT on your backend, connect from the browser, and authorize actions inside the actor." +--- + +This guide connects both halves of Rivet authentication in one working flow. A user logs in, your backend mints a JWT that reaches only that user's actor, the browser connects with it, and the actor authorizes what the user may do once connected. + +Read [Authentication](/docs/authentication) first for the two-layer model. This guide is the runnable version of it. + +## What You Are Building + +A per-user profile actor. Alice can reach `user:alice` and nothing else, enforced by the control plane before her request touches your code. Inside the actor, an admin flag decides who may edit. + +## Steps + + + + + +Only your server holds a credential that can mint tokens. Set it once: + +```sh +export RIVET_ENDPOINT="http://localhost:6420" +export RIVET_NAMESPACE="production" +export RIVET_ADMIN_TOKEN="$(openssl rand -hex 32)" +``` + +The admin token reaches every actor in every namespace, so it never leaves your backend. See [Configuration](/docs/deploy/self-host/control-plane/configuration#admin-token). + + + + + +Authenticate the user with your existing session system, resolve the actor they own, then request a JWT that grants `actor_gateway` `read` on that single actor ID. + + + + + + +Resolving the ID on the backend is what makes the grant tight. The client never names an actor, so it cannot ask for someone else's. + + + + + +Give the client `getToken` instead of a static token. RivetKit calls it when it needs a credential and again when one expires, so a 15 minute token supports a connection that stays open for hours. + + + + + + + +The token decides which actor Alice reaches. It cannot decide what she may do there, because the control plane does not know what your actions mean. Check that in the actor. + + + + + + + +Point the token at another actor and the control plane refuses before your code runs: + +``` +HTTP/1.1 403 Forbidden +x-rivet-error: auth.insufficient_permissions +``` + +Call an admin-only action as a member and the actor refuses: + +``` +ActorError: Admins only +``` + + + + + +## Where to Go Next + +- [JWTs](/docs/jwt) for the full grant vocabulary, expiry behavior, and renewal. +- [Permissions](/actors/docs/permissions) for queue and event gating and deny-by-default patterns. +- [`examples/jwt-counter`](https://github.com/rivet-dev/rivet/tree/main/examples/jwt-counter) for a complete project with a smoke test covering renewal and rejection. diff --git a/vendor/actors/docs/content/learn/chat-room.mdx b/vendor/actors/docs/content/learn/chat-room.mdx index 8d47f38d..88f79b11 100644 --- a/vendor/actors/docs/content/learn/chat-room.mdx +++ b/vendor/actors/docs/content/learn/chat-room.mdx @@ -109,7 +109,7 @@ sequenceDiagram The example is intentionally minimal and skips all of the following. Add them before production: -- **Auth before join**: any client can join any room by knowing its name, and `sender` is arbitrary client input on every call. Validate a token during [connection auth](/actors/docs/authentication), bind identity to [connection state](/actors/docs/connections), and check room membership before serving history. Never trust a sender name passed as an action argument. +- **Auth before join**: any client can join any room by knowing its name, and `sender` is arbitrary client input on every call. Validate a token during [connection auth](/actors/docs/permissions), bind identity to [connection state](/actors/docs/connections), and check room membership before serving history. Never trust a sender name passed as an action argument. - **Message length clamps**: the example accepts empty messages and has no length limit. Trim server-side, reject empty text, and clamp to a maximum length. - **Per-connection rate limiting**: rate limit `sendMessage` per connection to stop spam and broadcast amplification. - **Server-side timestamps and ids**: the example already does this correctly. `timestamp` comes from `Date.now()` inside the action and `id` from SQLite `AUTOINCREMENT`. Keep it that way; never accept client-supplied timestamps or ids. diff --git a/vendor/actors/docs/content/learn/collaborative-text-editor.mdx b/vendor/actors/docs/content/learn/collaborative-text-editor.mdx index 1504e1b3..887ac385 100644 --- a/vendor/actors/docs/content/learn/collaborative-text-editor.mdx +++ b/vendor/actors/docs/content/learn/collaborative-text-editor.mdx @@ -156,7 +156,7 @@ sequenceDiagram The example ships with no authentication or authorization. Harden it with this baseline before production. None of these are implemented in the example. -- **Authenticate before connect**: Anyone who knows or guesses a workspace ID can connect, and because `useActor` implicitly getOrCreates, connecting with a nonexistent workspace ID silently creates a blank `documentList` coordinator. Add connection auth so unauthenticated clients never reach an actor. See [Authentication](/actors/docs/authentication). +- **Authenticate before connect**: Anyone who knows or guesses a workspace ID can connect, and because `useActor` implicitly getOrCreates, connecting with a nonexistent workspace ID silently creates a blank `documentList` coordinator. Add connection auth so unauthenticated clients never reach an actor. See [Permissions](/actors/docs/permissions). - **Per-document access control**: Validate that the authenticated user is allowed to access the specific `[workspaceId, documentId]` key, not just any document. - **Cap and rate limit `applyUpdate`**: Update payloads are unvalidated `number[]` arrays with no size limit, and the example client sends one action per keystroke and per cursor move with zero throttling. Enforce payload size caps and per-connection rate limits on the server, and debounce on the client. - **Do not trust client-asserted awareness clientIds**: The `clientId` argument to `applyUpdate` is client-supplied and trusted as-is. Derive or verify presence identity from connection-scoped server state instead. diff --git a/vendor/actors/docs/content/learn/per-tenant-database.mdx b/vendor/actors/docs/content/learn/per-tenant-database.mdx index 37d9c520..5a368817 100644 --- a/vendor/actors/docs/content/learn/per-tenant-database.mdx +++ b/vendor/actors/docs/content/learn/per-tenant-database.mdx @@ -53,8 +53,8 @@ Because each tenant has its own database, migrations roll out per actor as each The example's sign-in is cosmetic: the client picks any company string and that string becomes the actor key, so any visitor can read and write any tenant's data. Do not ship this. As a required production extension (not implemented by the example): - Derive the tenant id from a verified credential, such as a JWT claim, never from user input. -- Validate the credential against `c.key` in `onBeforeConnect` (pass/fail) or `createConnState` (store the verified user on connection state). See [Authentication](/actors/docs/authentication) and [Connections](/actors/docs/connections). -- Add per-action permission checks on top of connection-level auth. See [Access Control](/actors/docs/access-control). +- Validate the credential against `c.key` in `onBeforeConnect` (pass/fail) or `createConnState` (store the verified user on connection state). See [Permissions](/actors/docs/permissions) and [Connections](/actors/docs/connections). +- Add per-action permission checks on top of connection-level auth, and scope each tenant's [JWT](/docs/jwt) to its own actor so the boundary is enforced before a request reaches your code. ## Actors @@ -116,7 +116,7 @@ The example ships with none of these. Apply all of them before production. - **Tenant identity**: Derive the tenant id from a verified JWT claim, never from a client-supplied string. - **Connection validation**: In `onBeforeConnect` or `createConnState`, verify the credential's tenant claim matches `c.key` and reject mismatches. -- **Per-action authorization**: Check the caller's role before mutating actions (`addEmployee`, `addProject`), not just at connect time. See [Access Control](/actors/docs/access-control). +- **Per-action authorization**: Check the caller's role before mutating actions (`addEmployee`, `addProject`), not just at connect time. See [Permissions](/actors/docs/permissions). - **Input validation**: Clamp name and role lengths and validate enums. The example only trims input and substitutes fallback defaults. - **Key construction**: Always pass the tenant id as an array element (`key: [tenantId]`). Never interpolate tenant ids into key strings, and never build keys from one tenant's input to address another tenant's actor. - **Growth limits**: As a recommended extension, cap or paginate the `employees` and `projects` arrays. The example lets them grow unboundedly in JSON state; move to [SQLite](/actors/docs/sqlite) when the dataset outgrows memory. diff --git a/vendor/actors/docs/sidebar.json b/vendor/actors/docs/sidebar.json index a341a7e2..5b6f93d9 100644 --- a/vendor/actors/docs/sidebar.json +++ b/vendor/actors/docs/sidebar.json @@ -104,14 +104,6 @@ "title": "Communication & Networking", "collapsible": true, "pages": [ - { - "title": "Authentication", - "href": "/actors/docs/authentication" - }, - { - "title": "Access Control", - "href": "/actors/docs/access-control" - }, { "title": "Connections", "href": "/actors/docs/connections" @@ -142,6 +134,10 @@ "title": "Lifecycle", "href": "/actors/docs/lifecycle" }, + { + "title": "Permissions", + "href": "/actors/docs/permissions" + }, { "title": "Input Parameters", "href": "/actors/docs/input" @@ -319,6 +315,10 @@ "title": "AI Agent", "href": "/actors/learn/ai-agent" }, + { + "title": "Authenticate Users End to End", + "href": "/actors/learn/authentication" + }, { "title": "Chat Room", "href": "/actors/learn/chat-room" diff --git a/vendor/actors/examples/docs/actors-access-control/chat-room.ts b/vendor/actors/examples/docs/actors-access-control/chat-room.ts deleted file mode 100644 index 9ae778af..00000000 --- a/vendor/actors/examples/docs/actors-access-control/chat-room.ts +++ /dev/null @@ -1,83 +0,0 @@ -import { actor, event, queue, UserError } from "rivetkit"; - -type ConnParams = { - authToken: string; -}; - -type ConnState = { - userId: string; - role: "member" | "admin"; -}; - -async function authenticate( - authToken: string, -): Promise { - if (authToken === "admin-token") { - return { userId: "admin-1", role: "admin" }; - } - if (authToken === "member-token") { - return { userId: "member-1", role: "member" }; - } - return null; -} - -export const chatRoom = actor({ - state: { messages: [] as Array<{ userId: string; text: string }> }, - - onBeforeConnect: async (_c, params: ConnParams) => { - if (!params.authToken) { - throw new UserError("Forbidden", { code: "forbidden" }); - } - - const session = await authenticate(params.authToken); - if (!session) { - throw new UserError("Forbidden", { code: "forbidden" }); - } - }, - - createConnState: async (_c, params: ConnParams): Promise => { - const session = await authenticate(params.authToken); - if (!session) { - throw new UserError("Forbidden", { code: "forbidden" }); - } - return session; - }, - - events: { - messages: event<{ userId: string; text: string }>(), - moderationLog: event<{ entry: string }>({ - canSubscribe: (c) => { - if (c.conn?.state.role === "admin") { - return true; - } - return false; - }, - }), - }, - - queues: { - moderationJobs: queue<{ action: "ban"; userId: string }>({ - canPublish: (c) => { - if (c.conn?.state.role === "admin") { - return true; - } - return false; - }, - }), - }, - - actions: { - sendMessage: (c, text: string) => { - const role = c.conn?.state.role; - const userId = c.conn?.state.userId; - - if (!userId || (role !== "member" && role !== "admin")) { - throw new UserError("Forbidden", { code: "forbidden" }); - } - - const message = { userId, text }; - c.state.messages.push(message); - c.broadcast("messages", message); - }, - }, -}); diff --git a/vendor/actors/examples/docs/actors-authentication/caching-tokens.ts b/vendor/actors/examples/docs/actors-authentication/caching-tokens.ts deleted file mode 100644 index 501d7f54..00000000 --- a/vendor/actors/examples/docs/actors-authentication/caching-tokens.ts +++ /dev/null @@ -1,61 +0,0 @@ -import { actor, UserError } from "rivetkit"; - -interface ConnParams { - authToken: string; -} - -interface ConnState { - userId: string; - role: string; -} - -interface TokenCache { - [token: string]: { - userId: string; - role: string; - expiresAt: number; - }; -} - -// Example token validation function -async function validateToken(token: string): Promise<{ sub: string; role: string } | null> { - // In production, verify JWT or call auth service - if (token.length > 0) { - return { sub: "user-123", role: "member" }; - } - return null; -} - -const cachedAuthActor = actor({ - state: {}, - createVars: () => ({ tokenCache: {} as TokenCache }), - - createConnState: async (c, params: ConnParams): Promise => { - const token = params.authToken; - - // Check cache first - const cached = c.vars.tokenCache[token]; - if (cached && cached.expiresAt > Date.now()) { - return { userId: cached.userId, role: cached.role }; - } - - // Validate token (expensive operation) - const payload = await validateToken(token); - if (!payload) { - throw new UserError("Invalid token", { code: "invalid_token" }); - } - - // Cache the result - c.vars.tokenCache[token] = { - userId: payload.sub, - role: payload.role, - expiresAt: Date.now() + 5 * 60 * 1000, // 5 minutes - }; - - return { userId: payload.sub, role: payload.role }; - }, - - actions: { - getData: (c) => ({ userId: c.conn.state.userId }), - }, -}); diff --git a/vendor/actors/examples/docs/actors-authentication/create-conn-state.ts b/vendor/actors/examples/docs/actors-authentication/create-conn-state.ts deleted file mode 100644 index edb02561..00000000 --- a/vendor/actors/examples/docs/actors-authentication/create-conn-state.ts +++ /dev/null @@ -1,55 +0,0 @@ -import { actor, UserError } from "rivetkit"; - -interface ConnParams { - authToken: string; -} - -interface ConnState { - userId: string; - role: string; -} - -interface Message { - userId: string; - text: string; - timestamp: number; -} - -// Example token validation function -async function validateToken(token: string, roomKey: string[]): Promise<{ sub: string; role: string } | null> { - // In production, verify JWT or call auth service - if (token.length > 0 && roomKey.length > 0) { - return { sub: "user-123", role: "member" }; - } - return null; -} - -const chatRoom = actor({ - state: { messages: [] as Message[] }, - - createConnState: async (c, params: ConnParams): Promise => { - const roomName = c.key; - const payload = await validateToken(params.authToken, roomName); - if (!payload) { - throw new UserError("Forbidden", { code: "forbidden" }); - } - return { - userId: payload.sub, - role: payload.role, - }; - }, - - actions: { - sendMessage: (c, text: string) => { - // Access user data via c.conn.state - const { userId, role } = c.conn.state; - - if (role !== "member") { - throw new UserError("Insufficient permissions", { code: "insufficient_permissions" }); - } - - c.state.messages.push({ userId, text, timestamp: Date.now() }); - c.broadcast("newMessage", { userId, text }); - }, - }, -}); diff --git a/vendor/actors/examples/docs/actors-authentication/external-auth-provider.ts b/vendor/actors/examples/docs/actors-authentication/external-auth-provider.ts deleted file mode 100644 index 65b22f6d..00000000 --- a/vendor/actors/examples/docs/actors-authentication/external-auth-provider.ts +++ /dev/null @@ -1,37 +0,0 @@ -import { actor, UserError } from "rivetkit"; - -interface ConnParams { - apiKey: string; -} - -interface ConnState { - userId: string; - tier: string; -} - -const apiActor = actor({ - state: {}, - - createConnState: async (c, params: ConnParams): Promise => { - const response = await fetch(`https://api.my-auth-provider.com/validate`, { - method: "POST", - headers: { "X-API-Key": params.apiKey }, - }); - - if (!response.ok) { - throw new UserError("Invalid API key", { code: "invalid_api_key" }); - } - - const data = await response.json(); - return { userId: data.id, tier: data.tier }; - }, - - actions: { - premiumAction: (c) => { - if (c.conn.state.tier !== "premium") { - throw new UserError("Premium subscription required", { code: "forbidden" }); - } - return "Premium content"; - }, - }, -}); diff --git a/vendor/actors/examples/docs/actors-authentication/jwt.ts b/vendor/actors/examples/docs/actors-authentication/jwt.ts deleted file mode 100644 index d15028ab..00000000 --- a/vendor/actors/examples/docs/actors-authentication/jwt.ts +++ /dev/null @@ -1,52 +0,0 @@ -import { actor, UserError } from "rivetkit"; - -interface ConnParams { - token: string; -} - -interface ConnState { - userId: string; - role: string; - permissions: string[]; -} - -interface JwtPayload { - sub: string; - role: string; - permissions?: string[]; -} - -// Example JWT verification function - in production use a JWT library -function verifyJwt(token: string, secret: string): JwtPayload { - // This is a simplified example - use jsonwebtoken or similar in production - const parts = token.split("."); - if (parts.length !== 3) throw new Error("Invalid token"); - const payload = JSON.parse(atob(parts[1])) as JwtPayload; - return payload; -} - -const jwtActor = actor({ - state: {}, - - createConnState: (c, params: ConnParams): ConnState => { - try { - const payload = verifyJwt(params.token, process.env.JWT_SECRET || "secret"); - return { - userId: payload.sub, - role: payload.role, - permissions: payload.permissions || [], - }; - } catch { - throw new UserError("Invalid or expired token", { code: "invalid_token" }); - } - }, - - actions: { - protectedAction: (c) => { - if (!c.conn.state.permissions.includes("write")) { - throw new UserError("Write permission required", { code: "forbidden" }); - } - return { success: true }; - }, - }, -}); diff --git a/vendor/actors/examples/docs/actors-authentication/on-before-connect.ts b/vendor/actors/examples/docs/actors-authentication/on-before-connect.ts deleted file mode 100644 index df835ce0..00000000 --- a/vendor/actors/examples/docs/actors-authentication/on-before-connect.ts +++ /dev/null @@ -1,34 +0,0 @@ -import { actor, UserError } from "rivetkit"; - -interface ConnParams { - authToken: string; -} - -// Example token validation function -async function validateToken(token: string, roomKey: string[]): Promise { - // In production, verify JWT or call auth service - return token.length > 0 && roomKey.length > 0; -} - -interface Message { - text: string; - timestamp: number; -} - -const chatRoom = actor({ - state: { messages: [] as Message[] }, - - onBeforeConnect: async (c, params: ConnParams) => { - const roomName = c.key; - const isValid = await validateToken(params.authToken, roomName); - if (!isValid) { - throw new UserError("Forbidden", { code: "forbidden" }); - } - }, - - actions: { - sendMessage: (c, text: string) => { - c.state.messages.push({ text, timestamp: Date.now() }); - }, - }, -}); diff --git a/vendor/actors/examples/docs/actors-authentication/rate-limiting.ts b/vendor/actors/examples/docs/actors-authentication/rate-limiting.ts deleted file mode 100644 index d0cda637..00000000 --- a/vendor/actors/examples/docs/actors-authentication/rate-limiting.ts +++ /dev/null @@ -1,45 +0,0 @@ -import { actor, UserError } from "rivetkit"; - -interface ConnParams { - authToken: string; -} - -interface RateLimitEntry { - count: number; - resetAt: number; -} - -// Example token validation function -async function validateToken(token: string): Promise<{ userId: string }> { - // In production, verify JWT or call auth service - return { userId: "user-123" }; -} - -const rateLimitedActor = actor({ - state: {}, - createVars: () => ({ rateLimits: {} as Record }), - - onBeforeConnect: async (c, params: ConnParams) => { - // Extract user ID - const { userId } = await validateToken(params.authToken); - - // Check rate limit - const now = Date.now(); - const limit = c.vars.rateLimits[userId]; - - if (limit && limit.resetAt > now && limit.count >= 10) { - throw new UserError("Too many requests, try again later", { code: "rate_limited" }); - } - - // Update rate limit - if (!limit || limit.resetAt <= now) { - c.vars.rateLimits[userId] = { count: 1, resetAt: now + 60_000 }; - } else { - limit.count++; - } - }, - - actions: { - getData: (c) => ({ success: true }), - }, -}); diff --git a/vendor/actors/examples/docs/actors-authentication/role-based-access-control.ts b/vendor/actors/examples/docs/actors-authentication/role-based-access-control.ts deleted file mode 100644 index 969ad121..00000000 --- a/vendor/actors/examples/docs/actors-authentication/role-based-access-control.ts +++ /dev/null @@ -1,52 +0,0 @@ -import { actor, UserError } from "rivetkit"; - -const ROLE_HIERARCHY = { user: 1, moderator: 2, admin: 3 }; - -interface ConnState { - role: keyof typeof ROLE_HIERARCHY; - permissions: string[]; -} - -// Example token validation function -async function validateToken(token: string): Promise<{ role: keyof typeof ROLE_HIERARCHY; permissions: string[] }> { - // In production, verify JWT or call auth service - return { role: "user", permissions: ["read", "edit_posts"] }; -} - -function requireRole(requiredRole: keyof typeof ROLE_HIERARCHY) { - return (c: { conn: { state: ConnState } }) => { - const userRole = c.conn.state.role; - if (ROLE_HIERARCHY[userRole] < ROLE_HIERARCHY[requiredRole]) { - throw new UserError(`${requiredRole} role required`, { code: "forbidden" }); - } - }; -} - -function requirePermission(permission: string) { - return (c: { conn: { state: ConnState } }) => { - if (!c.conn.state.permissions?.includes(permission)) { - throw new UserError(`Permission '${permission}' required`, { code: "forbidden" }); - } - }; -} - -const forumActor = actor({ - state: {}, - - createConnState: async (c, params: { token: string }): Promise => { - const user = await validateToken(params.token); - return { role: user.role, permissions: user.permissions }; - }, - - actions: { - deletePost: (c, postId: string) => { - requireRole("moderator")(c); - // Delete post... - }, - - editPost: (c, postId: string, content: string) => { - requirePermission("edit_posts")(c); - // Edit post... - }, - }, -}); diff --git a/vendor/actors/examples/docs/actors-authentication/using-state.ts b/vendor/actors/examples/docs/actors-authentication/using-state.ts deleted file mode 100644 index 3bc7a777..00000000 --- a/vendor/actors/examples/docs/actors-authentication/using-state.ts +++ /dev/null @@ -1,23 +0,0 @@ -import { actor, UserError } from "rivetkit"; - -interface ConnParams { - userId?: string; -} - -const userProfile = actor({ - state: { - ownerId: "user-123", - isPrivate: true, - }, - - onBeforeConnect: (c, params: ConnParams) => { - // Use actor state to check access permissions - if (c.state.isPrivate && params.userId !== c.state.ownerId) { - throw new UserError("Access denied to private profile", { code: "forbidden" }); - } - }, - - actions: { - getProfile: (c) => ({ ownerId: c.state.ownerId }), - }, -}); diff --git a/vendor/actors/examples/docs/actors-permissions/caching-tokens.ts b/vendor/actors/examples/docs/actors-permissions/caching-tokens.ts new file mode 100644 index 00000000..cca3e07a --- /dev/null +++ b/vendor/actors/examples/docs/actors-permissions/caching-tokens.ts @@ -0,0 +1,63 @@ +import { actor, UserError } from "rivetkit"; + +interface ConnParams { + authToken: string; +} + +interface ConnState { + userId: string; + role: string; +} + +interface TokenCache { + [token: string]: { + userId: string; + role: string; + expiresAt: number; + }; +} + +// Example token validation function +async function validateToken( + token: string, +): Promise<{ sub: string; role: string } | null> { + // In production, verify JWT or call auth service + if (token.length > 0) { + return { sub: "user-123", role: "member" }; + } + return null; +} + +const cachedAuthActor = actor({ + state: {}, + createVars: () => ({ tokenCache: {} as TokenCache }), + + createConnState: async (c, params: ConnParams): Promise => { + const token = params.authToken; + + // Check cache first + const cached = c.vars.tokenCache[token]; + if (cached && cached.expiresAt > Date.now()) { + return { userId: cached.userId, role: cached.role }; + } + + // Validate token (expensive operation) + const payload = await validateToken(token); + if (!payload) { + throw new UserError("Invalid token", { code: "invalid_token" }); + } + + // Cache the result + c.vars.tokenCache[token] = { + userId: payload.sub, + role: payload.role, + expiresAt: Date.now() + 5 * 60 * 1000, // 5 minutes + }; + + return { userId: payload.sub, role: payload.role }; + }, + + actions: { + getData: (c) => ({ userId: c.conn.state.userId }), + }, +}); diff --git a/vendor/actors/examples/docs/actors-permissions/chat-room.ts b/vendor/actors/examples/docs/actors-permissions/chat-room.ts new file mode 100644 index 00000000..ac949e52 --- /dev/null +++ b/vendor/actors/examples/docs/actors-permissions/chat-room.ts @@ -0,0 +1,81 @@ +import { actor, event, queue, UserError } from "rivetkit"; + +type ConnParams = { + authToken: string; +}; + +type ConnState = { + userId: string; + role: "member" | "admin"; +}; + +async function authenticate(authToken: string): Promise { + if (authToken === "admin-token") { + return { userId: "admin-1", role: "admin" }; + } + if (authToken === "member-token") { + return { userId: "member-1", role: "member" }; + } + return null; +} + +export const chatRoom = actor({ + state: { messages: [] as Array<{ userId: string; text: string }> }, + + onBeforeConnect: async (_c, params: ConnParams) => { + if (!params.authToken) { + throw new UserError("Forbidden", { code: "forbidden" }); + } + + const session = await authenticate(params.authToken); + if (!session) { + throw new UserError("Forbidden", { code: "forbidden" }); + } + }, + + createConnState: async (_c, params: ConnParams): Promise => { + const session = await authenticate(params.authToken); + if (!session) { + throw new UserError("Forbidden", { code: "forbidden" }); + } + return session; + }, + + events: { + messages: event<{ userId: string; text: string }>(), + moderationLog: event<{ entry: string }>({ + canSubscribe: (c) => { + if (c.conn?.state.role === "admin") { + return true; + } + return false; + }, + }), + }, + + queues: { + moderationJobs: queue<{ action: "ban"; userId: string }>({ + canPublish: (c) => { + if (c.conn?.state.role === "admin") { + return true; + } + return false; + }, + }), + }, + + actions: { + sendMessage: (c, text: string) => { + const role = c.conn?.state.role; + const userId = c.conn?.state.userId; + + if (!userId || (role !== "member" && role !== "admin")) { + throw new UserError("Forbidden", { code: "forbidden" }); + } + + const message = { userId, text }; + c.state.messages.push(message); + c.broadcast("messages", message); + }, + }, +}); diff --git a/vendor/actors/examples/docs/actors-permissions/create-conn-state.ts b/vendor/actors/examples/docs/actors-permissions/create-conn-state.ts new file mode 100644 index 00000000..8d35f8b8 --- /dev/null +++ b/vendor/actors/examples/docs/actors-permissions/create-conn-state.ts @@ -0,0 +1,60 @@ +import { actor, UserError } from "rivetkit"; + +interface ConnParams { + authToken: string; +} + +interface ConnState { + userId: string; + role: string; +} + +interface Message { + userId: string; + text: string; + timestamp: number; +} + +// Example token validation function +async function validateToken( + token: string, + roomKey: string[], +): Promise<{ sub: string; role: string } | null> { + // In production, verify JWT or call auth service + if (token.length > 0 && roomKey.length > 0) { + return { sub: "user-123", role: "member" }; + } + return null; +} + +const chatRoom = actor({ + state: { messages: [] as Message[] }, + + createConnState: async (c, params: ConnParams): Promise => { + const roomName = c.key; + const payload = await validateToken(params.authToken, roomName); + if (!payload) { + throw new UserError("Forbidden", { code: "forbidden" }); + } + return { + userId: payload.sub, + role: payload.role, + }; + }, + + actions: { + sendMessage: (c, text: string) => { + // Access user data via c.conn.state + const { userId, role } = c.conn.state; + + if (role !== "member") { + throw new UserError("Insufficient permissions", { + code: "insufficient_permissions", + }); + } + + c.state.messages.push({ userId, text, timestamp: Date.now() }); + c.broadcast("newMessage", { userId, text }); + }, + }, +}); diff --git a/vendor/actors/examples/docs/actors-permissions/external-auth-provider.ts b/vendor/actors/examples/docs/actors-permissions/external-auth-provider.ts new file mode 100644 index 00000000..29deaa45 --- /dev/null +++ b/vendor/actors/examples/docs/actors-permissions/external-auth-provider.ts @@ -0,0 +1,42 @@ +import { actor, UserError } from "rivetkit"; + +interface ConnParams { + apiKey: string; +} + +interface ConnState { + userId: string; + tier: string; +} + +const apiActor = actor({ + state: {}, + + createConnState: async (c, params: ConnParams): Promise => { + const response = await fetch( + `https://api.my-auth-provider.com/validate`, + { + method: "POST", + headers: { "X-API-Key": params.apiKey }, + }, + ); + + if (!response.ok) { + throw new UserError("Invalid API key", { code: "invalid_api_key" }); + } + + const data = await response.json(); + return { userId: data.id, tier: data.tier }; + }, + + actions: { + premiumAction: (c) => { + if (c.conn.state.tier !== "premium") { + throw new UserError("Premium subscription required", { + code: "forbidden", + }); + } + return "Premium content"; + }, + }, +}); diff --git a/vendor/actors/examples/docs/actors-authentication/handling-errors-connection.ts b/vendor/actors/examples/docs/actors-permissions/handling-errors-connection.ts similarity index 61% rename from vendor/actors/examples/docs/actors-authentication/handling-errors-connection.ts rename to vendor/actors/examples/docs/actors-permissions/handling-errors-connection.ts index 80b33413..df4b67a3 100644 --- a/vendor/actors/examples/docs/actors-authentication/handling-errors-connection.ts +++ b/vendor/actors/examples/docs/actors-permissions/handling-errors-connection.ts @@ -3,10 +3,10 @@ import { ActorError, createClient } from "rivetkit/client"; // Define actor with protected action const myActor = actor({ - state: {}, - actions: { - protectedAction: (c) => ({ success: true }) - } + state: {}, + actions: { + protectedAction: (c) => ({ success: true }), + }, }); const registry = setup({ use: { myActor } }); @@ -15,14 +15,14 @@ const actorHandle = await client.myActor.getOrCreate(); // Helper to show errors function showError(message: string) { - console.error(message); + console.error(message); } const conn = actorHandle.connect(); conn.onError((error: ActorError) => { - if (error.code === "forbidden") { - window.location.href = "/login"; - } else if (error.code === "insufficient_permissions") { - showError("You don't have permission for this action"); - } + if (error.code === "forbidden") { + window.location.href = "/login"; + } else if (error.code === "insufficient_permissions") { + showError("You don't have permission for this action"); + } }); diff --git a/vendor/actors/examples/docs/actors-authentication/handling-errors-stateless.ts b/vendor/actors/examples/docs/actors-permissions/handling-errors-stateless.ts similarity index 51% rename from vendor/actors/examples/docs/actors-authentication/handling-errors-stateless.ts rename to vendor/actors/examples/docs/actors-permissions/handling-errors-stateless.ts index 0a0e2525..c4d21c1b 100644 --- a/vendor/actors/examples/docs/actors-authentication/handling-errors-stateless.ts +++ b/vendor/actors/examples/docs/actors-permissions/handling-errors-stateless.ts @@ -3,10 +3,10 @@ import { ActorError, createClient } from "rivetkit/client"; // Define actor with protected action const myActor = actor({ - state: {}, - actions: { - protectedAction: (c) => ({ success: true }) - } + state: {}, + actions: { + protectedAction: (c) => ({ success: true }), + }, }); const registry = setup({ use: { myActor } }); @@ -15,15 +15,18 @@ const actorHandle = await client.myActor.getOrCreate(); // Helper to show errors function showError(message: string) { - console.error(message); + console.error(message); } try { - const result = await actorHandle.protectedAction(); + const result = await actorHandle.protectedAction(); } catch (error) { - if (error instanceof ActorError && error.code === "forbidden") { - window.location.href = "/login"; - } else if (error instanceof ActorError && error.code === "insufficient_permissions") { - showError("You don't have permission for this action"); - } + if (error instanceof ActorError && error.code === "forbidden") { + window.location.href = "/login"; + } else if ( + error instanceof ActorError && + error.code === "insufficient_permissions" + ) { + showError("You don't have permission for this action"); + } } diff --git a/vendor/actors/examples/docs/actors-permissions/jwt.ts b/vendor/actors/examples/docs/actors-permissions/jwt.ts new file mode 100644 index 00000000..c602dc37 --- /dev/null +++ b/vendor/actors/examples/docs/actors-permissions/jwt.ts @@ -0,0 +1,55 @@ +import { actor, UserError } from "rivetkit"; + +interface ConnParams { + token: string; +} + +interface ConnState { + userId: string; + role: string; + permissions: string[]; +} + +interface JwtPayload { + sub: string; + role: string; + permissions?: string[]; +} + +// Supply this from your auth provider's SDK or a JWT library such as `jose`. +// It must verify the signature and check the issuer, audience, and expiry. +// Decoding the payload without verifying the signature authenticates nobody: +// any client can forge a token. +declare function verifyAccessToken(token: string): Promise; + +const jwtActor = actor({ + state: {}, + + createConnState: async (c, params: ConnParams): Promise => { + let payload: JwtPayload; + try { + payload = await verifyAccessToken(params.token); + } catch { + throw new UserError("Invalid or expired token", { + code: "invalid_token", + }); + } + + return { + userId: payload.sub, + role: payload.role, + permissions: payload.permissions ?? [], + }; + }, + + actions: { + protectedAction: (c) => { + if (!c.conn.state.permissions.includes("write")) { + throw new UserError("Write permission required", { + code: "forbidden", + }); + } + return { success: true }; + }, + }, +}); diff --git a/vendor/actors/examples/docs/actors-permissions/on-before-connect.ts b/vendor/actors/examples/docs/actors-permissions/on-before-connect.ts new file mode 100644 index 00000000..1ab851a9 --- /dev/null +++ b/vendor/actors/examples/docs/actors-permissions/on-before-connect.ts @@ -0,0 +1,37 @@ +import { actor, UserError } from "rivetkit"; + +interface ConnParams { + authToken: string; +} + +// Example token validation function +async function validateToken( + token: string, + roomKey: string[], +): Promise { + // In production, verify JWT or call auth service + return token.length > 0 && roomKey.length > 0; +} + +interface Message { + text: string; + timestamp: number; +} + +const chatRoom = actor({ + state: { messages: [] as Message[] }, + + onBeforeConnect: async (c, params: ConnParams) => { + const roomName = c.key; + const isValid = await validateToken(params.authToken, roomName); + if (!isValid) { + throw new UserError("Forbidden", { code: "forbidden" }); + } + }, + + actions: { + sendMessage: (c, text: string) => { + c.state.messages.push({ text, timestamp: Date.now() }); + }, + }, +}); diff --git a/vendor/actors/examples/docs/actors-authentication/passing-credentials-connection.ts b/vendor/actors/examples/docs/actors-permissions/passing-credentials-connection.ts similarity index 75% rename from vendor/actors/examples/docs/actors-authentication/passing-credentials-connection.ts rename to vendor/actors/examples/docs/actors-permissions/passing-credentials-connection.ts index 80d2c9b4..9a8a8eec 100644 --- a/vendor/actors/examples/docs/actors-authentication/passing-credentials-connection.ts +++ b/vendor/actors/examples/docs/actors-permissions/passing-credentials-connection.ts @@ -1,14 +1,14 @@ import { createClient } from "rivetkit/client"; async function getAuthToken(): Promise { - return "jwt-token-here"; + return "jwt-token-here"; } const client = createClient(); const chat = client.chatRoom.getOrCreate(["general"], { - getParams: async () => ({ - authToken: await getAuthToken(), - }), + getParams: async () => ({ + authToken: await getAuthToken(), + }), }); // Authentication will happen on connect by reading connection parameters diff --git a/vendor/actors/examples/docs/actors-authentication/passing-credentials-headers.ts b/vendor/actors/examples/docs/actors-permissions/passing-credentials-headers.ts similarity index 84% rename from vendor/actors/examples/docs/actors-authentication/passing-credentials-headers.ts rename to vendor/actors/examples/docs/actors-permissions/passing-credentials-headers.ts index 2da6a5f6..d0f4e1d9 100644 --- a/vendor/actors/examples/docs/actors-authentication/passing-credentials-headers.ts +++ b/vendor/actors/examples/docs/actors-permissions/passing-credentials-headers.ts @@ -2,9 +2,9 @@ import { createClient } from "rivetkit/client"; // This only works for stateless actions, not WebSockets const client = createClient({ - headers: { - Authorization: "Bearer my-token", - }, + headers: { + Authorization: "Bearer my-token", + }, }); const chat = client.chatRoom.getOrCreate(["general"]); diff --git a/vendor/actors/examples/docs/actors-authentication/passing-credentials-stateless.ts b/vendor/actors/examples/docs/actors-permissions/passing-credentials-stateless.ts similarity index 86% rename from vendor/actors/examples/docs/actors-authentication/passing-credentials-stateless.ts rename to vendor/actors/examples/docs/actors-permissions/passing-credentials-stateless.ts index 57615ef3..da16920a 100644 --- a/vendor/actors/examples/docs/actors-authentication/passing-credentials-stateless.ts +++ b/vendor/actors/examples/docs/actors-permissions/passing-credentials-stateless.ts @@ -2,7 +2,7 @@ import { createClient } from "rivetkit/client"; const client = createClient(); const chat = client.chatRoom.getOrCreate(["general"], { - params: { authToken: "jwt-token-here" }, + params: { authToken: "jwt-token-here" }, }); // Authentication will happen when calling the action by reading input diff --git a/vendor/actors/examples/docs/actors-permissions/quickstart/client.ts b/vendor/actors/examples/docs/actors-permissions/quickstart/client.ts new file mode 100644 index 00000000..89db2da8 --- /dev/null +++ b/vendor/actors/examples/docs/actors-permissions/quickstart/client.ts @@ -0,0 +1,20 @@ +import { createClient } from "rivetkit/client"; +import type { registry } from "./index"; + +const client = createClient(); + +const doc = client.document.getOrCreate(["welcome"], { + params: { authToken: "member-token" }, +}); + +const conn = doc.connect(); + +// Allowed: every authenticated caller may read. +console.log(await conn.read()); + +try { + // Rejected: this connection is a member, not an admin. + await conn.edit("hello"); +} catch (error) { + console.error(error); +} diff --git a/vendor/actors/examples/docs/actors-permissions/quickstart/index.ts b/vendor/actors/examples/docs/actors-permissions/quickstart/index.ts new file mode 100644 index 00000000..a9109048 --- /dev/null +++ b/vendor/actors/examples/docs/actors-permissions/quickstart/index.ts @@ -0,0 +1,44 @@ +import { actor, setup, UserError } from "rivetkit"; + +interface ConnParams { + authToken: string; +} + +interface ConnState { + userId: string; + role: "member" | "admin"; +} + +// Replace this with your session store or auth provider. +async function verifySession(authToken: string): Promise { + if (authToken === "admin-token") return { userId: "u_1", role: "admin" }; + if (authToken === "member-token") return { userId: "u_2", role: "member" }; + return null; +} + +export const document = actor({ + state: { body: "" }, + + // 1. Identify the caller once, at connect time. + createConnState: async (_c, params: ConnParams): Promise => { + const session = await verifySession(params.authToken); + if (!session) { + throw new UserError("Invalid token", { code: "invalid_token" }); + } + return session; + }, + + actions: { + read: (c) => c.state.body, + + // 2. Gate the operation on the identity you established. + edit: (c, body: string) => { + if (c.conn.state.role !== "admin") { + throw new UserError("Admins only", { code: "forbidden" }); + } + c.state.body = body; + }, + }, +}); + +export const registry = setup({ use: { document } }); diff --git a/vendor/actors/examples/docs/actors-permissions/rate-limiting.ts b/vendor/actors/examples/docs/actors-permissions/rate-limiting.ts new file mode 100644 index 00000000..3bdba330 --- /dev/null +++ b/vendor/actors/examples/docs/actors-permissions/rate-limiting.ts @@ -0,0 +1,47 @@ +import { actor, UserError } from "rivetkit"; + +interface ConnParams { + authToken: string; +} + +interface RateLimitEntry { + count: number; + resetAt: number; +} + +// Example token validation function +async function validateToken(token: string): Promise<{ userId: string }> { + // In production, verify JWT or call auth service + return { userId: "user-123" }; +} + +const rateLimitedActor = actor({ + state: {}, + createVars: () => ({ rateLimits: {} as Record }), + + onBeforeConnect: async (c, params: ConnParams) => { + // Extract user ID + const { userId } = await validateToken(params.authToken); + + // Check rate limit + const now = Date.now(); + const limit = c.vars.rateLimits[userId]; + + if (limit && limit.resetAt > now && limit.count >= 10) { + throw new UserError("Too many requests, try again later", { + code: "rate_limited", + }); + } + + // Update rate limit + if (!limit || limit.resetAt <= now) { + c.vars.rateLimits[userId] = { count: 1, resetAt: now + 60_000 }; + } else { + limit.count++; + } + }, + + actions: { + getData: (c) => ({ success: true }), + }, +}); diff --git a/vendor/actors/examples/docs/actors-permissions/role-based-access-control.ts b/vendor/actors/examples/docs/actors-permissions/role-based-access-control.ts new file mode 100644 index 00000000..648e4942 --- /dev/null +++ b/vendor/actors/examples/docs/actors-permissions/role-based-access-control.ts @@ -0,0 +1,61 @@ +import { actor, UserError } from "rivetkit"; + +const ROLE_HIERARCHY = { user: 1, moderator: 2, admin: 3 }; + +interface ConnState { + role: keyof typeof ROLE_HIERARCHY; + permissions: string[]; +} + +// Example token validation function +async function validateToken( + token: string, +): Promise<{ role: keyof typeof ROLE_HIERARCHY; permissions: string[] }> { + // In production, verify JWT or call auth service + return { role: "user", permissions: ["read", "edit_posts"] }; +} + +function requireRole(requiredRole: keyof typeof ROLE_HIERARCHY) { + return (c: { conn: { state: ConnState } }) => { + const userRole = c.conn.state.role; + if (ROLE_HIERARCHY[userRole] < ROLE_HIERARCHY[requiredRole]) { + throw new UserError(`${requiredRole} role required`, { + code: "forbidden", + }); + } + }; +} + +function requirePermission(permission: string) { + return (c: { conn: { state: ConnState } }) => { + if (!c.conn.state.permissions?.includes(permission)) { + throw new UserError(`Permission '${permission}' required`, { + code: "forbidden", + }); + } + }; +} + +const forumActor = actor({ + state: {}, + + createConnState: async ( + c, + params: { token: string }, + ): Promise => { + const user = await validateToken(params.token); + return { role: user.role, permissions: user.permissions }; + }, + + actions: { + deletePost: (c, postId: string) => { + requireRole("moderator")(c); + // Delete post... + }, + + editPost: (c, postId: string, content: string) => { + requirePermission("edit_posts")(c); + // Edit post... + }, + }, +}); diff --git a/vendor/actors/examples/docs/actors-permissions/using-state.ts b/vendor/actors/examples/docs/actors-permissions/using-state.ts new file mode 100644 index 00000000..721bb3b1 --- /dev/null +++ b/vendor/actors/examples/docs/actors-permissions/using-state.ts @@ -0,0 +1,25 @@ +import { actor, UserError } from "rivetkit"; + +interface ConnParams { + userId?: string; +} + +const userProfile = actor({ + state: { + ownerId: "user-123", + isPrivate: true, + }, + + onBeforeConnect: (c, params: ConnParams) => { + // Use actor state to check access permissions + if (c.state.isPrivate && params.userId !== c.state.ownerId) { + throw new UserError("Access denied to private profile", { + code: "forbidden", + }); + } + }, + + actions: { + getProfile: (c) => ({ ownerId: c.state.ownerId }), + }, +}); diff --git a/vendor/actors/examples/docs/general-jwt/grants.ts b/vendor/actors/examples/docs/general-jwt/grants.ts new file mode 100644 index 00000000..bb456075 --- /dev/null +++ b/vendor/actors/examples/docs/general-jwt/grants.ts @@ -0,0 +1,36 @@ +import { createClient } from "rivetkit/client"; +import type { registry } from "./quickstart/registry"; + +const client = createClient(); +const userId = "user_alice"; +const profile = client.userProfile.getOrCreate(["user", userId]); + +// Reach exactly one actor. This is the default, so `permissions` can be +// omitted entirely. The holder cannot create actors or discover others. +export const oneActor = () => profile.issueToken({ subject: userId }); + +// Widen what the holder may do to that same actor. Every grant stays scoped +// to its resolved ID. +export const oneActorWithKv = () => + profile.issueToken({ + subject: userId, + permissions: { + actor_gateway: ["read"], + actor_kv: ["read"], + }, + }); + +// Namespace-wide operations such as creating actors need an explicit grant +// list. Nothing is added automatically. +export const anyActorInNamespace = () => + client.auth.issueToken({ + subject: userId, + grants: [ + { + resource: "actor", + target: "any", + operations: ["create", "read"], + }, + { resource: "actor_gateway", target: "any", operations: ["read"] }, + ], + }); diff --git a/vendor/actors/examples/docs/general-jwt/quickstart/client.ts b/vendor/actors/examples/docs/general-jwt/quickstart/client.ts new file mode 100644 index 00000000..e3fb8ff1 --- /dev/null +++ b/vendor/actors/examples/docs/general-jwt/quickstart/client.ts @@ -0,0 +1,27 @@ +import { createClient } from "rivetkit/client"; +import type { registry } from "./registry"; + +// Calls your own backend, never the Rivet control plane directly. +async function fetchToken(): Promise<{ actorId: string; token: string }> { + const response = await fetch("/token", { + method: "POST", + cache: "no-store", + }); + if (!response.ok) throw new Error("could not get a Rivet token"); + return (await response.json()) as { actorId: string; token: string }; +} + +const { actorId } = await fetchToken(); + +const client = createClient({ + endpoint: "https://api.rivet.dev", + namespace: "production", + + // Called whenever RivetKit needs a credential, including after one expires. + getToken: async () => (await fetchToken()).token, +}); + +const profile = client.userProfile.getForId(actorId); +const conn = profile.connect(); + +await conn.recordVisit(); diff --git a/vendor/actors/examples/docs/general-jwt/quickstart/registry.ts b/vendor/actors/examples/docs/general-jwt/quickstart/registry.ts new file mode 100644 index 00000000..b48ae124 --- /dev/null +++ b/vendor/actors/examples/docs/general-jwt/quickstart/registry.ts @@ -0,0 +1,17 @@ +import { actor, setup } from "rivetkit"; + +export const userProfile = actor({ + state: { displayName: "", visits: 0 }, + + actions: { + recordVisit: (c) => { + c.state.visits += 1; + return c.state.visits; + }, + setDisplayName: (c, displayName: string) => { + c.state.displayName = displayName; + }, + }, +}); + +export const registry = setup({ use: { userProfile } }); diff --git a/vendor/actors/examples/docs/general-jwt/quickstart/server.ts b/vendor/actors/examples/docs/general-jwt/quickstart/server.ts new file mode 100644 index 00000000..86bfefdf --- /dev/null +++ b/vendor/actors/examples/docs/general-jwt/quickstart/server.ts @@ -0,0 +1,35 @@ +import { Hono } from "hono"; +import { createClient } from "rivetkit/client"; +import { registry } from "./registry"; + +// The issuing credential stays on the backend. It is never sent to a browser. +const client = createClient({ + endpoint: process.env.RIVET_ENDPOINT!, + namespace: process.env.RIVET_NAMESPACE!, + token: process.env.RIVET_ADMIN_TOKEN!, +}); + +// Replace this with your own session check. +async function authenticateUser(request: Request): Promise { + return request.headers.get("x-demo-user"); +} + +const app = new Hono(); + +app.post("/token", async (c) => { + const userId = await authenticateUser(c.req.raw); + if (!userId) return c.json({ error: "unauthorized" }, 401); + + // Scoped to this one actor. The default permission is gateway read. + const profile = client.userProfile.getOrCreate(["user", userId]); + const { token, expiresAt } = await profile.issueToken({ + subject: userId, + expiresIn: 900, + }); + + return c.json({ actorId: await profile.resolve(), token, expiresAt }, 200, { + "Cache-Control": "no-store", + }); +}); + +export default app; diff --git a/vendor/actors/rivetkit-typescript/packages/rivetkit/package.json b/vendor/actors/rivetkit-typescript/packages/rivetkit/package.json index 0e9af458..30f74204 100644 --- a/vendor/actors/rivetkit-typescript/packages/rivetkit/package.json +++ b/vendor/actors/rivetkit-typescript/packages/rivetkit/package.json @@ -213,6 +213,7 @@ "@rivet-dev/agent-os-core": "^0.1.1", "@rivet-dev/services": "^0.1.5", "@rivetkit/bare-ts": "^0.6.2", + "@rivetkit/engine-api-full": "workspace:*", "@rivetkit/engine-cli": "workspace:*", "@rivetkit/engine-envoy-protocol": "workspace:*", "@rivetkit/on-change": "6.0.1",