rivetkit 2.3.20 → 2.3.21

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (130) hide show
  1. package/dist/browser/client.d.ts +43 -5
  2. package/dist/browser/client.js +302 -60
  3. package/dist/browser/client.js.map +1 -1
  4. package/dist/browser/inspector/client.js +2 -1
  5. package/dist/browser/inspector/client.js.map +1 -1
  6. package/dist/tsup/actor/errors.cjs.map +1 -1
  7. package/dist/tsup/agent-os/index.cjs +2 -1
  8. package/dist/tsup/agent-os/index.cjs.map +1 -1
  9. package/dist/tsup/agent-os/index.d.cts +32 -4
  10. package/dist/tsup/agent-os/index.d.ts +32 -4
  11. package/dist/tsup/agent-os/index.js +2 -1
  12. package/dist/tsup/agent-os/index.js.map +1 -1
  13. package/dist/tsup/{chunk-553CIQJY.js → chunk-2CI2IEVN.js} +2 -2
  14. package/dist/tsup/{chunk-W2PXGWMR.js → chunk-5EGLHQJJ.js} +2 -2
  15. package/dist/tsup/{chunk-DMQUERRN.js → chunk-5IBUZFLP.js} +2 -2
  16. package/dist/tsup/{chunk-76YTS32R.cjs → chunk-6LRPYB4Q.cjs} +4 -4
  17. package/dist/tsup/chunk-6LRPYB4Q.cjs.map +1 -0
  18. package/dist/tsup/{chunk-WAISW7KR.cjs → chunk-APOANSDG.cjs} +10 -10
  19. package/dist/tsup/chunk-APOANSDG.cjs.map +1 -0
  20. package/dist/tsup/{chunk-IHKDFKM4.cjs → chunk-ARGNQGYG.cjs} +3 -3
  21. package/dist/tsup/chunk-ARGNQGYG.cjs.map +1 -0
  22. package/dist/tsup/{chunk-A36FMKOV.cjs → chunk-B5SNXPSS.cjs} +448 -199
  23. package/dist/tsup/chunk-B5SNXPSS.cjs.map +1 -0
  24. package/dist/tsup/{chunk-SNNVRHLX.js → chunk-CJXBGTVT.js} +2 -2
  25. package/dist/tsup/{chunk-CVLNRT7O.cjs → chunk-GHKQC4PD.cjs} +3 -3
  26. package/dist/tsup/chunk-GHKQC4PD.cjs.map +1 -0
  27. package/dist/tsup/chunk-JX2ITWAV.cjs.map +1 -1
  28. package/dist/tsup/chunk-KVFIONKQ.cjs.map +1 -1
  29. package/dist/tsup/{chunk-2CE5UNWK.cjs → chunk-KWGN2ZBJ.cjs} +3 -2
  30. package/dist/tsup/chunk-KWGN2ZBJ.cjs.map +1 -0
  31. package/dist/tsup/{chunk-3WS7Z64U.cjs → chunk-LWSLKKZ5.cjs} +3 -3
  32. package/dist/tsup/chunk-LWSLKKZ5.cjs.map +1 -0
  33. package/dist/tsup/chunk-MYWZCDML.cjs.map +1 -1
  34. package/dist/tsup/{chunk-RSIOGT2U.cjs → chunk-NL2EIKK6.cjs} +7 -7
  35. package/dist/tsup/chunk-NL2EIKK6.cjs.map +1 -0
  36. package/dist/tsup/chunk-NWCAIWHL.cjs.map +1 -1
  37. package/dist/tsup/{chunk-5G2J26VM.js → chunk-PYRI75DL.js} +314 -65
  38. package/dist/tsup/chunk-PYRI75DL.js.map +1 -0
  39. package/dist/tsup/{chunk-2NXLTFWI.js → chunk-RFN3BG6D.js} +3 -3
  40. package/dist/tsup/chunk-RFN3BG6D.js.map +1 -0
  41. package/dist/tsup/{chunk-DHWZTUKA.js → chunk-RGIZNZYO.js} +3 -2
  42. package/dist/tsup/chunk-RGIZNZYO.js.map +1 -0
  43. package/dist/tsup/{chunk-TZVAVPGJ.js → chunk-VMMXXVDO.js} +4 -4
  44. package/dist/tsup/client/mod.cjs +5 -5
  45. package/dist/tsup/client/mod.cjs.map +1 -1
  46. package/dist/tsup/client/mod.d.cts +12 -3
  47. package/dist/tsup/client/mod.d.ts +12 -3
  48. package/dist/tsup/client/mod.js +4 -4
  49. package/dist/tsup/common/log.cjs +2 -2
  50. package/dist/tsup/common/log.cjs.map +1 -1
  51. package/dist/tsup/common/log.js +1 -1
  52. package/dist/tsup/common/websocket.cjs +3 -3
  53. package/dist/tsup/common/websocket.cjs.map +1 -1
  54. package/dist/tsup/common/websocket.js +2 -2
  55. package/dist/tsup/{config-Be-VOHaX.d.ts → config-Bc1nnvUl.d.ts} +35 -5
  56. package/dist/tsup/{config-Ct5uXxXk.d.cts → config-Do_0guSS.d.cts} +35 -5
  57. package/dist/tsup/{context-Cw-11Q-B.d.cts → context-CgNkhjdY.d.cts} +1 -1
  58. package/dist/tsup/{context-CXLa0BIM.d.ts → context-oGQxZDPz.d.ts} +1 -1
  59. package/dist/tsup/db/drizzle.cjs +2 -2
  60. package/dist/tsup/db/drizzle.cjs.map +1 -1
  61. package/dist/tsup/db/drizzle.js +1 -1
  62. package/dist/tsup/db/mod.cjs +3 -3
  63. package/dist/tsup/db/mod.cjs.map +1 -1
  64. package/dist/tsup/db/mod.js +2 -2
  65. package/dist/tsup/dynamic/mod.cjs.map +1 -1
  66. package/dist/tsup/dynamic/mod.d.cts +2 -1
  67. package/dist/tsup/dynamic/mod.d.ts +2 -1
  68. package/dist/tsup/inspector/mod.cjs +5 -5
  69. package/dist/tsup/inspector/mod.cjs.map +1 -1
  70. package/dist/tsup/inspector/mod.js +4 -4
  71. package/dist/tsup/inspector/workflow.cjs +4 -4
  72. package/dist/tsup/inspector/workflow.cjs.map +1 -1
  73. package/dist/tsup/inspector/workflow.js +3 -3
  74. package/dist/tsup/inspector-tab/mod.cjs.map +1 -1
  75. package/dist/tsup/inspector-tab/mod.d.cts +3 -2
  76. package/dist/tsup/inspector-tab/mod.d.ts +3 -2
  77. package/dist/tsup/mod.cjs +105 -115
  78. package/dist/tsup/mod.cjs.map +1 -1
  79. package/dist/tsup/mod.d.cts +4 -3
  80. package/dist/tsup/mod.d.ts +4 -3
  81. package/dist/tsup/mod.js +29 -39
  82. package/dist/tsup/mod.js.map +1 -1
  83. package/dist/tsup/test/mod.cjs +6 -6
  84. package/dist/tsup/test/mod.cjs.map +1 -1
  85. package/dist/tsup/test/mod.d.cts +2 -1
  86. package/dist/tsup/test/mod.d.ts +2 -1
  87. package/dist/tsup/test/mod.js +4 -4
  88. package/dist/tsup/unstable/migrations.cjs.map +1 -1
  89. package/dist/tsup/utils.cjs +2 -2
  90. package/dist/tsup/utils.cjs.map +1 -1
  91. package/dist/tsup/utils.js +1 -1
  92. package/dist/tsup/workflow/mod.cjs +7 -7
  93. package/dist/tsup/workflow/mod.cjs.map +1 -1
  94. package/dist/tsup/workflow/mod.d.cts +4 -3
  95. package/dist/tsup/workflow/mod.d.ts +4 -3
  96. package/dist/tsup/workflow/mod.js +4 -4
  97. package/package.json +10 -9
  98. package/src/client/actor-conn.ts +34 -1
  99. package/src/client/actor-handle.ts +29 -1
  100. package/src/client/auth.ts +37 -0
  101. package/src/client/client.ts +10 -2
  102. package/src/client/config.ts +12 -1
  103. package/src/client/mod.ts +9 -0
  104. package/src/client/token-provider.test.ts +86 -0
  105. package/src/client/token-provider.ts +93 -0
  106. package/src/client/utils.ts +1 -0
  107. package/src/engine-client/actor-websocket-client.ts +2 -1
  108. package/src/engine-client/api-endpoints.ts +28 -0
  109. package/src/engine-client/driver.ts +4 -0
  110. package/src/engine-client/mod.ts +201 -64
  111. package/src/registry/config/envoy.ts +1 -17
  112. package/src/registry/config/index.test.ts +2 -2
  113. package/src/registry/config/index.ts +7 -2
  114. package/src/registry/index.ts +28 -0
  115. package/dist/tsup/chunk-2CE5UNWK.cjs.map +0 -1
  116. package/dist/tsup/chunk-2NXLTFWI.js.map +0 -1
  117. package/dist/tsup/chunk-3WS7Z64U.cjs.map +0 -1
  118. package/dist/tsup/chunk-5G2J26VM.js.map +0 -1
  119. package/dist/tsup/chunk-76YTS32R.cjs.map +0 -1
  120. package/dist/tsup/chunk-A36FMKOV.cjs.map +0 -1
  121. package/dist/tsup/chunk-CVLNRT7O.cjs.map +0 -1
  122. package/dist/tsup/chunk-DHWZTUKA.js.map +0 -1
  123. package/dist/tsup/chunk-IHKDFKM4.cjs.map +0 -1
  124. package/dist/tsup/chunk-RSIOGT2U.cjs.map +0 -1
  125. package/dist/tsup/chunk-WAISW7KR.cjs.map +0 -1
  126. /package/dist/tsup/{chunk-553CIQJY.js.map → chunk-2CI2IEVN.js.map} +0 -0
  127. /package/dist/tsup/{chunk-W2PXGWMR.js.map → chunk-5EGLHQJJ.js.map} +0 -0
  128. /package/dist/tsup/{chunk-DMQUERRN.js.map → chunk-5IBUZFLP.js.map} +0 -0
  129. /package/dist/tsup/{chunk-SNNVRHLX.js.map → chunk-CJXBGTVT.js.map} +0 -0
  130. /package/dist/tsup/{chunk-TZVAVPGJ.js.map → chunk-VMMXXVDO.js.map} +0 -0
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "rivetkit",
3
- "version": "2.3.20",
3
+ "version": "2.3.21",
4
4
  "description": "Lightweight libraries for building stateful actors on edge platforms",
