@relayfile/sdk 0.10.60 → 0.10.62

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.
package/dist/cli/setup.js CHANGED
@@ -1,7 +1,6 @@
1
- import { createRelayfileCloudAccessTokenProvider } from "../cloud-token-provider.js";
2
1
  import { runRelayfileCloudLogin } from "../cloud-login.js";
3
2
  import { defaultMountLauncher, readMountedWorkspaceStatus } from "../mount-launcher.js";
4
- import { RelayfileSetup as BaseRelayfileSetup } from "../setup.js";
3
+ import { RelayfileSetup as BaseRelayfileSetup, resolveCloudTokensAccessToken } from "../setup.js";
5
4
  const DEFAULT_CLOUD_API_URL = "https://agentrelay.com/cloud";
6
5
  export class RelayfileSetup extends BaseRelayfileSetup {
7
6
  static async login(options = {}) {
@@ -21,13 +20,8 @@ export class RelayfileSetup extends BaseRelayfileSetup {
21
20
  return new RelayfileSetup({
22
21
  ...options,
23
22
  cloudApiUrl,
24
- accessToken: createRelayfileCloudAccessTokenProvider({
25
- ...tokens,
26
- apiUrl: tokens.apiUrl ?? cloudApiUrl
27
- }, {
28
- ...options,
29
- cloudApiUrl
30
- })
23
+ // Shared routing: relay_pa pairs rotate at RelayAuth, cloud pairs at Cloud.
24
+ accessToken: resolveCloudTokensAccessToken(tokens, options, cloudApiUrl)
31
25
  });
32
26
  }
33
27
  getDefaultMountLauncher() {
package/dist/client.d.ts CHANGED
@@ -7,6 +7,41 @@ import type { ForkHandle } from "@relayfile/core";
7
7
  * `{ workspace_id: "ws_123", agent_name: "review-bot", aud: ["relayfile"] }`
8
8
  */
9
9
  export type AccessTokenProvider = string | (() => string | Promise<string>);
10
+ /**
11
+ * An access+refresh token pair (e.g. `RELAYFILE_ACCESS_TOKEN` +
12
+ * `RELAYFILE_REFRESH_TOKEN`). Pass this as `token` and the client auto-wraps it
13
+ * in the correct rotating provider — a RelayAuth `relay_pa` pair refreshes at
14
+ * RelayAuth, a Cloud device-auth pair at Cloud — so the short access token is
15
+ * rotated automatically.
16
+ *
17
+ * Rotation is in-memory: on refresh the refresh token rotates too and the old
18
+ * one is revoked, so the pair you passed here (e.g. the one in your `.env`) is
19
+ * spent after the first refresh. In a long-lived process this is invisible, but
20
+ * a process that restarts (a redeploy, a new worker) will reload the original,
21
+ * now-revoked pair and fail with `invalid_grant` / "refresh token revoked".
22
+ * Supply {@link RelayFileClientOptions.onTokens} to persist each rotated pair so
23
+ * the next process boots from a live credential.
24
+ */
25
+ export interface RelayFileTokenPair {
26
+ accessToken: string;
27
+ refreshToken: string;
28
+ /** ISO expiry of the access token; derived from its `exp` claim when omitted. */
29
+ accessTokenExpiresAt?: string;
30
+ refreshTokenExpiresAt?: string;
31
+ /** RelayAuth base URL; derived from the refresh token's `iss` when omitted. */
32
+ relayauthUrl?: string;
33
+ /** Cloud API URL for a Cloud device-auth pair. */
34
+ apiUrl?: string;
35
+ }
36
+ /**
37
+ * Called after the client rotates the access+refresh {@link RelayFileTokenPair}.
38
+ * Persist the new pair (e.g. write it back to your secret store / `.env`) so a
39
+ * later process starts from a live credential instead of the revoked original.
40
+ * The callback is best-effort and retried on the next request until it succeeds;
41
+ * a throw is swallowed (the in-memory access token stays usable) but leaves the
42
+ * pair unpersisted, so make it durable.
43
+ */
44
+ export type RelayFileTokenPersister = (tokens: RelayFileTokenPair) => void | Promise<void>;
10
45
  export interface RelayFileRetryOptions {
11
46
  maxRetries?: number;
12
47
  baseDelayMs?: number;
@@ -31,12 +66,24 @@ export interface RelayFileClientOptions {
31
66
  /** API base URL. Defaults to https://api.relayfile.dev */
32
67
  baseUrl?: string;
33
68
  /**
34
- * Bearer token or token factory for SDK requests.
69
+ * Bearer token, token factory, or an access+refresh {@link RelayFileTokenPair}
70
+ * for SDK requests. A pair is auto-wrapped in a rotating provider (RelayAuth
71
+ * `relay_pa` pairs refresh at RelayAuth, Cloud pairs at Cloud), so the short
72
+ * access token is renewed automatically.
35
73
  *
36
74
  * Relayfile-authenticated JWTs should include `workspace_id`, `agent_name`,
37
75
  * and `aud` containing `relayfile`.
38
76
  */
39
- token: AccessTokenProvider;
77
+ token: AccessTokenProvider | RelayFileTokenPair;
78
+ /**
79
+ * Persistence hook for a rotating {@link RelayFileTokenPair}. Called with the
80
+ * new pair each time the access token is refreshed (the refresh token rotates
81
+ * too). Write it back to your secret store so a process that restarts reloads a
82
+ * live credential instead of the spent original — without this, a `token` pair
83
+ * survives only within one process lifetime. Ignored when `token` is a string
84
+ * or token factory (that provider owns its own rotation).
85
+ */
86
+ onTokens?: RelayFileTokenPersister;
40
87
  fetchImpl?: typeof fetch;
41
88
  userAgent?: string;
42
89
  retry?: RelayFileRetryOptions;
package/dist/client.js CHANGED
@@ -1,5 +1,53 @@
1
1
  import { RelayFileSync, normalizeFilesystemEvent } from "./sync.js";
2
+ import { createRelayfileCloudAccessTokenProvider } from "./cloud-token-provider.js";
3
+ import { createRelayauthPathTokenAccessTokenProvider, isRelayauthRefreshToken } from "./relayauth-token-provider.js";
2
4
  import { InvalidStateError, MergeConflictError, ParentMovedError, PayloadTooLargeError, QueueFullError, RelayFileApiError, RevisionConflictError } from "./errors.js";
5
+ // Resolve the `token` option into a concrete AccessTokenProvider. A string or
6
+ // function is used as-is; a token PAIR is auto-wrapped in the rotating provider
7
+ // that matches the refresh token's issuer. When `onTokens` is supplied, each
8
+ // rotated pair is handed back so the caller can persist it across restarts.
9
+ function resolveTokenOption(token, onTokens) {
10
+ if (typeof token === "string" || typeof token === "function") {
11
+ return token;
12
+ }
13
+ if (isRelayauthRefreshToken(token.refreshToken)) {
14
+ return createRelayauthPathTokenAccessTokenProvider({
15
+ accessToken: token.accessToken,
16
+ refreshToken: token.refreshToken,
17
+ accessTokenExpiresAt: token.accessTokenExpiresAt,
18
+ refreshTokenExpiresAt: token.refreshTokenExpiresAt,
19
+ relayauthUrl: token.relayauthUrl
20
+ }, onTokens
21
+ ? {
22
+ onTokens: (rotated) => onTokens({
23
+ accessToken: rotated.accessToken,
24
+ refreshToken: rotated.refreshToken,
25
+ accessTokenExpiresAt: rotated.accessTokenExpiresAt,
26
+ refreshTokenExpiresAt: rotated.refreshTokenExpiresAt,
27
+ relayauthUrl: rotated.relayauthUrl
28
+ })
29
+ }
30
+ : undefined);
31
+ }
32
+ return createRelayfileCloudAccessTokenProvider({
33
+ apiUrl: token.apiUrl,
34
+ accessToken: token.accessToken,
35
+ refreshToken: token.refreshToken,
36
+ // The cloud provider treats an unparseable expiry as "refresh now".
37
+ accessTokenExpiresAt: token.accessTokenExpiresAt ?? "",
38
+ refreshTokenExpiresAt: token.refreshTokenExpiresAt
39
+ }, onTokens
40
+ ? {
41
+ onTokens: (rotated) => onTokens({
42
+ accessToken: rotated.accessToken,
43
+ refreshToken: rotated.refreshToken,
44
+ accessTokenExpiresAt: rotated.accessTokenExpiresAt,
45
+ refreshTokenExpiresAt: rotated.refreshTokenExpiresAt,
46
+ apiUrl: rotated.apiUrl
47
+ })
48
+ }
49
+ : undefined);
50
+ }
3
51
  /** Default base URL for the hosted Relayfile API */
4
52
  export const DEFAULT_RELAYFILE_BASE_URL = "https://api.relayfile.dev";
5
53
  const DEFAULT_RETRY_OPTIONS = {
@@ -1222,7 +1270,7 @@ export class RelayFileClient {
1222
1270
  retryOptions;
1223
1271
  constructor(options) {
1224
1272
  this.baseUrl = (options.baseUrl ?? DEFAULT_RELAYFILE_BASE_URL).replace(/\/+$/, "");
1225
- this.tokenProvider = options.token;
1273
+ this.tokenProvider = resolveTokenOption(options.token, options.onTokens);
1226
1274
  this.fetchImpl = options.fetchImpl ?? fetch.bind(globalThis);
1227
1275
  this.userAgent = options.userAgent;
1228
1276
  this.retryOptions = normalizeRetryOptions(options.retry);
@@ -14,6 +14,11 @@ export function createRelayfileCloudAccessTokenProvider(initialTokens, options =
14
14
  apiUrl: normalizeNonEmptyString(initialTokens.apiUrl) ?? cloudApiUrl
15
15
  };
16
16
  let refreshPromise;
17
+ // A rotated token set whose `onTokens` persistence callback has not yet
18
+ // succeeded. Until it does, a consumed refresh token may still live in the
19
+ // caller's persisted copy (→ `invalid_grant` after restart), so we retry the
20
+ // callback on every subsequent use rather than treating rotation as complete.
21
+ let pendingPersist;
17
22
  return async () => {
18
23
  if (shouldRefresh(tokens, refreshWindowMs)) {
19
24
  if (!refreshPromise) {
@@ -26,6 +31,10 @@ export function createRelayfileCloudAccessTokenProvider(initialTokens, options =
26
31
  refreshPromise = undefined;
27
32
  }
28
33
  }
34
+ else if (pendingPersist) {
35
+ // No refresh needed, but a prior rotation still owes a successful persist.
36
+ await persistPendingTokens();
37
+ }
29
38
  return tokens.accessToken;
30
39
  };
31
40
  async function refresh() {
@@ -42,7 +51,32 @@ export function createRelayfileCloudAccessTokenProvider(initialTokens, options =
42
51
  throw new CloudApiError(response.status, payload);
43
52
  }
44
53
  tokens = readTokenSetFromPayload(payload, cloudApiUrl);
45
- await options.onTokens?.({ ...tokens });
54
+ pendingPersist = { ...tokens };
55
+ await persistPendingTokens();
56
+ }
57
+ // Best-effort, retried persistence. A callback failure never rejects the
58
+ // provider — the in-memory access token is valid and usable — but the set
59
+ // stays pending so the next provider() call re-attempts, so a durable copy
60
+ // eventually catches up with the rotated refresh token.
61
+ async function persistPendingTokens() {
62
+ if (!pendingPersist) {
63
+ return;
64
+ }
65
+ if (!options.onTokens) {
66
+ pendingPersist = undefined;
67
+ return;
68
+ }
69
+ const toPersist = pendingPersist;
70
+ try {
71
+ await options.onTokens({ ...toPersist });
72
+ // Only clear if nothing rotated again while the callback was in flight.
73
+ if (pendingPersist === toPersist) {
74
+ pendingPersist = undefined;
75
+ }
76
+ }
77
+ catch {
78
+ // Keep pending; retried on the next use.
79
+ }
46
80
  }
47
81
  }
48
82
  function shouldRefresh(tokens, refreshWindowMs) {
package/dist/index.d.ts CHANGED
@@ -1,7 +1,8 @@
1
- export { RelayFileClient, DEFAULT_RELAYFILE_BASE_URL, type AccessTokenProvider, type RelayFileChangeLogOptions, type ConnectWebSocketOptions, type RelayFileClientOptions, type RelayFileRetryOptions, type WebSocketConnection } from "./client.js";
1
+ export { RelayFileClient, DEFAULT_RELAYFILE_BASE_URL, type AccessTokenProvider, type RelayFileTokenPair, type RelayFileTokenPersister, type RelayFileChangeLogOptions, type ConnectWebSocketOptions, type RelayFileClientOptions, type RelayFileRetryOptions, type WebSocketConnection } from "./client.js";
2
2
  export type { RelayFileReadCacheOptions } from "./types.js";
3
3
  export { RelayfileSetup, RELAYFILE_SDK_VERSION, WorkspaceHandle } from "./setup.js";
4
4
  export { type RelayfileCloudLoginOptions, type RelayfileCloudTokenSet, type RelayfileCloudTokenSetupOptions } from "./cloud-login.js";
5
+ export { createRelayauthPathTokenAccessTokenProvider, isRelayauthRefreshToken, type RelayauthPathTokenSet, type RelayauthPathTokenSetupOptions } from "./relayauth-token-provider.js";
5
6
  export { CloudAbortError, CloudApiError, CloudTimeoutError, InvalidLocalDirError, InvalidMountModeError, InvalidRemotePathError, IntegrationConnectionTimeoutError, MalformedCloudResponseError, MissingConnectionIdError, MountModeUnavailableError, MountMultiPathUnsupportedError, MountReadyTimeoutError, MountSessionInputError, ProviderNotConnectedError, ProviderNotReadyError, RelayfileSetupError, UnknownProviderError } from "./setup-errors.js";
6
7
  export { type EnsureMountedWorkspaceInput, WORKSPACE_INTEGRATION_PROVIDERS, type AgentWorkspaceInvite, type AgentWorkspaceInviteOptions, type AgentWorkspaceScopedInviteOptions, type ConnectIntegrationOptions, type ConnectIntegrationResult, type CheckpointAndSealInput, type CreateWorkspaceOptions, type JoinWorkspaceOptions, type MountLauncher, type MountLauncherEvent, type MountLauncherInstance, type MountLauncherStart, type MountLocalLayout, type MountMode, type MountSessionRequest, type MountSessionResponse, type MountSessionResult, type MountSyncMode, type MountSupervisorEvent, type MountedWorkspaceHandle, type MountedWorkspaceStatus, type MountWorkspaceInput, type ReadMountedWorkspaceStatusInput, type RelayfileSetupOptions, type RelayfileSetupRetryOptions, type WaitForConnectionOptions, type WorkspaceInfo, type WorkspaceIntegrationProvider, type WorkspaceMountEnv, type WorkspaceMountEnvOptions, type WorkspacePermissions } from "./setup-types.js";
7
8
  export { RelayFileSync, type RelayFileSyncOptions, type RelayFileSyncPong, type RelayFileSyncReconnectOptions, type RelayFileSyncSocket, type RelayFileSyncStart, type RelayFileSyncState, type RelayFileSyncTokenProvider } from "./sync.js";
package/dist/index.js CHANGED
@@ -1,5 +1,6 @@
1
1
  export { RelayFileClient, DEFAULT_RELAYFILE_BASE_URL } from "./client.js";
2
2
  export { RelayfileSetup, RELAYFILE_SDK_VERSION, WorkspaceHandle } from "./setup.js";
3
+ export { createRelayauthPathTokenAccessTokenProvider, isRelayauthRefreshToken } from "./relayauth-token-provider.js";
3
4
  export { CloudAbortError, CloudApiError, CloudTimeoutError, InvalidLocalDirError, InvalidMountModeError, InvalidRemotePathError, IntegrationConnectionTimeoutError, MalformedCloudResponseError, MissingConnectionIdError, MountModeUnavailableError, MountMultiPathUnsupportedError, MountReadyTimeoutError, MountSessionInputError, ProviderNotConnectedError, ProviderNotReadyError, RelayfileSetupError, UnknownProviderError } from "./setup-errors.js";
4
5
  export { WORKSPACE_INTEGRATION_PROVIDERS } from "./setup-types.js";
5
6
  export { RelayFileSync } from "./sync.js";
@@ -1 +1 @@
1
- export declare const RELAYFILE_VERSION = "0.10.60";
1
+ export declare const RELAYFILE_VERSION = "0.10.62";
@@ -1,2 +1,2 @@
1
1
  // Generated by scripts/sync-package-version.mjs during every SDK build.
2
- export const RELAYFILE_VERSION = "0.10.60";
2
+ export const RELAYFILE_VERSION = "0.10.62";
@@ -0,0 +1,22 @@
1
+ import type { AccessTokenProvider } from "./client.js";
2
+ export interface RelayauthPathTokenSet {
3
+ accessToken: string;
4
+ refreshToken: string;
5
+ /** Optional; when omitted it is derived from the access token's `exp` claim. */
6
+ accessTokenExpiresAt?: string;
7
+ refreshTokenExpiresAt?: string;
8
+ /**
9
+ * RelayAuth base URL (e.g. `https://api.relayauth.dev`). When omitted it is
10
+ * derived from the refresh token's `iss` claim, mapping the issuer host to its
11
+ * API host (`relayauth.dev` -> `api.relayauth.dev`).
12
+ */
13
+ relayauthUrl?: string;
14
+ }
15
+ export interface RelayauthPathTokenSetupOptions {
16
+ requestTimeoutMs?: number;
17
+ refreshWindowMs?: number;
18
+ onTokens?: (tokens: RelayauthPathTokenSet) => void | Promise<void>;
19
+ }
20
+ /** True when `refreshToken` is a RelayAuth-issued refresh token (relay_pa pair). */
21
+ export declare function isRelayauthRefreshToken(refreshToken: string): boolean;
22
+ export declare function createRelayauthPathTokenAccessTokenProvider(initialTokens: RelayauthPathTokenSet, options?: RelayauthPathTokenSetupOptions): AccessTokenProvider;
@@ -0,0 +1,250 @@
1
+ import { CloudApiError, CloudTimeoutError, MalformedCloudResponseError, RelayfileSetupError } from "./setup-errors.js";
2
+ import { RELAYFILE_SDK_VERSION } from "./version.js";
3
+ // A `relay_pa` (RelayAuth path-token) access+refresh pair is issued and rotated
4
+ // by RelayAuth, NOT by the Cloud device-auth flow. Its refresh token carries
5
+ // `aud: ["relayauth"]` and scope `relayauth:token:refresh`, and it is exchanged
6
+ // at `<relayauth>/v1/tokens/refresh` — a DIFFERENT endpoint from Cloud's
7
+ // `api/v1/auth/token/refresh`. Sending such a refresh token to the Cloud endpoint
8
+ // returns `invalid_grant`, which is the failure mode this provider fixes:
9
+ // `createRelayfileCloudAccessTokenProvider` unconditionally targets Cloud, so a
10
+ // relay_pa credential (e.g. `RELAYFILE_ACCESS_TOKEN` + `RELAYFILE_REFRESH_TOKEN`
11
+ // in an agent .env) never rotates through it. Conveniently, RelayAuth's refresh
12
+ // response has the SAME shape Cloud returns
13
+ // (`accessToken`/`refreshToken`/`accessTokenExpiresAt`/`refreshTokenExpiresAt`),
14
+ // so only the endpoint differs.
15
+ const DEFAULT_REQUEST_TIMEOUT_MS = 30_000;
16
+ const DEFAULT_REFRESH_WINDOW_MS = 60_000;
17
+ /** True when `refreshToken` is a RelayAuth-issued refresh token (relay_pa pair). */
18
+ export function isRelayauthRefreshToken(refreshToken) {
19
+ const claims = decodeJwtClaims(refreshToken);
20
+ if (!claims)
21
+ return false;
22
+ const aud = claims.aud;
23
+ const audiences = Array.isArray(aud) ? aud : typeof aud === "string" ? [aud] : [];
24
+ if (audiences.includes("relayauth"))
25
+ return true;
26
+ const scopes = Array.isArray(claims.scopes) ? claims.scopes : [];
27
+ return scopes.includes("relayauth:token:refresh");
28
+ }
29
+ export function createRelayauthPathTokenAccessTokenProvider(initialTokens, options = {}) {
30
+ const requestTimeoutMs = Math.max(1, Math.floor(options.requestTimeoutMs ?? DEFAULT_REQUEST_TIMEOUT_MS));
31
+ const refreshWindowMs = Math.max(0, Math.floor(options.refreshWindowMs ?? DEFAULT_REFRESH_WINDOW_MS));
32
+ const refreshUrl = buildRefreshUrl(initialTokens);
33
+ let tokens = withDerivedAccessExpiry(initialTokens);
34
+ let refreshPromise;
35
+ // A rotated token set whose `onTokens` persistence callback has not yet
36
+ // succeeded. Until it does, a consumed refresh token may still live in the
37
+ // caller's persisted copy (→ `invalid_grant` after restart), so we retry the
38
+ // callback on every subsequent use rather than treating rotation as complete.
39
+ let pendingPersist;
40
+ return async () => {
41
+ if (shouldRefresh(tokens, refreshWindowMs)) {
42
+ if (!refreshPromise) {
43
+ refreshPromise = refresh();
44
+ }
45
+ try {
46
+ await refreshPromise;
47
+ }
48
+ finally {
49
+ refreshPromise = undefined;
50
+ }
51
+ }
52
+ else if (pendingPersist) {
53
+ // No refresh needed, but a prior rotation still owes a successful persist.
54
+ await persistPendingTokens();
55
+ }
56
+ return tokens.accessToken;
57
+ };
58
+ async function refresh() {
59
+ const { response, payload } = await fetchJsonWithTimeout(refreshUrl, {
60
+ method: "POST",
61
+ headers: {
62
+ "Content-Type": "application/json",
63
+ "X-Relayfile-SDK-Version": RELAYFILE_SDK_VERSION
64
+ },
65
+ body: JSON.stringify({ refreshToken: tokens.refreshToken })
66
+ }, requestTimeoutMs);
67
+ if (!response.ok) {
68
+ throw new CloudApiError(response.status, payload);
69
+ }
70
+ tokens = withDerivedAccessExpiry({
71
+ relayauthUrl: tokens.relayauthUrl,
72
+ accessToken: requireStringField(payload, "accessToken"),
73
+ refreshToken: requireStringField(payload, "refreshToken"),
74
+ accessTokenExpiresAt: readOptionalStringField(payload, "accessTokenExpiresAt"),
75
+ refreshTokenExpiresAt: readOptionalStringField(payload, "refreshTokenExpiresAt")
76
+ });
77
+ pendingPersist = { ...tokens };
78
+ await persistPendingTokens();
79
+ }
80
+ // Best-effort, retried persistence. A callback failure never rejects the
81
+ // provider — the in-memory access token is valid and usable — but the set
82
+ // stays pending so the next provider() call re-attempts, so a durable copy
83
+ // eventually catches up with the rotated refresh token.
84
+ async function persistPendingTokens() {
85
+ if (!pendingPersist) {
86
+ return;
87
+ }
88
+ if (!options.onTokens) {
89
+ pendingPersist = undefined;
90
+ return;
91
+ }
92
+ const toPersist = pendingPersist;
93
+ try {
94
+ await options.onTokens({ ...toPersist });
95
+ // Only clear if nothing rotated again while the callback was in flight.
96
+ if (pendingPersist === toPersist) {
97
+ pendingPersist = undefined;
98
+ }
99
+ }
100
+ catch {
101
+ // Keep pending; retried on the next use.
102
+ }
103
+ }
104
+ }
105
+ function buildRefreshUrl(tokens) {
106
+ const explicit = normalizeNonEmptyString(tokens.relayauthUrl);
107
+ const base = explicit ?? deriveRelayauthApiBase(tokens.refreshToken);
108
+ if (!base) {
109
+ // NEVER put the refresh token in the error — it is a live bearer credential.
110
+ throw new RelayfileSetupError("Cannot determine the RelayAuth refresh endpoint: pass `relayauthUrl`, or use a refresh token that carries an `iss` claim.", "relayauth_url_unresolved");
111
+ }
112
+ let url;
113
+ try {
114
+ url = new URL(base);
115
+ }
116
+ catch {
117
+ throw new RelayfileSetupError(`Invalid RelayAuth URL: ${base}`, "relayauth_url_invalid");
118
+ }
119
+ // The refresh token is transmitted in the request body, so the endpoint must
120
+ // be HTTPS. A loopback host is allowed for local self-host development.
121
+ if (url.protocol !== "https:" && !isLoopbackHost(url.hostname)) {
122
+ throw new RelayfileSetupError(`RelayAuth refresh endpoint must use HTTPS (got ${url.protocol}//${url.host}).`, "relayauth_url_insecure");
123
+ }
124
+ if (!url.pathname.endsWith("/")) {
125
+ url.pathname = `${url.pathname}/`;
126
+ }
127
+ return new URL("v1/tokens/refresh", url).toString();
128
+ }
129
+ function isLoopbackHost(hostname) {
130
+ return (hostname === "localhost" ||
131
+ hostname === "127.0.0.1" ||
132
+ hostname === "::1" ||
133
+ hostname === "[::1]");
134
+ }
135
+ // Derive the RelayAuth API base from the refresh token's `iss`. The issuer is the
136
+ // public host (`https://relayauth.dev`); token exchange lives on the API host
137
+ // (`https://api.relayauth.dev`). A host that already begins with `api.` is left
138
+ // as-is so self-hosted issuers work unchanged.
139
+ function deriveRelayauthApiBase(refreshToken) {
140
+ const claims = decodeJwtClaims(refreshToken);
141
+ const iss = typeof claims?.iss === "string" ? claims.iss : undefined;
142
+ if (!iss)
143
+ return undefined;
144
+ try {
145
+ const url = new URL(iss);
146
+ if (!url.hostname.startsWith("api.")) {
147
+ url.hostname = `api.${url.hostname}`;
148
+ }
149
+ // `url.host` preserves a non-default port; `url.hostname` would drop it.
150
+ return `${url.protocol}//${url.host}`;
151
+ }
152
+ catch {
153
+ return undefined;
154
+ }
155
+ }
156
+ function withDerivedAccessExpiry(tokens) {
157
+ const explicit = normalizeNonEmptyString(tokens.accessTokenExpiresAt);
158
+ if (explicit && !Number.isNaN(Date.parse(explicit))) {
159
+ return { ...tokens, accessTokenExpiresAt: explicit };
160
+ }
161
+ const exp = decodeJwtClaims(tokens.accessToken)?.exp;
162
+ const iso = typeof exp === "number" && Number.isFinite(exp)
163
+ ? new Date(exp * 1000).toISOString()
164
+ : new Date(0).toISOString(); // unknown -> force refresh on first use
165
+ return { ...tokens, accessTokenExpiresAt: iso };
166
+ }
167
+ function shouldRefresh(tokens, refreshWindowMs) {
168
+ const expiresAt = Date.parse(tokens.accessTokenExpiresAt);
169
+ if (Number.isNaN(expiresAt)) {
170
+ return true;
171
+ }
172
+ return expiresAt - Date.now() <= refreshWindowMs;
173
+ }
174
+ function decodeJwtClaims(token) {
175
+ try {
176
+ const compact = token.replace(/^relay_[a-z]+_/, "");
177
+ const payload = compact.split(".")[1];
178
+ if (!payload)
179
+ return undefined;
180
+ const json = Buffer.from(payload, "base64url").toString("utf8");
181
+ return JSON.parse(json);
182
+ }
183
+ catch {
184
+ return undefined;
185
+ }
186
+ }
187
+ // Runs the fetch AND the response-body read under one timeout, so a server that
188
+ // sends headers then stalls the body still trips `requestTimeoutMs` instead of
189
+ // hanging the shared refresh (and every caller awaiting it) indefinitely.
190
+ async function fetchJsonWithTimeout(url, init, timeoutMs) {
191
+ const controller = new AbortController();
192
+ const timer = setTimeout(() => controller.abort(), timeoutMs);
193
+ try {
194
+ const response = await fetch(url, { ...init, signal: controller.signal });
195
+ const payload = await readResponseBody(response);
196
+ return { response, payload };
197
+ }
198
+ catch (error) {
199
+ if (controller.signal.aborted) {
200
+ throw new CloudTimeoutError("refreshRelayauthAccessToken", timeoutMs);
201
+ }
202
+ throw error;
203
+ }
204
+ finally {
205
+ clearTimeout(timer);
206
+ }
207
+ }
208
+ async function readResponseBody(response) {
209
+ const text = await response.text();
210
+ if (text === "") {
211
+ return null;
212
+ }
213
+ const contentType = response.headers.get("content-type") ?? "";
214
+ if (contentType.includes("application/json")) {
215
+ try {
216
+ return JSON.parse(text);
217
+ }
218
+ catch {
219
+ return text;
220
+ }
221
+ }
222
+ return text;
223
+ }
224
+ function requireStringField(payload, field) {
225
+ const value = readField(payload, field);
226
+ if (typeof value !== "string" || value.trim() === "") {
227
+ throw new MalformedCloudResponseError(field, payload);
228
+ }
229
+ return value;
230
+ }
231
+ function readOptionalStringField(payload, field) {
232
+ const value = readField(payload, field);
233
+ if (value === undefined) {
234
+ return undefined;
235
+ }
236
+ if (typeof value !== "string" || value.trim() === "") {
237
+ throw new MalformedCloudResponseError(field, payload);
238
+ }
239
+ return value;
240
+ }
241
+ function readField(payload, field) {
242
+ if (!payload || typeof payload !== "object" || Array.isArray(payload)) {
243
+ return undefined;
244
+ }
245
+ return payload[field];
246
+ }
247
+ function normalizeNonEmptyString(value) {
248
+ const normalized = value?.trim();
249
+ return normalized ? normalized : undefined;
250
+ }
package/dist/setup.d.ts CHANGED
@@ -37,6 +37,16 @@ interface WorkspaceHandleOptions {
37
37
  token: string;
38
38
  joinOptions: NormalizedJoinWorkspaceOptions;
39
39
  }
40
+ /**
41
+ * Build the rotating access-token provider for a cloud token set, auto-routing
42
+ * by the refresh token's issuer: a RelayAuth `relay_pa` pair (e.g.
43
+ * RELAYFILE_ACCESS_TOKEN + RELAYFILE_REFRESH_TOKEN) rotates at RelayAuth, while a
44
+ * Cloud device-auth pair rotates at Cloud. Routing a relay_pa token through the
45
+ * Cloud endpoint returns `invalid_grant`, so this picks the right provider once
46
+ * and is shared by every `fromCloudTokens` (base and CLI subclass) so no entry
47
+ * point can silently skip the routing.
48
+ */
49
+ export declare function resolveCloudTokensAccessToken(tokens: RelayfileCloudTokenSet, options: RelayfileCloudTokenSetupOptions, cloudApiUrl: string): AccessTokenProvider;
40
50
  export declare class RelayfileSetup {
41
51
  private readonly cloudApiUrl;
42
52
  private readonly accessToken?;
package/dist/setup.js CHANGED
@@ -1,5 +1,6 @@
1
1
  import { RelayFileClient } from "./client.js";
2
2
  import { createRelayfileCloudAccessTokenProvider } from "./cloud-token-provider.js";
3
+ import { createRelayauthPathTokenAccessTokenProvider, isRelayauthRefreshToken } from "./relayauth-token-provider.js";
3
4
  import { CloudAbortError, CloudApiError, CloudTimeoutError, InvalidLocalDirError, InvalidMountModeError, InvalidRemotePathError, IntegrationConnectionTimeoutError, MalformedCloudResponseError, MissingConnectionIdError, MountSessionInputError, ProviderNotConnectedError, ProviderNotReadyError, RelayfileSetupError, UnknownProviderError } from "./setup-errors.js";
4
5
  import { WORKSPACE_INTEGRATION_PROVIDERS } from "./setup-types.js";
5
6
  import { RELAYFILE_SDK_VERSION } from "./version.js";
@@ -32,6 +33,38 @@ const nodeOnlyMountLauncher = {
32
33
  throw new RelayfileSetupError("The default relayfile-mount launcher is only available from @relayfile/sdk/cli. Import RelayfileSetup from @relayfile/sdk/cli or pass a custom launcher to mountWorkspace().", "node_only_sdk_feature");
33
34
  }
34
35
  };
36
+ /**
37
+ * Build the rotating access-token provider for a cloud token set, auto-routing
38
+ * by the refresh token's issuer: a RelayAuth `relay_pa` pair (e.g.
39
+ * RELAYFILE_ACCESS_TOKEN + RELAYFILE_REFRESH_TOKEN) rotates at RelayAuth, while a
40
+ * Cloud device-auth pair rotates at Cloud. Routing a relay_pa token through the
41
+ * Cloud endpoint returns `invalid_grant`, so this picks the right provider once
42
+ * and is shared by every `fromCloudTokens` (base and CLI subclass) so no entry
43
+ * point can silently skip the routing.
44
+ */
45
+ export function resolveCloudTokensAccessToken(tokens, options, cloudApiUrl) {
46
+ if (!isRelayauthRefreshToken(tokens.refreshToken)) {
47
+ return createRelayfileCloudAccessTokenProvider({ ...tokens, apiUrl: tokens.apiUrl ?? cloudApiUrl }, { ...options, cloudApiUrl });
48
+ }
49
+ return createRelayauthPathTokenAccessTokenProvider({
50
+ accessToken: tokens.accessToken,
51
+ refreshToken: tokens.refreshToken,
52
+ accessTokenExpiresAt: tokens.accessTokenExpiresAt,
53
+ refreshTokenExpiresAt: tokens.refreshTokenExpiresAt
54
+ }, {
55
+ requestTimeoutMs: options.requestTimeoutMs,
56
+ refreshWindowMs: options.refreshWindowMs,
57
+ onTokens: options.onTokens
58
+ ? (rotated) => options.onTokens({
59
+ apiUrl: tokens.apiUrl ?? cloudApiUrl,
60
+ accessToken: rotated.accessToken,
61
+ refreshToken: rotated.refreshToken,
62
+ accessTokenExpiresAt: rotated.accessTokenExpiresAt ?? "",
63
+ refreshTokenExpiresAt: rotated.refreshTokenExpiresAt
64
+ })
65
+ : undefined
66
+ });
67
+ }
35
68
  export class RelayfileSetup {
36
69
  cloudApiUrl;
37
70
  accessToken;
@@ -47,13 +80,7 @@ export class RelayfileSetup {
47
80
  return new RelayfileSetup({
48
81
  ...options,
49
82
  cloudApiUrl,
50
- accessToken: createRelayfileCloudAccessTokenProvider({
51
- ...tokens,
52
- apiUrl: tokens.apiUrl ?? cloudApiUrl
53
- }, {
54
- ...options,
55
- cloudApiUrl
56
- })
83
+ accessToken: resolveCloudTokensAccessToken(tokens, options, cloudApiUrl)
57
84
  });
58
85
  }
59
86
  constructor(options = {}) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@relayfile/sdk",
3
- "version": "0.10.60",
3
+ "version": "0.10.62",
4
4
  "description": "TypeScript SDK for relayfile — real-time filesystem for humans and agents",
5
5
  "main": "dist/index.js",
6
6
  "types": "dist/index.d.ts",
@@ -60,15 +60,15 @@
60
60
  "prepublishOnly": "npm run build"
61
61
  },
62
62
  "dependencies": {
63
- "@relayfile/core": "0.10.60",
63
+ "@relayfile/core": "0.10.62",
64
64
  "ignore": "^7.0.5",
65
65
  "tar": "^7.5.10"
66
66
  },
67
67
  "optionalDependencies": {
68
- "@relayfile/mount-darwin-arm64": "0.10.60",
69
- "@relayfile/mount-darwin-x64": "0.10.60",
70
- "@relayfile/mount-linux-arm64": "0.10.60",
71
- "@relayfile/mount-linux-x64": "0.10.60"
68
+ "@relayfile/mount-darwin-arm64": "0.10.62",
69
+ "@relayfile/mount-darwin-x64": "0.10.62",
70
+ "@relayfile/mount-linux-arm64": "0.10.62",
71
+ "@relayfile/mount-linux-x64": "0.10.62"
72
72
  },
73
73
  "devDependencies": {
74
74
  "typescript": "^5.7.3",