diff --git a/docs/api/content/actor-keys.mdx b/docs/api/content/actor-keys.mdx index 9e319727a0..d6142d9a2e 100644 --- a/docs/api/content/actor-keys.mdx +++ b/docs/api/content/actor-keys.mdx @@ -34,7 +34,9 @@ Escaping is what makes array keys safe to build from user data. `["org", "a/b"]` ## Keys on gateway URLs -The gateway takes the key as the `rvt-key` query parameter and splits it on commas instead of slashes: `rvt-key=org-1,room-2` is `["org-1", "room-2"]`. There is no escape syntax, so a part that contains a comma cannot be expressed on a gateway URL. Create such actors through the control plane and route to them by actor ID. See [Actor Routing](/docs/api/actor-routing). +The gateway takes the key as one or more `rvt-key-part` query parameters, one occurrence per part: `rvt-key-part=org-1&rvt-key-part=room-2` is `["org-1", "room-2"]`. Each occurrence is a complete, independently percent-encoded part, so a part containing a comma, slash, or any other character is expressed exactly as given, with no escaping needed. + +The gateway also accepts the legacy `rvt-key` parameter, which splits a single value on commas: `rvt-key=org-1,room-2` is `["org-1", "room-2"]`. This form cannot express a part that contains a comma, since it becomes indistinguishable from two separate parts. It is kept only for older clients; new clients should always send `rvt-key-part`. See [Actor Routing](/docs/api/actor-routing). ## Related diff --git a/docs/api/content/actor-routing.mdx b/docs/api/content/actor-routing.mdx index 2f50fb9dcb..ea56c4c6fe 100644 --- a/docs/api/content/actor-routing.mdx +++ b/docs/api/content/actor-routing.mdx @@ -30,7 +30,7 @@ Actor IDs come from the response of [Create Actor](/docs/api/actors/create), [Ge Put the actor name in the path and describe the lookup with `rvt-*` query parameters. Rivet resolves the actor, creates it if asked, waits for it to be ready, and forwards the request. ```bash -curl "https://api.rivet.dev/gateway/counter/action/increment?rvt-namespace=$RIVET_NAMESPACE&rvt-method=getOrCreate&rvt-key=my-counter&rvt-pool=default" \ +curl "https://api.rivet.dev/gateway/counter/action/increment?rvt-namespace=$RIVET_NAMESPACE&rvt-method=getOrCreate&rvt-key-part=my-counter&rvt-pool=default" \ -H "x-rivet-token: $RIVET_TOKEN" \ -H "Content-Type: application/json" \ -d '{"args": [1]}' @@ -44,7 +44,8 @@ This is what the RivetKit client does for `client.counter.getOrCreate(["my-count | --- | --- | --- | | `rvt-namespace` | Yes | Namespace the actor lives in. | | `rvt-method` | Yes | `get` to fail if the actor does not exist, or `getOrCreate` to create it on first use. | -| `rvt-key` | No | Actor key. Separate multi-part keys with commas: `rvt-key=org-1,room-2`. Omit for a keyless actor. | +| `rvt-key-part` | No | One key part. Repeat for a multi-part key, in order: `rvt-key-part=org-1&rvt-key-part=room-2`. Omit entirely for a keyless actor. | +| `rvt-key` | No | Legacy single-parameter key encoding. Separate multi-part keys with commas: `rvt-key=org-1,room-2`. Prefer `rvt-key-part`; this form cannot express a part that contains a comma. | | `rvt-pool` | For `getOrCreate` | Pool to create the actor on. RivetKit registers the `default` pool unless configured otherwise. `rvt-runner` is a deprecated alias. | | `rvt-input` | No | Base64url-encoded CBOR passed to `createState` and `onCreate` if the actor is created. Only with `getOrCreate`. | | `rvt-region` | No | Preferred region if the actor is created. Only with `getOrCreate`. | @@ -54,8 +55,8 @@ This is what the RivetKit client does for `client.counter.getOrCreate(["my-count ### Rules -- Every `rvt-*` parameter is reserved. Unknown or duplicated `rvt-*` parameters return a `400`. +- Every `rvt-*` parameter is reserved. Unknown `rvt-*` parameters return a `400`, as does a duplicated `rvt-*` parameter other than `rvt-key-part`, which is expected to repeat once per key part. - `rvt-*` parameters are stripped before the request reaches your actor. Other query parameters pass through unchanged, so `/gateway/counter/request/search?q=hi&rvt-namespace=...` reaches `onRequest` as `/search?q=hi`. - `rvt-method=get` never creates an actor. Passing `rvt-input`, `rvt-region`, `rvt-crash-policy`, or `rvt-runner` with `get` returns a `400`. - The `@{token}` path syntax is not allowed with actor names. Use `rvt-token` or the header instead. -- Commas inside a key part cannot be expressed in `rvt-key`. Create such actors with [Create Actor](/docs/api/actors/create) and route to them by ID. See [Actor Keys](/docs/api/actor-keys) for how the control plane encodes keys. +- If both `rvt-key-part` and `rvt-key` are present, `rvt-key-part` wins. Use `rvt-key-part` for any key part that might contain a comma; see [Actor Keys](/docs/api/actor-keys) for how the control plane encodes keys. diff --git a/docs/api/content/gateway/call-action.mdx b/docs/api/content/gateway/call-action.mdx index 835ec6950d..379a8ac248 100644 --- a/docs/api/content/gateway/call-action.mdx +++ b/docs/api/content/gateway/call-action.mdx @@ -51,7 +51,8 @@ Send a token in the `x-rivet-token` header, inline in the path as `{actor_id}@{t | --- | --- | --- | --- | | `rvt-namespace` | `string` | No | Namespace the actor lives in. Required when `{actor}` is an actor name. | | `rvt-method` | `string` | No | How to resolve an actor name to an actor. `getOrCreate` requires `rvt-pool`. One of `"get"`, `"getOrCreate"`. | -| `rvt-key` | `string` | No | Actor key. Separate multi-part keys with commas. | +| `rvt-key` | `string` | No | Actor key. Separate multi-part keys with commas. Deprecated in favor of `rvt-key-part`, which unambiguously represents a component containing a literal comma. | +| `rvt-key-part` | `string[]` | No | Actor key component. Repeat this parameter once per key component instead of comma-joining them into `rvt-key`. Takes precedence over `rvt-key` when both are present. | | `rvt-pool` | `string` | No | Pool to create the actor on when `rvt-method=getOrCreate`. RivetKit registers the `default` pool unless configured otherwise. | | `rvt-runner` | `string` | No | Deprecated alias for `rvt-pool`. | | `rvt-input` | `string` | No | Base64url-encoded CBOR input passed to `createState` and `onCreate` when `rvt-method=getOrCreate` creates the actor. | diff --git a/docs/api/content/gateway/health.mdx b/docs/api/content/gateway/health.mdx index de6438c331..af4673a02b 100644 --- a/docs/api/content/gateway/health.mdx +++ b/docs/api/content/gateway/health.mdx @@ -42,7 +42,8 @@ Send a token in the `x-rivet-token` header, inline in the path as `{actor_id}@{t | --- | --- | --- | --- | | `rvt-namespace` | `string` | No | Namespace the actor lives in. Required when `{actor}` is an actor name. | | `rvt-method` | `string` | No | How to resolve an actor name to an actor. `getOrCreate` requires `rvt-pool`. One of `"get"`, `"getOrCreate"`. | -| `rvt-key` | `string` | No | Actor key. Separate multi-part keys with commas. | +| `rvt-key` | `string` | No | Actor key. Separate multi-part keys with commas. Deprecated in favor of `rvt-key-part`, which unambiguously represents a component containing a literal comma. | +| `rvt-key-part` | `string[]` | No | Actor key component. Repeat this parameter once per key component instead of comma-joining them into `rvt-key`. Takes precedence over `rvt-key` when both are present. | | `rvt-pool` | `string` | No | Pool to create the actor on when `rvt-method=getOrCreate`. RivetKit registers the `default` pool unless configured otherwise. | | `rvt-runner` | `string` | No | Deprecated alias for `rvt-pool`. | | `rvt-input` | `string` | No | Base64url-encoded CBOR input passed to `createState` and `onCreate` when `rvt-method=getOrCreate` creates the actor. | diff --git a/docs/api/content/gateway/metadata.mdx b/docs/api/content/gateway/metadata.mdx index 4078765cc9..c17e90b5b4 100644 --- a/docs/api/content/gateway/metadata.mdx +++ b/docs/api/content/gateway/metadata.mdx @@ -42,7 +42,8 @@ Send a token in the `x-rivet-token` header, inline in the path as `{actor_id}@{t | --- | --- | --- | --- | | `rvt-namespace` | `string` | No | Namespace the actor lives in. Required when `{actor}` is an actor name. | | `rvt-method` | `string` | No | How to resolve an actor name to an actor. `getOrCreate` requires `rvt-pool`. One of `"get"`, `"getOrCreate"`. | -| `rvt-key` | `string` | No | Actor key. Separate multi-part keys with commas. | +| `rvt-key` | `string` | No | Actor key. Separate multi-part keys with commas. Deprecated in favor of `rvt-key-part`, which unambiguously represents a component containing a literal comma. | +| `rvt-key-part` | `string[]` | No | Actor key component. Repeat this parameter once per key component instead of comma-joining them into `rvt-key`. Takes precedence over `rvt-key` when both are present. | | `rvt-pool` | `string` | No | Pool to create the actor on when `rvt-method=getOrCreate`. RivetKit registers the `default` pool unless configured otherwise. | | `rvt-runner` | `string` | No | Deprecated alias for `rvt-pool`. | | `rvt-input` | `string` | No | Base64url-encoded CBOR input passed to `createState` and `onCreate` when `rvt-method=getOrCreate` creates the actor. | diff --git a/docs/api/content/gateway/queue.mdx b/docs/api/content/gateway/queue.mdx index e1245953f8..d41545112f 100644 --- a/docs/api/content/gateway/queue.mdx +++ b/docs/api/content/gateway/queue.mdx @@ -53,7 +53,8 @@ Send a token in the `x-rivet-token` header, inline in the path as `{actor_id}@{t | --- | --- | --- | --- | | `rvt-namespace` | `string` | No | Namespace the actor lives in. Required when `{actor}` is an actor name. | | `rvt-method` | `string` | No | How to resolve an actor name to an actor. `getOrCreate` requires `rvt-pool`. One of `"get"`, `"getOrCreate"`. | -| `rvt-key` | `string` | No | Actor key. Separate multi-part keys with commas. | +| `rvt-key` | `string` | No | Actor key. Separate multi-part keys with commas. Deprecated in favor of `rvt-key-part`, which unambiguously represents a component containing a literal comma. | +| `rvt-key-part` | `string[]` | No | Actor key component. Repeat this parameter once per key component instead of comma-joining them into `rvt-key`. Takes precedence over `rvt-key` when both are present. | | `rvt-pool` | `string` | No | Pool to create the actor on when `rvt-method=getOrCreate`. RivetKit registers the `default` pool unless configured otherwise. | | `rvt-runner` | `string` | No | Deprecated alias for `rvt-pool`. | | `rvt-input` | `string` | No | Base64url-encoded CBOR input passed to `createState` and `onCreate` when `rvt-method=getOrCreate` creates the actor. | diff --git a/docs/api/content/gateway/request.mdx b/docs/api/content/gateway/request.mdx index 58be200b6f..1ad5396421 100644 --- a/docs/api/content/gateway/request.mdx +++ b/docs/api/content/gateway/request.mdx @@ -47,7 +47,8 @@ Send a token in the `x-rivet-token` header, inline in the path as `{actor_id}@{t | --- | --- | --- | --- | | `rvt-namespace` | `string` | No | Namespace the actor lives in. Required when `{actor}` is an actor name. | | `rvt-method` | `string` | No | How to resolve an actor name to an actor. `getOrCreate` requires `rvt-pool`. One of `"get"`, `"getOrCreate"`. | -| `rvt-key` | `string` | No | Actor key. Separate multi-part keys with commas. | +| `rvt-key` | `string` | No | Actor key. Separate multi-part keys with commas. Deprecated in favor of `rvt-key-part`, which unambiguously represents a component containing a literal comma. | +| `rvt-key-part` | `string[]` | No | Actor key component. Repeat this parameter once per key component instead of comma-joining them into `rvt-key`. Takes precedence over `rvt-key` when both are present. | | `rvt-pool` | `string` | No | Pool to create the actor on when `rvt-method=getOrCreate`. RivetKit registers the `default` pool unless configured otherwise. | | `rvt-runner` | `string` | No | Deprecated alias for `rvt-pool`. | | `rvt-input` | `string` | No | Base64url-encoded CBOR input passed to `createState` and `onCreate` when `rvt-method=getOrCreate` creates the actor. | diff --git a/docs/api/content/inspector/action.mdx b/docs/api/content/inspector/action.mdx index ee7dd8b125..dac6a96bd1 100644 --- a/docs/api/content/inspector/action.mdx +++ b/docs/api/content/inspector/action.mdx @@ -59,7 +59,8 @@ Inspector endpoints do not accept namespace tokens. Send the actor's inspector t | --- | --- | --- | --- | | `rvt-namespace` | `string` | No | Namespace the actor lives in. Required when `{actor}` is an actor name. | | `rvt-method` | `string` | No | How to resolve an actor name to an actor. `getOrCreate` requires `rvt-pool`. One of `"get"`, `"getOrCreate"`. | -| `rvt-key` | `string` | No | Actor key. Separate multi-part keys with commas. | +| `rvt-key` | `string` | No | Actor key. Separate multi-part keys with commas. Deprecated in favor of `rvt-key-part`, which unambiguously represents a component containing a literal comma. | +| `rvt-key-part` | `string[]` | No | Actor key component. Repeat this parameter once per key component instead of comma-joining them into `rvt-key`. Takes precedence over `rvt-key` when both are present. | | `rvt-pool` | `string` | No | Pool to create the actor on when `rvt-method=getOrCreate`. RivetKit registers the `default` pool unless configured otherwise. | | `rvt-runner` | `string` | No | Deprecated alias for `rvt-pool`. | | `rvt-input` | `string` | No | Base64url-encoded CBOR input passed to `createState` and `onCreate` when `rvt-method=getOrCreate` creates the actor. | diff --git a/docs/api/content/inspector/connections.mdx b/docs/api/content/inspector/connections.mdx index 83c22b95c4..05b6cb5a52 100644 --- a/docs/api/content/inspector/connections.mdx +++ b/docs/api/content/inspector/connections.mdx @@ -52,7 +52,8 @@ Inspector endpoints do not accept namespace tokens. Send the actor's inspector t | --- | --- | --- | --- | | `rvt-namespace` | `string` | No | Namespace the actor lives in. Required when `{actor}` is an actor name. | | `rvt-method` | `string` | No | How to resolve an actor name to an actor. `getOrCreate` requires `rvt-pool`. One of `"get"`, `"getOrCreate"`. | -| `rvt-key` | `string` | No | Actor key. Separate multi-part keys with commas. | +| `rvt-key` | `string` | No | Actor key. Separate multi-part keys with commas. Deprecated in favor of `rvt-key-part`, which unambiguously represents a component containing a literal comma. | +| `rvt-key-part` | `string[]` | No | Actor key component. Repeat this parameter once per key component instead of comma-joining them into `rvt-key`. Takes precedence over `rvt-key` when both are present. | | `rvt-pool` | `string` | No | Pool to create the actor on when `rvt-method=getOrCreate`. RivetKit registers the `default` pool unless configured otherwise. | | `rvt-runner` | `string` | No | Deprecated alias for `rvt-pool`. | | `rvt-input` | `string` | No | Base64url-encoded CBOR input passed to `createState` and `onCreate` when `rvt-method=getOrCreate` creates the actor. | diff --git a/docs/api/content/inspector/queue-get.mdx b/docs/api/content/inspector/queue-get.mdx index 07b589fa86..3c19d276e3 100644 --- a/docs/api/content/inspector/queue-get.mdx +++ b/docs/api/content/inspector/queue-get.mdx @@ -58,7 +58,8 @@ Inspector endpoints do not accept namespace tokens. Send the actor's inspector t | --- | --- | --- | --- | | `rvt-namespace` | `string` | No | Namespace the actor lives in. Required when `{actor}` is an actor name. | | `rvt-method` | `string` | No | How to resolve an actor name to an actor. `getOrCreate` requires `rvt-pool`. One of `"get"`, `"getOrCreate"`. | -| `rvt-key` | `string` | No | Actor key. Separate multi-part keys with commas. | +| `rvt-key` | `string` | No | Actor key. Separate multi-part keys with commas. Deprecated in favor of `rvt-key-part`, which unambiguously represents a component containing a literal comma. | +| `rvt-key-part` | `string[]` | No | Actor key component. Repeat this parameter once per key component instead of comma-joining them into `rvt-key`. Takes precedence over `rvt-key` when both are present. | | `rvt-pool` | `string` | No | Pool to create the actor on when `rvt-method=getOrCreate`. RivetKit registers the `default` pool unless configured otherwise. | | `rvt-runner` | `string` | No | Deprecated alias for `rvt-pool`. | | `rvt-input` | `string` | No | Base64url-encoded CBOR input passed to `createState` and `onCreate` when `rvt-method=getOrCreate` creates the actor. | diff --git a/docs/api/content/inspector/rpcs.mdx b/docs/api/content/inspector/rpcs.mdx index fe976e729b..53609a7e9d 100644 --- a/docs/api/content/inspector/rpcs.mdx +++ b/docs/api/content/inspector/rpcs.mdx @@ -52,7 +52,8 @@ Inspector endpoints do not accept namespace tokens. Send the actor's inspector t | --- | --- | --- | --- | | `rvt-namespace` | `string` | No | Namespace the actor lives in. Required when `{actor}` is an actor name. | | `rvt-method` | `string` | No | How to resolve an actor name to an actor. `getOrCreate` requires `rvt-pool`. One of `"get"`, `"getOrCreate"`. | -| `rvt-key` | `string` | No | Actor key. Separate multi-part keys with commas. | +| `rvt-key` | `string` | No | Actor key. Separate multi-part keys with commas. Deprecated in favor of `rvt-key-part`, which unambiguously represents a component containing a literal comma. | +| `rvt-key-part` | `string[]` | No | Actor key component. Repeat this parameter once per key component instead of comma-joining them into `rvt-key`. Takes precedence over `rvt-key` when both are present. | | `rvt-pool` | `string` | No | Pool to create the actor on when `rvt-method=getOrCreate`. RivetKit registers the `default` pool unless configured otherwise. | | `rvt-runner` | `string` | No | Deprecated alias for `rvt-pool`. | | `rvt-input` | `string` | No | Base64url-encoded CBOR input passed to `createState` and `onCreate` when `rvt-method=getOrCreate` creates the actor. | diff --git a/docs/api/content/inspector/state-get.mdx b/docs/api/content/inspector/state-get.mdx index 72b8a49af8..be866e2499 100644 --- a/docs/api/content/inspector/state-get.mdx +++ b/docs/api/content/inspector/state-get.mdx @@ -52,7 +52,8 @@ Inspector endpoints do not accept namespace tokens. Send the actor's inspector t | --- | --- | --- | --- | | `rvt-namespace` | `string` | No | Namespace the actor lives in. Required when `{actor}` is an actor name. | | `rvt-method` | `string` | No | How to resolve an actor name to an actor. `getOrCreate` requires `rvt-pool`. One of `"get"`, `"getOrCreate"`. | -| `rvt-key` | `string` | No | Actor key. Separate multi-part keys with commas. | +| `rvt-key` | `string` | No | Actor key. Separate multi-part keys with commas. Deprecated in favor of `rvt-key-part`, which unambiguously represents a component containing a literal comma. | +| `rvt-key-part` | `string[]` | No | Actor key component. Repeat this parameter once per key component instead of comma-joining them into `rvt-key`. Takes precedence over `rvt-key` when both are present. | | `rvt-pool` | `string` | No | Pool to create the actor on when `rvt-method=getOrCreate`. RivetKit registers the `default` pool unless configured otherwise. | | `rvt-runner` | `string` | No | Deprecated alias for `rvt-pool`. | | `rvt-input` | `string` | No | Base64url-encoded CBOR input passed to `createState` and `onCreate` when `rvt-method=getOrCreate` creates the actor. | diff --git a/docs/api/content/inspector/state-patch.mdx b/docs/api/content/inspector/state-patch.mdx index c85acc01f3..256521357e 100644 --- a/docs/api/content/inspector/state-patch.mdx +++ b/docs/api/content/inspector/state-patch.mdx @@ -60,7 +60,8 @@ Inspector endpoints do not accept namespace tokens. Send the actor's inspector t | --- | --- | --- | --- | | `rvt-namespace` | `string` | No | Namespace the actor lives in. Required when `{actor}` is an actor name. | | `rvt-method` | `string` | No | How to resolve an actor name to an actor. `getOrCreate` requires `rvt-pool`. One of `"get"`, `"getOrCreate"`. | -| `rvt-key` | `string` | No | Actor key. Separate multi-part keys with commas. | +| `rvt-key` | `string` | No | Actor key. Separate multi-part keys with commas. Deprecated in favor of `rvt-key-part`, which unambiguously represents a component containing a literal comma. | +| `rvt-key-part` | `string[]` | No | Actor key component. Repeat this parameter once per key component instead of comma-joining them into `rvt-key`. Takes precedence over `rvt-key` when both are present. | | `rvt-pool` | `string` | No | Pool to create the actor on when `rvt-method=getOrCreate`. RivetKit registers the `default` pool unless configured otherwise. | | `rvt-runner` | `string` | No | Deprecated alias for `rvt-pool`. | | `rvt-input` | `string` | No | Base64url-encoded CBOR input passed to `createState` and `onCreate` when `rvt-method=getOrCreate` creates the actor. | diff --git a/docs/api/content/inspector/summary.mdx b/docs/api/content/inspector/summary.mdx index fc12eab391..5ece7b7031 100644 --- a/docs/api/content/inspector/summary.mdx +++ b/docs/api/content/inspector/summary.mdx @@ -52,7 +52,8 @@ Inspector endpoints do not accept namespace tokens. Send the actor's inspector t | --- | --- | --- | --- | | `rvt-namespace` | `string` | No | Namespace the actor lives in. Required when `{actor}` is an actor name. | | `rvt-method` | `string` | No | How to resolve an actor name to an actor. `getOrCreate` requires `rvt-pool`. One of `"get"`, `"getOrCreate"`. | -| `rvt-key` | `string` | No | Actor key. Separate multi-part keys with commas. | +| `rvt-key` | `string` | No | Actor key. Separate multi-part keys with commas. Deprecated in favor of `rvt-key-part`, which unambiguously represents a component containing a literal comma. | +| `rvt-key-part` | `string[]` | No | Actor key component. Repeat this parameter once per key component instead of comma-joining them into `rvt-key`. Takes precedence over `rvt-key` when both are present. | | `rvt-pool` | `string` | No | Pool to create the actor on when `rvt-method=getOrCreate`. RivetKit registers the `default` pool unless configured otherwise. | | `rvt-runner` | `string` | No | Deprecated alias for `rvt-pool`. | | `rvt-input` | `string` | No | Base64url-encoded CBOR input passed to `createState` and `onCreate` when `rvt-method=getOrCreate` creates the actor. | diff --git a/docs/api/content/inspector/traces.mdx b/docs/api/content/inspector/traces.mdx index d241af78ce..ffb1019e5e 100644 --- a/docs/api/content/inspector/traces.mdx +++ b/docs/api/content/inspector/traces.mdx @@ -62,7 +62,8 @@ Inspector endpoints do not accept namespace tokens. Send the actor's inspector t | --- | --- | --- | --- | | `rvt-namespace` | `string` | No | Namespace the actor lives in. Required when `{actor}` is an actor name. | | `rvt-method` | `string` | No | How to resolve an actor name to an actor. `getOrCreate` requires `rvt-pool`. One of `"get"`, `"getOrCreate"`. | -| `rvt-key` | `string` | No | Actor key. Separate multi-part keys with commas. | +| `rvt-key` | `string` | No | Actor key. Separate multi-part keys with commas. Deprecated in favor of `rvt-key-part`, which unambiguously represents a component containing a literal comma. | +| `rvt-key-part` | `string[]` | No | Actor key component. Repeat this parameter once per key component instead of comma-joining them into `rvt-key`. Takes precedence over `rvt-key` when both are present. | | `rvt-pool` | `string` | No | Pool to create the actor on when `rvt-method=getOrCreate`. RivetKit registers the `default` pool unless configured otherwise. | | `rvt-runner` | `string` | No | Deprecated alias for `rvt-pool`. | | `rvt-input` | `string` | No | Base64url-encoded CBOR input passed to `createState` and `onCreate` when `rvt-method=getOrCreate` creates the actor. | diff --git a/docs/api/content/inspector/workflow-history.mdx b/docs/api/content/inspector/workflow-history.mdx index 6f56124d16..92e5a2a122 100644 --- a/docs/api/content/inspector/workflow-history.mdx +++ b/docs/api/content/inspector/workflow-history.mdx @@ -52,7 +52,8 @@ Inspector endpoints do not accept namespace tokens. Send the actor's inspector t | --- | --- | --- | --- | | `rvt-namespace` | `string` | No | Namespace the actor lives in. Required when `{actor}` is an actor name. | | `rvt-method` | `string` | No | How to resolve an actor name to an actor. `getOrCreate` requires `rvt-pool`. One of `"get"`, `"getOrCreate"`. | -| `rvt-key` | `string` | No | Actor key. Separate multi-part keys with commas. | +| `rvt-key` | `string` | No | Actor key. Separate multi-part keys with commas. Deprecated in favor of `rvt-key-part`, which unambiguously represents a component containing a literal comma. | +| `rvt-key-part` | `string[]` | No | Actor key component. Repeat this parameter once per key component instead of comma-joining them into `rvt-key`. Takes precedence over `rvt-key` when both are present. | | `rvt-pool` | `string` | No | Pool to create the actor on when `rvt-method=getOrCreate`. RivetKit registers the `default` pool unless configured otherwise. | | `rvt-runner` | `string` | No | Deprecated alias for `rvt-pool`. | | `rvt-input` | `string` | No | Base64url-encoded CBOR input passed to `createState` and `onCreate` when `rvt-method=getOrCreate` creates the actor. | diff --git a/docs/api/content/inspector/workflow-replay.mdx b/docs/api/content/inspector/workflow-replay.mdx index 97cbb57736..b04189307b 100644 --- a/docs/api/content/inspector/workflow-replay.mdx +++ b/docs/api/content/inspector/workflow-replay.mdx @@ -56,7 +56,8 @@ Inspector endpoints do not accept namespace tokens. Send the actor's inspector t | --- | --- | --- | --- | | `rvt-namespace` | `string` | No | Namespace the actor lives in. Required when `{actor}` is an actor name. | | `rvt-method` | `string` | No | How to resolve an actor name to an actor. `getOrCreate` requires `rvt-pool`. One of `"get"`, `"getOrCreate"`. | -| `rvt-key` | `string` | No | Actor key. Separate multi-part keys with commas. | +| `rvt-key` | `string` | No | Actor key. Separate multi-part keys with commas. Deprecated in favor of `rvt-key-part`, which unambiguously represents a component containing a literal comma. | +| `rvt-key-part` | `string[]` | No | Actor key component. Repeat this parameter once per key component instead of comma-joining them into `rvt-key`. Takes precedence over `rvt-key` when both are present. | | `rvt-pool` | `string` | No | Pool to create the actor on when `rvt-method=getOrCreate`. RivetKit registers the `default` pool unless configured otherwise. | | `rvt-runner` | `string` | No | Deprecated alias for `rvt-pool`. | | `rvt-input` | `string` | No | Base64url-encoded CBOR input passed to `createState` and `onCreate` when `rvt-method=getOrCreate` creates the actor. | diff --git a/docs/api/endpoints.json b/docs/api/endpoints.json index 029154f41f..5fe14e7d55 100644 --- a/docs/api/endpoints.json +++ b/docs/api/endpoints.json @@ -64,7 +64,8 @@ "actor": "Actor ID, actor ID with an inline token (`{actor_id}@{token}`), or actor name combined with the `rvt-*` query parameters.", "rvt-namespace": "Namespace the actor lives in. Required when `{actor}` is an actor name.", "rvt-method": "How to resolve an actor name to an actor. `getOrCreate` requires `rvt-pool`.", - "rvt-key": "Actor key. Separate multi-part keys with commas.", + "rvt-key": "Actor key. Separate multi-part keys with commas. Deprecated in favor of `rvt-key-part`, which unambiguously represents a component containing a literal comma.", + "rvt-key-part": "Actor key component. Repeat this parameter once per key component instead of comma-joining them into `rvt-key`. Takes precedence over `rvt-key` when both are present.", "rvt-pool": "Pool to create the actor on when `rvt-method=getOrCreate`. RivetKit registers the `default` pool unless configured otherwise.", "rvt-runner": "Deprecated alias for `rvt-pool`.", "rvt-input": "Base64url-encoded CBOR input passed to `createState` and `onCreate` when `rvt-method=getOrCreate` creates the actor.", diff --git a/engine/packages/guard/src/routing/actor_path.rs b/engine/packages/guard/src/routing/actor_path.rs index 64dd0302d4..c7c5c31d3d 100644 --- a/engine/packages/guard/src/routing/actor_path.rs +++ b/engine/packages/guard/src/routing/actor_path.rs @@ -89,6 +89,11 @@ struct RvtParams { pool: Option, #[serde(default)] key: Option, + /// Unambiguous key encoding: one `rvt-key-part` occurrence per key + /// component. Takes precedence over the legacy comma-joined `key` field + /// when present. See `build_actor_query`. + #[serde(default, rename = "key-part")] + key_part: Vec, #[serde(default)] input: Option, #[serde(default)] @@ -231,12 +236,21 @@ fn parse_query_actor_path( /// Extract and validate rvt-* params from pre-parsed query pairs. fn extract_rvt_params(rvt_params: &[(String, String)]) -> Result { let mut map = serde_json::Map::new(); + let mut key_parts = Vec::new(); for (raw_key, value) in rvt_params { let stripped = raw_key .strip_prefix(RVT_PREFIX) .expect("rvt_params should only contain rvt- prefixed keys"); + // `key-part` is the only rvt-* param allowed to repeat: one + // occurrence per key component, collected in order below instead of + // going through the single-value duplicate check. + if stripped == "key-part" { + key_parts.push(serde_json::Value::String(value.clone())); + continue; + } + if map.contains_key(stripped) { return Err(errors::QueryDuplicateParam { name: raw_key.clone(), @@ -252,6 +266,8 @@ fn extract_rvt_params(rvt_params: &[(String, String)]) -> Result { map.insert(stripped.to_string(), value); } + map.insert("key-part".to_string(), serde_json::Value::Array(key_parts)); + serde_json::from_value(serde_json::Value::Object(map)).map_err(|e| { errors::QueryInvalidParams { detail: e.to_string(), @@ -270,6 +286,12 @@ fn parse_query_bool(value: &str) -> Option { /// Split a comma-separated key string into components. /// Missing or empty key yields an empty vec. +/// +/// This is the legacy encoding: it cannot distinguish a single component +/// containing a literal comma from multiple components, so it only remains +/// for backward compatibility with clients that predate `rvt-key-part`. New +/// clients must send `rvt-key-part` instead, which `build_actor_query` below +/// prefers whenever present. fn split_key(raw: Option<&str>) -> Vec { match raw { None | Some("") => Vec::new(), @@ -278,7 +300,11 @@ fn split_key(raw: Option<&str>) -> Vec { } fn build_actor_query(name: &str, rvt: RvtParams) -> Result { - let key = split_key(rvt.key.as_deref()); + let key = if !rvt.key_part.is_empty() { + rvt.key_part.clone() + } else { + split_key(rvt.key.as_deref()) + }; match rvt.method.as_str() { "get" => { diff --git a/engine/packages/guard/tests/parse_actor_path.rs b/engine/packages/guard/tests/parse_actor_path.rs index 3502cc5181..4e7d95915e 100644 --- a/engine/packages/guard/tests/parse_actor_path.rs +++ b/engine/packages/guard/tests/parse_actor_path.rs @@ -1,5 +1,8 @@ -// Keep this test suite in sync with the TypeScript equivalent at -// rivetkit-typescript/packages/rivetkit/tests/parse-actor-path.test.ts +// Keep this test suite in sync with the TypeScript equivalents at +// rivetkit-typescript/packages/rivetkit/tests/actor-gateway-url.test.ts +// (URL builder) and +// rivetkit-typescript/packages/rivetkit/tests/driver/gateway-routing.test.ts +// (end-to-end). use base64::{Engine, engine::general_purpose::URL_SAFE_NO_PAD}; use rivet_guard::routing::actor_path::{ ParsedActorPath, QueryActorQuery, is_actor_gateway_path, parse_actor_path, @@ -199,6 +202,105 @@ fn parses_simple_multi_component_keys() { } } +#[test] +fn parses_repeated_key_part_params_in_order() { + let path = + "/gateway/lobby?rvt-namespace=default&rvt-method=get&rvt-key-part=tenant&rvt-key-part=room"; + let result = parse_actor_path(path).unwrap().unwrap(); + + match result { + ParsedActorPath::Query(path) => { + assert_eq!( + path.query, + QueryActorQuery::Get { + namespace: "default".to_string(), + name: "lobby".to_string(), + key: vec!["tenant".to_string(), "room".to_string()], + skip_ready_wait: false, + } + ); + } + ParsedActorPath::Direct(_) => panic!("expected query actor path"), + } +} + +#[test] +fn rvt_key_part_takes_precedence_over_legacy_rvt_key() { + let path = + "/gateway/lobby?rvt-namespace=default&rvt-method=get&rvt-key=ignored&rvt-key-part=tenant"; + let result = parse_actor_path(path).unwrap().unwrap(); + + match result { + ParsedActorPath::Query(path) => { + assert_eq!( + path.query, + QueryActorQuery::Get { + namespace: "default".to_string(), + name: "lobby".to_string(), + key: vec!["tenant".to_string()], + skip_ready_wait: false, + } + ); + } + ParsedActorPath::Direct(_) => panic!("expected query actor path"), + } +} + +#[test] +fn distinguishes_comma_containing_component_from_two_components() { + // Regression coverage for https://github.com/rivet-dev/rivet/issues/5807: + // a component containing a literal comma must not collide with a + // two-component key once encoded via rvt-key-part. + let single_part_with_comma = + "/gateway/lobby?rvt-namespace=default&rvt-method=get&rvt-key-part=tenant%2Cadmin"; + let two_parts = "/gateway/lobby?rvt-namespace=default&rvt-method=get&rvt-key-part=tenant&rvt-key-part=admin"; + + let single_result = parse_actor_path(single_part_with_comma).unwrap().unwrap(); + let two_result = parse_actor_path(two_parts).unwrap().unwrap(); + + let single_key = match single_result { + ParsedActorPath::Query(path) => match path.query { + QueryActorQuery::Get { key, .. } => key, + QueryActorQuery::GetOrCreate { .. } => panic!("expected get query"), + }, + ParsedActorPath::Direct(_) => panic!("expected query actor path"), + }; + let two_key = match two_result { + ParsedActorPath::Query(path) => match path.query { + QueryActorQuery::Get { key, .. } => key, + QueryActorQuery::GetOrCreate { .. } => panic!("expected get query"), + }, + ParsedActorPath::Direct(_) => panic!("expected query actor path"), + }; + + assert_eq!(single_key, vec!["tenant,admin".to_string()]); + assert_eq!(two_key, vec!["tenant".to_string(), "admin".to_string()]); + assert_ne!(single_key, two_key); +} + +#[test] +fn parses_empty_key_part_components() { + // An empty rvt-key-part occurrence is a real component (empty string), + // distinct from omitting the param entirely. + let path = "/gateway/lobby?rvt-namespace=default&rvt-method=get&rvt-key-part=&rvt-key-part=b"; + let result = parse_actor_path(path).unwrap().unwrap(); + + match result { + ParsedActorPath::Query(path) => { + assert_eq!( + path.query, + QueryActorQuery::Get { + namespace: "default".to_string(), + name: "lobby".to_string(), + key: vec!["".to_string(), "b".to_string()], + skip_ready_wait: false, + } + ); + } + ParsedActorPath::Direct(_) => panic!("expected query actor path"), + } +} + #[test] fn parses_crash_policy_param() { let path = "/gateway/worker?rvt-namespace=default&rvt-method=getOrCreate&rvt-runner=default&rvt-crash-policy=restart"; diff --git a/rivetkit-openapi/openapi.json b/rivetkit-openapi/openapi.json index ba1b5e851d..4ae98eef38 100644 --- a/rivetkit-openapi/openapi.json +++ b/rivetkit-openapi/openapi.json @@ -505,6 +505,20 @@ "name": "rvt-key", "description": "Comma-separated actor key components for actor-name resolution." }, + { + "in": "query", + "required": false, + "schema": { + "type": "array", + "items": { + "type": "string" + } + }, + "style": "form", + "explode": true, + "name": "rvt-key-part", + "description": "Actor key components for actor-name resolution, one query occurrence per component. Preferred over the legacy comma-separated rvt-key; takes precedence when both are present." + }, { "in": "query", "required": false, @@ -656,6 +670,20 @@ "name": "rvt-key", "description": "Comma-separated actor key components for actor-name resolution." }, + { + "in": "query", + "required": false, + "schema": { + "type": "array", + "items": { + "type": "string" + } + }, + "style": "form", + "explode": true, + "name": "rvt-key-part", + "description": "Actor key components for actor-name resolution, one query occurrence per component. Preferred over the legacy comma-separated rvt-key; takes precedence when both are present." + }, { "in": "query", "required": false, @@ -836,6 +864,20 @@ "name": "rvt-key", "description": "Comma-separated actor key components for actor-name resolution." }, + { + "in": "query", + "required": false, + "schema": { + "type": "array", + "items": { + "type": "string" + } + }, + "style": "form", + "explode": true, + "name": "rvt-key-part", + "description": "Actor key components for actor-name resolution, one query occurrence per component. Preferred over the legacy comma-separated rvt-key; takes precedence when both are present." + }, { "in": "query", "required": false, @@ -978,6 +1020,20 @@ "name": "rvt-key", "description": "Comma-separated actor key components for actor-name resolution." }, + { + "in": "query", + "required": false, + "schema": { + "type": "array", + "items": { + "type": "string" + } + }, + "style": "form", + "explode": true, + "name": "rvt-key-part", + "description": "Actor key components for actor-name resolution, one query occurrence per component. Preferred over the legacy comma-separated rvt-key; takes precedence when both are present." + }, { "in": "query", "required": false, @@ -1120,6 +1176,20 @@ "name": "rvt-key", "description": "Comma-separated actor key components for actor-name resolution." }, + { + "in": "query", + "required": false, + "schema": { + "type": "array", + "items": { + "type": "string" + } + }, + "style": "form", + "explode": true, + "name": "rvt-key-part", + "description": "Actor key components for actor-name resolution, one query occurrence per component. Preferred over the legacy comma-separated rvt-key; takes precedence when both are present." + }, { "in": "query", "required": false, @@ -1262,6 +1332,20 @@ "name": "rvt-key", "description": "Comma-separated actor key components for actor-name resolution." }, + { + "in": "query", + "required": false, + "schema": { + "type": "array", + "items": { + "type": "string" + } + }, + "style": "form", + "explode": true, + "name": "rvt-key-part", + "description": "Actor key components for actor-name resolution, one query occurrence per component. Preferred over the legacy comma-separated rvt-key; takes precedence when both are present." + }, { "in": "query", "required": false, @@ -1404,6 +1488,20 @@ "name": "rvt-key", "description": "Comma-separated actor key components for actor-name resolution." }, + { + "in": "query", + "required": false, + "schema": { + "type": "array", + "items": { + "type": "string" + } + }, + "style": "form", + "explode": true, + "name": "rvt-key-part", + "description": "Actor key components for actor-name resolution, one query occurrence per component. Preferred over the legacy comma-separated rvt-key; takes precedence when both are present." + }, { "in": "query", "required": false, @@ -1546,6 +1644,20 @@ "name": "rvt-key", "description": "Comma-separated actor key components for actor-name resolution." }, + { + "in": "query", + "required": false, + "schema": { + "type": "array", + "items": { + "type": "string" + } + }, + "style": "form", + "explode": true, + "name": "rvt-key-part", + "description": "Actor key components for actor-name resolution, one query occurrence per component. Preferred over the legacy comma-separated rvt-key; takes precedence when both are present." + }, { "in": "query", "required": false, @@ -1688,6 +1800,20 @@ "name": "rvt-key", "description": "Comma-separated actor key components for actor-name resolution." }, + { + "in": "query", + "required": false, + "schema": { + "type": "array", + "items": { + "type": "string" + } + }, + "style": "form", + "explode": true, + "name": "rvt-key-part", + "description": "Actor key components for actor-name resolution, one query occurrence per component. Preferred over the legacy comma-separated rvt-key; takes precedence when both are present." + }, { "in": "query", "required": false, @@ -1832,6 +1958,20 @@ "name": "rvt-key", "description": "Comma-separated actor key components for actor-name resolution." }, + { + "in": "query", + "required": false, + "schema": { + "type": "array", + "items": { + "type": "string" + } + }, + "style": "form", + "explode": true, + "name": "rvt-key-part", + "description": "Actor key components for actor-name resolution, one query occurrence per component. Preferred over the legacy comma-separated rvt-key; takes precedence when both are present." + }, { "in": "query", "required": false, @@ -1987,6 +2127,20 @@ "name": "rvt-key", "description": "Comma-separated actor key components for actor-name resolution." }, + { + "in": "query", + "required": false, + "schema": { + "type": "array", + "items": { + "type": "string" + } + }, + "style": "form", + "explode": true, + "name": "rvt-key-part", + "description": "Actor key components for actor-name resolution, one query occurrence per component. Preferred over the legacy comma-separated rvt-key; takes precedence when both are present." + }, { "in": "query", "required": false, @@ -2161,6 +2315,20 @@ "name": "rvt-key", "description": "Comma-separated actor key components for actor-name resolution." }, + { + "in": "query", + "required": false, + "schema": { + "type": "array", + "items": { + "type": "string" + } + }, + "style": "form", + "explode": true, + "name": "rvt-key-part", + "description": "Actor key components for actor-name resolution, one query occurrence per component. Preferred over the legacy comma-separated rvt-key; takes precedence when both are present." + }, { "in": "query", "required": false, @@ -2323,6 +2491,20 @@ "name": "rvt-key", "description": "Comma-separated actor key components for actor-name resolution." }, + { + "in": "query", + "required": false, + "schema": { + "type": "array", + "items": { + "type": "string" + } + }, + "style": "form", + "explode": true, + "name": "rvt-key-part", + "description": "Actor key components for actor-name resolution, one query occurrence per component. Preferred over the legacy comma-separated rvt-key; takes precedence when both are present." + }, { "in": "query", "required": false, @@ -2491,6 +2673,20 @@ "name": "rvt-key", "description": "Comma-separated actor key components for actor-name resolution." }, + { + "in": "query", + "required": false, + "schema": { + "type": "array", + "items": { + "type": "string" + } + }, + "style": "form", + "explode": true, + "name": "rvt-key-part", + "description": "Actor key components for actor-name resolution, one query occurrence per component. Preferred over the legacy comma-separated rvt-key; takes precedence when both are present." + }, { "in": "query", "required": false, @@ -2673,6 +2869,20 @@ "name": "rvt-key", "description": "Comma-separated actor key components for actor-name resolution." }, + { + "in": "query", + "required": false, + "schema": { + "type": "array", + "items": { + "type": "string" + } + }, + "style": "form", + "explode": true, + "name": "rvt-key-part", + "description": "Actor key components for actor-name resolution, one query occurrence per component. Preferred over the legacy comma-separated rvt-key; takes precedence when both are present." + }, { "in": "query", "required": false, @@ -3025,6 +3235,20 @@ "name": "rvt-key", "description": "Comma-separated actor key components for actor-name resolution." }, + { + "in": "query", + "required": false, + "schema": { + "type": "array", + "items": { + "type": "string" + } + }, + "style": "form", + "explode": true, + "name": "rvt-key-part", + "description": "Actor key components for actor-name resolution, one query occurrence per component. Preferred over the legacy comma-separated rvt-key; takes precedence when both are present." + }, { "in": "query", "required": false, @@ -3187,6 +3411,20 @@ "name": "rvt-key", "description": "Comma-separated actor key components for actor-name resolution." }, + { + "in": "query", + "required": false, + "schema": { + "type": "array", + "items": { + "type": "string" + } + }, + "style": "form", + "explode": true, + "name": "rvt-key-part", + "description": "Actor key components for actor-name resolution, one query occurrence per component. Preferred over the legacy comma-separated rvt-key; takes precedence when both are present." + }, { "in": "query", "required": false, @@ -3347,6 +3585,20 @@ "name": "rvt-key", "description": "Comma-separated actor key components for actor-name resolution." }, + { + "in": "query", + "required": false, + "schema": { + "type": "array", + "items": { + "type": "string" + } + }, + "style": "form", + "explode": true, + "name": "rvt-key-part", + "description": "Actor key components for actor-name resolution, one query occurrence per component. Preferred over the legacy comma-separated rvt-key; takes precedence when both are present." + }, { "in": "query", "required": false, @@ -3522,6 +3774,20 @@ "name": "rvt-key", "description": "Comma-separated actor key components for actor-name resolution." }, + { + "in": "query", + "required": false, + "schema": { + "type": "array", + "items": { + "type": "string" + } + }, + "style": "form", + "explode": true, + "name": "rvt-key-part", + "description": "Actor key components for actor-name resolution, one query occurrence per component. Preferred over the legacy comma-separated rvt-key; takes precedence when both are present." + }, { "in": "query", "required": false, @@ -3694,6 +3960,20 @@ "name": "rvt-key", "description": "Comma-separated actor key components for actor-name resolution." }, + { + "in": "query", + "required": false, + "schema": { + "type": "array", + "items": { + "type": "string" + } + }, + "style": "form", + "explode": true, + "name": "rvt-key-part", + "description": "Actor key components for actor-name resolution, one query occurrence per component. Preferred over the legacy comma-separated rvt-key; takes precedence when both are present." + }, { "in": "query", "required": false, @@ -3890,6 +4170,20 @@ "name": "rvt-key", "description": "Comma-separated actor key components for actor-name resolution." }, + { + "in": "query", + "required": false, + "schema": { + "type": "array", + "items": { + "type": "string" + } + }, + "style": "form", + "explode": true, + "name": "rvt-key-part", + "description": "Actor key components for actor-name resolution, one query occurrence per component. Preferred over the legacy comma-separated rvt-key; takes precedence when both are present." + }, { "in": "query", "required": false, @@ -4053,6 +4347,20 @@ "name": "rvt-key", "description": "Comma-separated actor key components for actor-name resolution." }, + { + "in": "query", + "required": false, + "schema": { + "type": "array", + "items": { + "type": "string" + } + }, + "style": "form", + "explode": true, + "name": "rvt-key-part", + "description": "Actor key components for actor-name resolution, one query occurrence per component. Preferred over the legacy comma-separated rvt-key; takes precedence when both are present." + }, { "in": "query", "required": false, diff --git a/rivetkit-rust/packages/client/src/remote_manager.rs b/rivetkit-rust/packages/client/src/remote_manager.rs index 7d96a97ac6..914fa3bf18 100644 --- a/rivetkit-rust/packages/client/src/remote_manager.rs +++ b/rivetkit-rust/packages/client/src/remote_manager.rs @@ -1,16 +1,16 @@ -use anyhow::{anyhow, Context, Result}; -use base64::{engine::general_purpose, engine::general_purpose::URL_SAFE_NO_PAD, Engine as _}; +use anyhow::{Context, Result, anyhow}; +use base64::{Engine as _, engine::general_purpose, engine::general_purpose::URL_SAFE_NO_PAD}; use bytes::Bytes; use opentelemetry::baggage::BaggageExt as _; use opentelemetry::propagation::TextMapPropagator as _; use opentelemetry_http::HeaderInjector; use opentelemetry_sdk::propagation::TraceContextPropagator; use reqwest::{ - header::{HeaderMap, HeaderName, HeaderValue, USER_AGENT}, Method, + header::{HeaderMap, HeaderName, HeaderValue, USER_AGENT}, }; use rivetkit_client_protocol::telemetry_headers::{ - bounded_ray_id, HEADER_RIVET_RAY_ID, HEADER_TRACEPARENT, HEADER_TRACESTATE, RAY_BAGGAGE_KEY, + HEADER_RIVET_RAY_ID, HEADER_TRACEPARENT, HEADER_TRACESTATE, RAY_BAGGAGE_KEY, bounded_ray_id, }; use serde::{Deserialize, Serialize}; use serde_cbor; @@ -21,11 +21,11 @@ use tracing_opentelemetry::OpenTelemetrySpanExt as _; use crate::{ common::{ - serialize_actor_key, ActorKey, EncodingKind, RawWebSocket, HEADER_RIVET_ACTOR, - HEADER_RIVET_NAMESPACE, HEADER_RIVET_TARGET, HEADER_RIVET_TOKEN, PATH_CONNECT_WEBSOCKET, - PATH_WEBSOCKET_PREFIX, USER_AGENT_VALUE, WS_PROTOCOL_ACTOR, WS_PROTOCOL_CONN_ID, - WS_PROTOCOL_CONN_PARAMS, WS_PROTOCOL_CONN_TOKEN, WS_PROTOCOL_ENCODING, - WS_PROTOCOL_STANDARD, WS_PROTOCOL_TARGET, WS_PROTOCOL_TOKEN, + ActorKey, EncodingKind, HEADER_RIVET_ACTOR, HEADER_RIVET_NAMESPACE, HEADER_RIVET_TARGET, + HEADER_RIVET_TOKEN, PATH_CONNECT_WEBSOCKET, PATH_WEBSOCKET_PREFIX, RawWebSocket, + USER_AGENT_VALUE, WS_PROTOCOL_ACTOR, WS_PROTOCOL_CONN_ID, WS_PROTOCOL_CONN_PARAMS, + WS_PROTOCOL_CONN_TOKEN, WS_PROTOCOL_ENCODING, WS_PROTOCOL_STANDARD, WS_PROTOCOL_TARGET, + WS_PROTOCOL_TOKEN, serialize_actor_key, }, protocol::query::ActorQuery, }; @@ -656,8 +656,15 @@ impl RemoteManager { push_query_param(&mut params, "rvt-namespace", &config.namespace); push_query_param(&mut params, "rvt-method", method); if let Some(key) = key { - if !key.is_empty() { - push_query_param(&mut params, "rvt-key", &key.join(",")); + // Each component is sent as its own `rvt-key-part` occurrence + // instead of being comma-joined into a single `rvt-key` value. + // A comma-joined value cannot distinguish a component + // containing a literal comma from multiple components, so + // `rvt-key` remains only as a legacy fallback for older + // gateways. `rvt-key-part` has no such ambiguity: each + // occurrence is one already-final component. + for part in key { + push_query_param(&mut params, "rvt-key-part", part); } } if let Some(input) = input { @@ -882,3 +889,114 @@ fn is_trace_context_header(name: &str) -> bool { .iter() .any(|header| header.eq_ignore_ascii_case(name)) } + +// Tests are inline because `build_actor_query_gateway_url` and +// `ResolvedClientConfig` are private and cannot be reached from the +// integration `tests/` directory without widening visibility. Keep these in +// sync with the TypeScript equivalent at +// rivetkit-typescript/packages/rivetkit/tests/actor-gateway-url.test.ts and +// the guard-side decode tests at +// engine/packages/guard/tests/parse_actor_path.rs. +#[cfg(test)] +mod tests { + use super::*; + + fn test_config() -> ResolvedClientConfig { + ResolvedClientConfig { + endpoint: "https://api.rivet.dev".to_string(), + token: None, + namespace: "default".to_string(), + } + } + + fn key_parts_of(url: &str) -> Vec { + let query = url.split_once('?').map(|(_, q)| q).unwrap_or(""); + query + .split('&') + .filter_map(|pair| pair.split_once('=')) + .filter(|(k, _)| *k == "rvt-key-part") + .map(|(_, v)| urlencoding::decode(v).unwrap().into_owned()) + .collect() + } + + #[test] + fn sends_one_rvt_key_part_per_component() { + let manager = RemoteManager::new("https://api.rivet.dev", None); + let key: ActorKey = vec!["part/one".to_string(), "shard-2".to_string()]; + let url = manager + .build_actor_query_gateway_url( + &test_config(), + "lobby", + "get", + Some(&key), + None, + None, + None, + "", + ) + .unwrap(); + + assert_eq!(key_parts_of(&url), vec!["part/one", "shard-2"]); + assert!(!url.contains("rvt-key=")); + } + + #[test] + fn distinguishes_comma_containing_component_from_two_components() { + // Regression coverage for https://github.com/rivet-dev/rivet/issues/5807. + let manager = RemoteManager::new("https://api.rivet.dev", None); + let single: ActorKey = vec!["tenant,admin".to_string()]; + let two: ActorKey = vec!["tenant".to_string(), "admin".to_string()]; + + let single_url = manager + .build_actor_query_gateway_url( + &test_config(), + "lobby", + "get", + Some(&single), + None, + None, + None, + "", + ) + .unwrap(); + let two_url = manager + .build_actor_query_gateway_url( + &test_config(), + "lobby", + "get", + Some(&two), + None, + None, + None, + "", + ) + .unwrap(); + + let single_parts = key_parts_of(&single_url); + let two_parts = key_parts_of(&two_url); + assert_eq!(single_parts, vec!["tenant,admin"]); + assert_eq!(two_parts, vec!["tenant", "admin"]); + assert_ne!(single_parts, two_parts); + } + + #[test] + fn omits_key_params_for_empty_key() { + let manager = RemoteManager::new("https://api.rivet.dev", None); + let key: ActorKey = vec![]; + let url = manager + .build_actor_query_gateway_url( + &test_config(), + "lobby", + "get", + Some(&key), + None, + None, + None, + "", + ) + .unwrap(); + + assert!(!url.contains("rvt-key-part")); + assert!(!url.contains("rvt-key=")); + } +} diff --git a/rivetkit-rust/packages/client/tests/bare.rs b/rivetkit-rust/packages/client/tests/bare.rs index ccb9f18a2a..ee3b9d77c5 100644 --- a/rivetkit-rust/packages/client/tests/bare.rs +++ b/rivetkit-rust/packages/client/tests/bare.rs @@ -716,9 +716,10 @@ fn gateway_url_uses_query_backed_get_target() { assert_eq!(params.get("rvt-namespace").map(String::as_str), Some("ns")); assert_eq!(params.get("rvt-method").map(String::as_str), Some("get")); assert_eq!( - params.get("rvt-key").map(String::as_str), - Some("tenant,room 1") + query_key_parts(&url), + vec!["tenant".to_owned(), "room 1".to_owned()] ); + assert!(!params.contains_key("rvt-key")); assert_eq!( params.get("rvt-token").map(String::as_str), Some("dev-token") @@ -758,9 +759,10 @@ fn gateway_url_uses_query_backed_get_or_create_target() { Some("getOrCreate") ); assert_eq!( - params.get("rvt-key").map(String::as_str), - Some("tenant,room 1") + query_key_parts(&url), + vec!["tenant".to_owned(), "room 1".to_owned()] ); + assert!(!params.contains_key("rvt-key")); assert_eq!( params.get("rvt-runner").map(String::as_str), Some("runner-a") @@ -1140,7 +1142,10 @@ async fn raw_fetch( params.get("rvt-method").map(String::as_str), Some("getOrCreate") ); - assert_eq!(params.get("rvt-key").map(String::as_str), Some("raw-fetch")); + assert_eq!( + params.get("rvt-key-part").map(String::as_str), + Some("raw-fetch") + ); assert_eq!( headers .get("x-test-header") @@ -1177,7 +1182,7 @@ async fn raw_websocket( Some("getOrCreate") ); assert_eq!( - params.get("rvt-key").map(String::as_str), + params.get("rvt-key-part").map(String::as_str), Some("raw-websocket") ); let protocols = headers @@ -1551,6 +1556,13 @@ fn query_params(url: &Url) -> HashMap { url.query_pairs().into_owned().collect() } +fn query_key_parts(url: &Url) -> Vec { + url.query_pairs() + .filter(|(key, _)| key == "rvt-key-part") + .map(|(_, value)| value.into_owned()) + .collect() +} + fn query_map(query: &str) -> HashMap { query .split('&') diff --git a/rivetkit-typescript/CLAUDE.md b/rivetkit-typescript/CLAUDE.md index 88ef4ee64f..7af3e5c283 100644 --- a/rivetkit-typescript/CLAUDE.md +++ b/rivetkit-typescript/CLAUDE.md @@ -54,13 +54,11 @@ Keep the TypeScript `ActorKey` and `ActorContext.key` surfaces string-only unles Actor-connect protocol `actionId` values are nullable, and `0` is a valid action ID. Treat only `null` as a connection-level error. -Query-backed remote gateway URLs use `rvt-*` query parameters: `/gateway/{name}/{path}?rvt-namespace=...&rvt-method=...&rvt-key=...`. The actor name is a clean path segment, and all routing params are standard query parameters with the `rvt-` prefix. The known `rvt-*` params are: `rvt-namespace`, `rvt-method`, `rvt-runner`, `rvt-key`, `rvt-input`, `rvt-region`, `rvt-crash-policy`, `rvt-token`. `rvt-runner` is required for `getOrCreate` and disallowed for `get`. For multi-component keys, use a single comma-separated `rvt-key` param (e.g. `rvt-key=tenant,room`). Use `URLSearchParams` to build and parse query strings. +Query-backed remote gateway URLs use `rvt-*` query parameters: `/gateway/{name}/{path}?rvt-namespace=...&rvt-method=...&rvt-key-part=...`. The actor name is a clean path segment, and all routing params are standard query parameters with the `rvt-` prefix. The known `rvt-*` params are: `rvt-namespace`, `rvt-method`, `rvt-runner`, `rvt-key-part`, `rvt-key`, `rvt-input`, `rvt-region`, `rvt-crash-policy`, `rvt-token`. `rvt-runner` is required for `getOrCreate` and disallowed for `get`. For a compound key, send one `rvt-key-part` occurrence per component (e.g. `rvt-key-part=tenant&rvt-key-part=room`); each occurrence is percent-encoded independently, so no component-boundary escaping is needed. `rvt-key` (a single comma-joined value) is a legacy fallback the guard still decodes for older clients, but it cannot distinguish a component containing a literal comma from multiple components, so new code must use `rvt-key-part`. Use `URLSearchParams` to build and parse query strings. -Keep `buildGatewayUrl()` query-backed for `get()` and `getOrCreate()` handles instead of pre-resolving to an actor ID. Local `getGatewayUrl()` flows should exercise the shared `actorGateway` query param parser on the served runtime router path, while direct actor ID targets still use `/gateway/{actorId}`. +Keep `buildGatewayUrl()` query-backed for `get()` and `getOrCreate()` handles instead of pre-resolving to an actor ID; direct actor ID targets still use `/gateway/{actorId}`. -When parsing query gateway paths in `packages/rivetkit/src/actor-gateway/gateway.ts` or in parity implementations, detect query paths by checking if any query parameter starts with `rvt-`. Use `URLSearchParams` to parse query params. Partition into `rvt-*` params and actor params. Reject raw `@token` syntax, unknown `rvt-*` params, and duplicate scalar `rvt-*` params. Strip all `rvt-*` params from the query string before forwarding to the actor by reconstructing the query string from only the actor params. - -Once a query path has been parsed in `packages/rivetkit/src/actor-gateway/gateway.ts`, resolve it to an actor ID inside the shared path-based HTTP and WebSocket gateway helpers before calling `proxyRequest` or `proxyWebSocket`. After resolution, reuse the existing direct-ID proxy flow and preserve the original remaining path with `rvt-*` params stripped. +Query gateway path parsing (detecting `rvt-*` params, partitioning them from actor params, rejecting raw `@token` syntax, unknown `rvt-*` params, and duplicate scalar `rvt-*` params, and stripping `rvt-*` params before forwarding to the actor) lives exclusively in the Rust engine guard (`engine/packages/guard/src/routing/actor_path.rs`) as of the Rust cutover (see "Pre-Rust Refactor Reference" above); there is no TypeScript-side parser to keep in sync. When adding or validating query input payloads in `packages/rivetkit/src/engine-client/actor-websocket-client.ts`, enforce `ClientConfig.maxInputSize` against the raw CBOR byte length before base64url encoding. This keeps the limit aligned with the actual serialized payload instead of the encoded URL expansion. diff --git a/rivetkit-typescript/packages/rivetkit/src/engine-client/actor-websocket-client.ts b/rivetkit-typescript/packages/rivetkit/src/engine-client/actor-websocket-client.ts index 1fa0fda00a..1999062995 100644 --- a/rivetkit-typescript/packages/rivetkit/src/engine-client/actor-websocket-client.ts +++ b/rivetkit-typescript/packages/rivetkit/src/engine-client/actor-websocket-client.ts @@ -289,8 +289,14 @@ export function buildActorQueryGatewayUrl( } function pushKeyQueryParams(params: URLSearchParams, key: string[]): void { - if (key.length > 0) { - params.append("rvt-key", key.join(",")); + // Each component is sent as its own `rvt-key-part` occurrence instead of + // being comma-joined into a single `rvt-key` value. A comma-joined value + // cannot distinguish a component containing a literal comma from + // multiple components, so `rvt-key` is kept only as a legacy fallback + // for older gateways/clients. `rvt-key-part` has no such ambiguity: each + // occurrence is one already-final component, percent-encoded normally. + for (const part of key) { + params.append("rvt-key-part", part); } } diff --git a/rivetkit-typescript/packages/rivetkit/tests/actor-gateway-url.test.ts b/rivetkit-typescript/packages/rivetkit/tests/actor-gateway-url.test.ts index 56f667c2a3..9dda2b9083 100644 --- a/rivetkit-typescript/packages/rivetkit/tests/actor-gateway-url.test.ts +++ b/rivetkit-typescript/packages/rivetkit/tests/actor-gateway-url.test.ts @@ -33,7 +33,7 @@ describe("gateway URL builders", () => { ); }); - test("serializes get queries with comma-separated key encoding", () => { + test("serializes get queries with one rvt-key-part per key component", () => { const url = buildActorQueryGatewayUrl( "https://api.rivet.dev/manager", "prod", @@ -51,12 +51,48 @@ describe("gateway URL builders", () => { const params = urlObj.searchParams; expect(params.get("rvt-namespace")).toBe("prod"); expect(params.get("rvt-method")).toBe("get"); - expect(params.get("rvt-key")).toBe("part/one,shard-2,100%"); + expect(params.getAll("rvt-key-part")).toEqual([ + "part/one", + "shard-2", + "100%", + ]); + expect(params.has("rvt-key")).toBe(false); expect(params.get("rvt-token")).toBe("tok/en"); expect(urlObj.pathname).toContain("/gateway/alpha%20team/status"); expect(url).not.toContain("@"); }); + test("distinguishes key component boundaries that previously collided under comma-joining", () => { + // Regression coverage for https://github.com/rivet-dev/rivet/issues/5807: + // a component containing a literal comma must not become + // indistinguishable from a key with an extra component. + const keyPartsOf = (key: string[]) => { + const url = buildActorQueryGatewayUrl( + "https://api.rivet.dev/manager", + "prod", + { getForKey: { name: "room", key } }, + undefined, + ); + return new URL(url).searchParams.getAll("rvt-key-part"); + }; + + expect(keyPartsOf(["a,b"])).toEqual(["a,b"]); + expect(keyPartsOf(["a", "b"])).toEqual(["a", "b"]); + expect(keyPartsOf(["a,b"])).not.toEqual(keyPartsOf(["a", "b"])); + + expect(keyPartsOf([""])).toEqual([""]); + expect(keyPartsOf([])).toEqual([]); + expect(keyPartsOf([""])).not.toEqual(keyPartsOf([])); + + expect(keyPartsOf(["a,", "b"])).toEqual(["a,", "b"]); + expect(keyPartsOf(["a", "", "b"])).toEqual(["a", "", "b"]); + expect(keyPartsOf(["a,", "b"])).not.toEqual(keyPartsOf(["a", "", "b"])); + + expect(keyPartsOf([","])).toEqual([","]); + expect(keyPartsOf(["", ""])).toEqual(["", ""]); + expect(keyPartsOf([","])).not.toEqual(keyPartsOf(["", ""])); + }); + test("serializes skipReadyWait for query routing", () => { const url = buildActorQueryGatewayUrl( "https://api.rivet.dev/manager", @@ -122,7 +158,7 @@ describe("gateway URL builders", () => { expect(params.get("rvt-namespace")).toBe("default"); expect(params.get("rvt-method")).toBe("getOrCreate"); expect(params.get("rvt-runner")).toBe("my-pool"); - expect(params.get("rvt-key")).toBe("user,"); + expect(params.getAll("rvt-key-part")).toEqual(["user", ""]); expect(params.get("rvt-input")).toBe(toBase64Url(cbor.encode(input))); expect(params.get("rvt-region")).toBe("local/us-west"); expect(params.get("rvt-crash-policy")).toBe("sleep"); @@ -130,7 +166,7 @@ describe("gateway URL builders", () => { expect(urlObj.pathname).toContain("/gateway/room/connect"); }); - test("omits rvt-key param for empty key arrays", () => { + test("omits key params for empty key arrays", () => { const url = buildActorQueryGatewayUrl( "https://api.rivet.dev/manager", "default", @@ -149,7 +185,9 @@ describe("gateway URL builders", () => { "default", ); - expect(new URL(url).searchParams.has("rvt-key")).toBe(false); + const params = new URL(url).searchParams; + expect(params.has("rvt-key")).toBe(false); + expect(params.has("rvt-key-part")).toBe(false); }); test("rejects oversized query input before base64url encoding", () => { diff --git a/rivetkit-typescript/packages/rivetkit/tests/driver/gateway-routing.test.ts b/rivetkit-typescript/packages/rivetkit/tests/driver/gateway-routing.test.ts index de17856215..5183dab8d9 100644 --- a/rivetkit-typescript/packages/rivetkit/tests/driver/gateway-routing.test.ts +++ b/rivetkit-typescript/packages/rivetkit/tests/driver/gateway-routing.test.ts @@ -212,7 +212,11 @@ describeDriverMatrix("Gateway Routing", (driverTestConfig) => { expect(data.url).not.toContain("rvt-runner"); }); - test("supports multi-component keys via comma-separated rvt-key", async (c) => { + test("supports multi-component keys via legacy comma-separated rvt-key", async (c) => { + // Legacy encoding, kept for backward compatibility with + // clients that predate rvt-key-part. Cannot express a + // component containing a literal comma; see the + // rvt-key-part tests below for the fix. const { client, endpoint } = await setupDriverTest( c, driverTestConfig, @@ -242,6 +246,63 @@ describeDriverMatrix("Gateway Routing", (driverTestConfig) => { const data = await response.json(); expect(data).toEqual({ message: "Hello from actor!" }); }); + + test("supports multi-component keys via repeated rvt-key-part", async (c) => { + const { client, endpoint } = await setupDriverTest( + c, + driverTestConfig, + ); + + const handle = client.rawHttpActor.getOrCreate([ + "tenant-part", + "room-part", + ]); + await handle.fetch("api/hello"); + + const gatewayUrl = await handle.getGatewayUrl(); + const parsedUrl = new URL(gatewayUrl); + const namespace = parsedUrl.searchParams.get("rvt-namespace")!; + const runner = parsedUrl.searchParams.get("rvt-runner")!; + expect(parsedUrl.searchParams.getAll("rvt-key-part")).toEqual([ + "tenant-part", + "room-part", + ]); + + const queryUrl = new URL( + `${endpoint}/gateway/rawHttpActor/request/api/hello`, + ); + queryUrl.searchParams.set("rvt-namespace", namespace); + queryUrl.searchParams.set("rvt-method", "getOrCreate"); + queryUrl.searchParams.append("rvt-key-part", "tenant-part"); + queryUrl.searchParams.append("rvt-key-part", "room-part"); + queryUrl.searchParams.set("rvt-runner", runner); + + const response = await fetch(queryUrl.toString()); + expect(response.ok).toBe(true); + const data = await response.json(); + expect(data).toEqual({ message: "Hello from actor!" }); + }); + + test("distinguishes a comma-containing component from a two-component key (issue #5807)", async (c) => { + const { client } = await setupDriverTest(c, driverTestConfig); + + // One component containing a literal comma. + const singlePart = client.rawHttpActor.getOrCreate([ + "tenant-x,admin-x", + ]); + await singlePart.fetch("api/hello"); + const singlePartId = await singlePart.resolve(); + + // Two components, no literal comma. + const twoParts = client.rawHttpActor.getOrCreate([ + "tenant-x", + "admin-x", + ]); + await twoParts.fetch("api/hello"); + const twoPartsId = await twoParts.resolve(); + + expect(singlePartId).not.toBe(twoPartsId); + }); }); }); });