5
5
  "license": "Apache-2.0",
6
6
  "keywords": [
@@ -213,14 +213,15 @@
213
213
  "@rivet-dev/agent-os-core": "^0.1.1",
214
214
  "@rivet-dev/services": "^0.1.5",
215
215
  "@rivetkit/bare-ts": "^0.6.2",
216
- "@rivetkit/engine-cli": "2.3.20",
217
- "@rivetkit/engine-envoy-protocol": "2.3.20",
216
+ "@rivetkit/engine-api-full": "2.3.21",
217
+ "@rivetkit/engine-cli": "2.3.21",
218
+ "@rivetkit/engine-envoy-protocol": "2.3.21",
218
219
  "@rivetkit/on-change": "6.0.1",
219
- "@rivetkit/rivetkit-napi": "2.3.20",
220
- "@rivetkit/rivetkit-wasm": "2.3.20",
221
- "@rivetkit/traces": "2.3.20",
222
- "@rivetkit/virtual-websocket": "2.3.20",
223
- "@rivetkit/workflow-engine": "2.3.20",
220
+ "@rivetkit/rivetkit-napi": "2.3.21",
221
+ "@rivetkit/rivetkit-wasm": "2.3.21",
222
+ "@rivetkit/traces": "2.3.21",
223
+ "@rivetkit/virtual-websocket": "2.3.21",
224
+ "@rivetkit/workflow-engine": "2.3.21",
224
225
  "cbor-x": "^1.6.0",
225
226
  "drizzle-orm": "0.45.2",
226
227
  "hono": "^4.7.0",
@@ -270,7 +271,7 @@
270
271
  "stableVersion": "0.8.0",
271
272
  "repository": {
272
273
  "type": "git",
273
- "url": "https://github.com/rivet-dev/actors.git",
274
+ "url": "https://github.com/rivet-dev/rivet.git",
274
275
  "directory": "rivetkit-typescript/packages/rivetkit"
275
276
  }
276
277
  }
@@ -51,6 +51,7 @@ import { ACTOR_CONNS_SYMBOL, type ClientRaw } from "./client";
51
51
  import * as errors from "./errors";
52
52
  import { isRetryableLifecycleReconnectSignal } from "./lifecycle-errors";
53
53
  import { logger } from "./log";
54
+ import { isInvalidToken } from "./token-provider";
54
55
  import { outboundTelemetryHeaders } from "./outbound-telemetry";
55
56
  import {
56
57
  createQueueSender,
@@ -183,6 +184,7 @@ export class ActorConnRaw {
183
184
  #onOpenPromise?: ReturnType<typeof promiseWithResolvers<undefined>>;
184
185
 
185
186
  #websocket?: UniversalWebSocket;
187
+ #authRetryAvailable = true;
186
188
 
187
189
  #client: ClientRaw;
188
190
  #driver: EngineControlClient;
@@ -531,6 +533,15 @@ export class ActorConnRaw {
531
533
  if (error instanceof errors.ActorConnDisposed) {
532
534
  return false;
533
535
  }
536
+ if (
537
+ error instanceof errors.ActorError &&
538
+ isInvalidToken(error.group, error.code) &&
539
+ this.#driver.refreshAuthToken &&
540
+ this.#authRetryAvailable
541
+ ) {
542
+ this.#authRetryAvailable = false;
543
+ return true;
544
+ }
534
545
 
535
546
  if (
536
547
  error instanceof errors.ActorError &&
@@ -679,6 +690,7 @@ export class ActorConnRaw {
679
690
  });
680
691
 
681
692
  // Update connection state (this also notifies handlers)
693
+ this.#authRetryAvailable = true;
682
694
  this.#setConnStatus("connected");
683
695
 
684
696
  // Resolve open promise
@@ -853,6 +865,7 @@ export class ActorConnRaw {
853
865
 
854
866
  /** Called by the onclose event from drivers. */
855
867
  async #handleOnClose(event: Event | CloseEventLike) {
868
+ const failedSocket = this.#websocket;
856
869
  // We can't use `event instanceof CloseEvent` because it's not defined in NodeJS
857
870
  const closeEvent = event as CloseEventLike;
858
871
  const wasClean = closeEvent.wasClean;
@@ -882,6 +895,26 @@ export class ActorConnRaw {
882
895
 
883
896
  if (parsed) {
884
897
  const { group, code, rayId } = parsed;
898
+ if (
899
+ isInvalidToken(group, code) &&
900
+ closeEvent.code === 1008 &&
901
+ failedSocket
902
+ ) {
903
+ try {
904
+ if (
905
+ !(await this.#driver.refreshAuthToken?.(
906
+ failedSocket,
907
+ ))
908
+ ) {
909
+ this.#authRetryAvailable = false;
910
+ }
911
+ } catch {
912
+ logger().warn("token renewal failed");
913
+ this.#authRetryAvailable = false;
914
+ }
915
+ } else if (isInvalidToken(group, code)) {
916
+ this.#authRetryAvailable = false;
917
+ }
885
918
 
886
919
  if (this.#shouldReconnectForStaleActor(group, code)) {
887
920
  this.#clearResolvedActorIdentity();
@@ -941,7 +974,7 @@ export class ActorConnRaw {
941
974
  }
942
975
 
943
976
  // Automatically reconnect if we were connected
944
- if (wasConnected) {
977
+ if (wasConnected && this.#authRetryAvailable) {
945
978
  logger().debug({
946
979
  msg: "triggering reconnect",
947
980
  connId: this.#connId,
@@ -25,6 +25,13 @@ import { AsyncMutex } from "@/common/database/shared";
25
25
  import type { Encoding, JsonCompatValue } from "@/common/encoding";
26
26
  import { deconstructError } from "@/common/utils";
27
27
  import type { EngineControlClient } from "@/engine-client/driver";
28
+ import type {
29
+ ActorIssueTokenOptions,
30
+ ActorTokenPermissions,
31
+ ActorTokenResource,
32
+ IssuedToken,
33
+ TokenGrant,
34
+ } from "@/client/auth";
28
35
  import type { StartCallSpan, CurrentActorInvocation } from "@/registry/runtime";
29
36
  import {
30
37
  decodeCborCompat,
@@ -993,6 +1000,27 @@ export class ActorHandleRaw {
993
1000
  );
994
1001
  }
995
1002
 
1003
+ /** Issue a token scoped to this actor. Defaults to actor gateway read access. */
1004
+ async issueToken(options: ActorIssueTokenOptions = {}): Promise<IssuedToken> {
1005
+ const actorId = await this.resolve();
1006
+ const permissions: ActorTokenPermissions = options.permissions ?? {
1007
+ actor_gateway: ["read"],
1008
+ };
1009
+ const grants: TokenGrant[] = (
1010
+ Object.keys(permissions) as ActorTokenResource[]
1011
+ ).flatMap((resource) => {
1012
+ const operations = permissions[resource];
1013
+ return operations
1014
+ ? [{ resource, target: { id: actorId }, operations }]
1015
+ : [];
1016
+ });
1017
+ return this.#driver.issueToken({
1018
+ subject: options.subject,
1019
+ expiresIn: options.expiresIn,
1020
+ grants,
1021
+ });
1022
+ }
1023
+
996
1024
  /**
997
1025
  * Returns the raw URL for routing traffic to the actor.
998
1026
  */
@@ -1048,4 +1076,4 @@ export type ActorHandle<AD extends AnyActorDefinition> = Omit<
1048
1076
  // Resolve method returns the actor ID
1049
1077
  resolve(): Promise<string>;
1050
1078
  } & ActorDefinitionQueueSend<AD> &
1051
- ActorDefinitionActions<AD>;
1079
+ Omit<ActorDefinitionActions<AD>, keyof ActorHandleRaw>;
@@ -0,0 +1,37 @@
1
+ import type { Rivet } from "@rivetkit/engine-api-full";
2
+
3
+ /** A namespace-scoped Engine grant. The caller must provide every grant explicitly. */
4
+ export type TokenGrant = Rivet.AuthTokenGrant;
5
+
6
+ /** Options for `client.auth.issueToken()`. `expiresIn` is in seconds. */
7
+ export type IssueTokenOptions = Pick<
8
+ Rivet.AuthTokenCreateRequest,
9
+ "subject" | "grants"
10
+ > & {
11
+ expiresIn?: Rivet.AuthTokenCreateRequest["duration"];
12
+ };
13
+
14
+ /** Token timestamps are Unix milliseconds. */
15
+ export interface IssuedToken {
16
+ token: Rivet.AuthTokenCreateResponse["token"];
17
+ issuedAt: Rivet.AuthTokenCreateResponse["issuedTs"];
18
+ expiresAt: Rivet.AuthTokenCreateResponse["expiresTs"];
19
+ }
20
+
21
+ /** Resources that can be scoped to a single actor ID. */
22
+ export type ActorTokenResource = Extract<
23
+ Rivet.AuthTokenGrant["resource"],
24
+ "actor_gateway" | "actor" | "actor_kv"
25
+ >;
26
+
27
+ export type ActorTokenPermissions = Partial<
28
+ Record<ActorTokenResource, Rivet.AuthTokenGrant["operations"]>
29
+ >;
30
+
31
+ /** Options for `actor.issueToken()`. Omit permissions for gateway read access only. */
32
+ export type ActorIssueTokenOptions = Pick<
33
+ IssueTokenOptions,
34
+ "subject" | "expiresIn"
35
+ > & {
36
+ permissions?: ActorTokenPermissions;
37
+ };
@@ -1,4 +1,5 @@
1
1
  import type { AnyActorDefinition } from "@/actor/definition";
2
+ import type { IssuedToken, IssueTokenOptions } from "@/client/auth";
2
3
  import type { ActorQuery } from "@/client/query";
3
4
  import type { Encoding } from "@/common/encoding";
4
5
  import type { EngineControlClient } from "@/engine-client/driver";
@@ -198,6 +199,10 @@ export interface ActorClientRuntimeOptions {
198
199
  */
199
200
  export class ClientRaw {
200
201
  #disposed = false;
202
+ /** Issue namespace-scoped tokens with an explicit grant list. */
203
+ readonly auth: {
204
+ issueToken(options: IssueTokenOptions): Promise<IssuedToken>;
205
+ };
201
206
 
202
207
  [ACTOR_CONNS_SYMBOL] = new Set<ActorConnRaw>();
203
208
 
@@ -215,6 +220,9 @@ export class ClientRaw {
215
220
  options: ClientRawOptions & ActorClientRuntimeOptions = {},
216
221
  ) {
217
222
  this.#driver = driver;
223
+ this.auth = {
224
+ issueToken: (issueOptions) => this.#driver.issueToken(issueOptions),
225
+ };
218
226
 
219
227
  this.#encodingKind = options.encoding ?? "bare";
220
228
  this.#gatewayOptions = options.gateway ?? {};
@@ -469,11 +477,11 @@ export class ClientRaw {
469
477
  *
470
478
  * @template A The actor registry type.
471
479
  */
472
- export type Client<A extends Registry<any>> = ClientRaw & {
480
+ export type Client<A extends Registry<any>> = ClientRaw & Omit<{
473
481
  [K in keyof ExtractActorsFromRegistry<A>]: ActorAccessor<
474
482
  ExtractActorsFromRegistry<A>[K]
475
483
  >;
476
- };
484
+ }, keyof ClientRaw>;
477
485
 
478
486
  export type AnyClient = Client<Registry<any>>;
479
487
 
@@ -1,5 +1,6 @@
1
1
  import z from "zod/v4";
2
2
  import { EncodingSchema } from "@/common/encoding";
3
+ import { isLocalEngineEndpoint } from "@/common/engine";
3
4
  import type { RegistryConfig } from "@/registry/config";
4
5
  import type { GetUpgradeWebSocket } from "@/utils";
5
6
  import { tryParseEndpoint } from "@/utils/endpoint-parser";
@@ -10,6 +11,7 @@ import {
10
11
  getRivetPool,
11
12
  getRivetToken,
12
13
  } from "@/utils/env-vars";
14
+ import type { GetToken } from "./token-provider";
13
15
 
14
16
  const DEFAULT_ENDPOINT = "http://localhost:6420";
15
17
  export const DEFAULT_MAX_QUERY_INPUT_SIZE = 4 * 1024;
@@ -54,6 +56,9 @@ export const ClientConfigSchemaBase = z.object({
54
56
  .optional()
55
57
  .transform((val) => val ?? getRivetToken()),
56
58
 
59
+ /** Issue and renew a client token on demand. Overrides the static token when provided. */
60
+ getToken: z.custom<GetToken>().optional(),
61
+
57
62
  /** Namespace to connect to. */
58
63
  namespace: z
59
64
  .string()
@@ -131,7 +136,12 @@ export function transformClientConfig(
131
136
  ...config,
132
137
  endpoint: parsedEndpoint?.endpoint,
133
138
  namespace: parsedEndpoint?.namespace ?? config.namespace ?? "default",
134
- token: parsedEndpoint?.token ?? config.token,
139
+ token:
140
+ parsedEndpoint?.token ??
141
+ config.token ??
142
+ (config.endpoint && isLocalEngineEndpoint(config.endpoint)
143
+ ? "default"
144
+ : undefined),
135
145
  };
136
146
  }
137
147
 
@@ -151,6 +161,7 @@ export function convertRegistryConfigToClientConfig(
151
161
  return {
152
162
  endpoint: config.endpoint,
153
163
  token: config.token,
164
+ getToken: undefined,
154
165
  namespace: config.namespace,
155
166
  poolName: config.envoy.poolName,
156
167
  headers: config.headers,
package/src/client/mod.ts CHANGED
@@ -19,6 +19,14 @@ export {
19
19
  UserError,
20
20
  } from "@/client/errors";
21
21
  export type { CreateRequest } from "@/client/query";
22
+ export type {
23
+ ActorIssueTokenOptions,
24
+ ActorTokenPermissions,
25
+ ActorTokenResource,
26
+ IssuedToken,
27
+ IssueTokenOptions,
28
+ TokenGrant,
29
+ } from "./auth";
22
30
  export type { Encoding } from "@/common/encoding";
23
31
  export type {
24
32
  ActorActionFunction,
@@ -49,6 +57,7 @@ export type {
49
57
  QueryOptions,
50
58
  Region,
51
59
  } from "./client";
60
+ export type { GetToken } from "./token-provider";
52
61
 
53
62
  /**
54
63
  * Creates a client with the actor accessor proxy.
@@ -0,0 +1,86 @@
1
+ import { describe, expect, test, vi } from "vitest";
2
+ import {
3
+ isInvalidToken,
4
+ isInvalidTokenResponse,
5
+ TokenProvider,
6
+ } from "./token-provider";
7
+
8
+ describe("renewable client tokens", () => {
9
+ test("single-flights initial issuance and concurrent rejection refreshes", async () => {
10
+ const getToken = vi.fn(
11
+ async ({ forceRefresh }: { forceRefresh: boolean }) =>
12
+ forceRefresh ? "renewed" : "initial",
13
+ );
14
+ const provider = new TokenProvider(getToken);
15
+ expect(
16
+ await Promise.all([provider.current(), provider.current()]),
17
+ ).toEqual(["initial", "initial"]);
18
+ expect(getToken).toHaveBeenCalledTimes(1);
19
+ expect(
20
+ await Promise.all([
21
+ provider.refreshIfCurrent("initial"),
22
+ provider.refreshIfCurrent("initial"),
23
+ ]),
24
+ ).toEqual(["renewed", "renewed"]);
25
+ expect(getToken).toHaveBeenCalledTimes(2);
26
+ expect(await provider.refreshIfCurrent("initial")).toBe("renewed");
27
+ expect(getToken).toHaveBeenCalledTimes(2);
28
+ });
29
+
30
+ test("retries failed issuance and refuses empty credentials", async () => {
31
+ const getToken = vi
32
+ .fn()
33
+ .mockRejectedValueOnce(new Error("issuer down"))
34
+ .mockResolvedValueOnce("ok");
35
+ const provider = new TokenProvider(getToken);
36
+ await expect(provider.current()).rejects.toThrow("issuer down");
37
+ expect(await provider.current()).toBe("ok");
38
+ await expect(
39
+ new TokenProvider(async () => "").current(),
40
+ ).rejects.toThrow("nonempty");
41
+ });
42
+
43
+ test("refreshes before a known JWT expiry and treats opaque tokens as unexpiring hints", async () => {
44
+ const payload = btoa(
45
+ JSON.stringify({ exp: Math.floor(Date.now() / 1_000) + 1 }),
46
+ ).replaceAll("=", "");
47
+ const getToken = vi.fn(async () => `header.${payload}.signature`);
48
+ const provider = new TokenProvider(getToken);
49
+ await provider.current();
50
+ await provider.current();
51
+ expect(getToken).toHaveBeenCalledTimes(2);
52
+ const opaque = vi.fn(async () => "opaque");
53
+ const opaqueProvider = new TokenProvider(opaque);
54
+ await opaqueProvider.current();
55
+ await opaqueProvider.current();
56
+ expect(opaque).toHaveBeenCalledTimes(1);
57
+ });
58
+
59
+ test("only confirmed 401 invalid/expired errors permit renewal", () => {
60
+ expect(isInvalidToken("auth", "invalid_token")).toBe(true);
61
+ expect(isInvalidToken("auth", "token_expired")).toBe(true);
62
+ for (const code of [
63
+ "insufficient_permissions",
64
+ "verification_unavailable",
65
+ "issuance_unavailable",
66
+ ]) {
67
+ expect(isInvalidToken("auth", code)).toBe(false);
68
+ }
69
+ expect(
70
+ isInvalidTokenResponse(
71
+ new Response(null, {
72
+ status: 401,
73
+ headers: { "x-rivet-error": "auth.token_expired" },
74
+ }),
75
+ ),
76
+ ).toBe(true);
77
+ expect(
78
+ isInvalidTokenResponse(
79
+ new Response(null, {
80
+ status: 503,
81
+ headers: { "x-rivet-error": "auth.token_expired" },
82
+ }),
83
+ ),
84
+ ).toBe(false);
85
+ });
86
+ });
@@ -0,0 +1,93 @@
1
+ /** Called when RivetKit needs a credential. Keep issuer secrets on your backend. */
2
+ export type GetToken = (options: { forceRefresh: boolean }) => Promise<string>;
3
+
4
+ /** Only a confirmed invalid/expired credential can trigger an automatic renewal. */
5
+ export function isInvalidToken(group: string, code: string): boolean {
6
+ return (
7
+ group === "auth" &&
8
+ (code === "invalid_token" || code === "token_expired")
9
+ );
10
+ }
11
+
12
+ export function isInvalidTokenResponse(response: Response): boolean {
13
+ if (response.status !== 401) return false;
14
+ const error = response.headers.get("x-rivet-error");
15
+ if (!error) return false;
16
+ const separator = error.indexOf(".");
17
+ return (
18
+ separator > 0 &&
19
+ isInvalidToken(error.slice(0, separator), error.slice(separator + 1))
20
+ );
21
+ }
22
+
23
+ /** One token cache per client; concurrent requests and refreshes share the same issuance call. */
24
+ export class TokenProvider {
25
+ #cached?: { token: string; expiresAt?: number };
26
+ #pending?: Promise<string>;
27
+ #pendingForce = false;
28
+
29
+ constructor(private readonly getToken: GetToken) {}
30
+
31
+ async current(): Promise<string> {
32
+ if (
33
+ this.#cached &&
34
+ (this.#cached.expiresAt === undefined ||
35
+ Date.now() < this.#cached.expiresAt - 5_000)
36
+ ) {
37
+ return this.#cached.token;
38
+ }
39
+ return this.#load(false);
40
+ }
41
+
42
+ /** A late rejection for an older token must never erase a newer credential. */
43
+ async refreshIfCurrent(failedToken: string): Promise<string> {
44
+ if (this.#pending !== undefined) {
45
+ const wasForced = this.#pendingForce;
46
+ const refreshed = await this.#pending;
47
+ if (wasForced || refreshed !== failedToken) return refreshed;
48
+ }
49
+ if (this.#cached?.token !== failedToken) {
50
+ return this.current();
51
+ }
52
+ return this.#load(true);
53
+ }
54
+
55
+ #load(forceRefresh: boolean): Promise<string> {
56
+ if (this.#pending !== undefined) return this.#pending;
57
+
58
+ const pending = Promise.resolve()
59
+ .then(() => this.getToken({ forceRefresh }))
60
+ .then((token) => {
61
+ if (typeof token !== "string" || token.length === 0) {
62
+ throw new Error(
63
+ "getToken must resolve to a nonempty string",
64
+ );
65
+ }
66
+ this.#cached = { token, expiresAt: jwtExpiration(token) };
67
+ return token;
68
+ })
69
+ .finally(() => {
70
+ if (this.#pending === pending) this.#pending = undefined;
71
+ });
72
+ this.#pending = pending;
73
+ this.#pendingForce = forceRefresh;
74
+ return pending;
75
+ }
76
+ }
77
+
78
+ // This is only a cache hint, never a signature or authorization check. The Engine verifies JWTs.
79
+ function jwtExpiration(token: string): number | undefined {
80
+ try {
81
+ const payload = token.split(".")[1];
82
+ if (!payload) return undefined;
83
+ const claims = JSON.parse(
84
+ atob(payload.replace(/-/g, "+").replace(/_/g, "/")),
85
+ ) as { exp?: unknown };
86
+ return typeof claims.exp === "number" &&
87
+ Number.isSafeInteger(claims.exp)
88
+ ? claims.exp * 1_000
89
+ : undefined;
90
+ } catch {
91
+ return undefined;
92
+ }
93
+ }
@@ -229,6 +229,7 @@ export async function sendHttpRequest<
229
229
  metadata: responseData.metadata,
230
230
  rayId: rayId ?? undefined,
231
231
  actor: responseData.actor,
232
+ statusCode: response.status,
232
233
  },
233
234
  );
234
235
  } catch (error) {
@@ -336,7 +336,8 @@ export async function openWebSocketToGateway(
336
336
 
337
337
  logger().debug({
338
338
  msg: "opening websocket to actor via guard",
339
- gatewayUrl,
339
+ // Gateway URLs can embed credentials; never include the path or query in logs.
340
+ origin: new URL(gatewayUrl).origin,
340
341
  });
341
342
 
342
343
  // Create WebSocket connection
@@ -1,5 +1,6 @@
1
1
  import { serializeActorKey } from "@/actor/keys";
2
2
  import type { ClientConfig } from "@/client/client";
3
+ import type { IssuedToken, IssueTokenOptions } from "@/client/auth";
3
4
  import type { MetadataResponse } from "@/common/router";
4
5
  import type {
5
6
  ActorsCreateRequest,
@@ -12,6 +13,33 @@ import type {
12
13
  import type { RivetId } from "@/engine-api/common";
13
14
  import { apiCall } from "./api-utils";
14
15
 
16
+ /** Engine's JSON response uses snake_case; the public result uses camelCase. */
17
+ export type IssueTokenResponse = {
18
+ token: IssuedToken["token"];
19
+ issued_ts: IssuedToken["issuedAt"];
20
+ expires_ts: IssuedToken["expiresAt"];
21
+ };
22
+
23
+ export async function issueToken(
24
+ config: ClientConfig,
25
+ options: IssueTokenOptions,
26
+ ): Promise<IssueTokenResponse> {
27
+ return apiCall<
28
+ {
29
+ namespace: string;
30
+ subject?: IssueTokenOptions["subject"];
31
+ duration?: IssueTokenOptions["expiresIn"];
32
+ grants: IssueTokenOptions["grants"];
33
+ },
34
+ IssueTokenResponse
35
+ >(config, "POST", "/auth/tokens", {
36
+ namespace: config.namespace,
37
+ subject: options.subject,
38
+ duration: options.expiresIn,
39
+ grants: options.grants,
40
+ });
41
+ }
42
+
15
43
  // MARK: Get actor
16
44
  export async function getActor(
17
45
  config: ClientConfig,
@@ -1,6 +1,7 @@
1
1
  import type { Hono, Context as HonoContext } from "hono";
2
2
  import type { ActorKey, Encoding, UniversalWebSocket } from "@/actor/mod";
3
3
  import type { ActorQuery, CrashPolicy } from "@/client/query";
4
+ import type { IssuedToken, IssueTokenOptions } from "@/client/auth";
4
5
  import type { RegistryConfig } from "@/registry/config";
5
6
  import type { GetUpgradeWebSocket } from "@/utils";
6
7
 
@@ -17,6 +18,7 @@ export function shouldSkipReadyWait(
17
18
  }
18
19
 
19
20
  export interface EngineControlClient {
21
+ issueToken(options: IssueTokenOptions): Promise<IssuedToken>;
20
22
  getForId(input: GetForIdInput): Promise<ActorOutput | undefined>;
21
23
  getWithKey(input: GetWithKeyInput): Promise<ActorOutput | undefined>;
22
24
  getOrCreateWithKey(input: GetOrCreateWithKeyInput): Promise<ActorOutput>;
@@ -35,6 +37,8 @@ export interface EngineControlClient {
35
37
  params: unknown,
36
38
  options?: GatewayRequestOptions,
37
39
  ): Promise<UniversalWebSocket>;
40
+ /** Refresh a credential rejected during this WebSocket handshake, if supported. */
41
+ refreshAuthToken?(ws: UniversalWebSocket): Promise<boolean>;
38
42
  proxyRequest(
39
43
  c: HonoContext,
40
44
  actorRequest: Request,