diff --git a/apps/mobile/app.config.ts b/apps/mobile/app.config.ts index ea885f819984f..71f617b041360 100644 --- a/apps/mobile/app.config.ts +++ b/apps/mobile/app.config.ts @@ -439,6 +439,7 @@ const config: ExpoConfig = { iosPersonalTeamBuild: isIosPersonalTeamBuild, relay: { url: repoEnv.T3CODE_RELAY_URL ?? null, + backgroundUrl: repoEnv.T3CODE_BACKGROUND_RELAY_URL ?? null, }, clerk: { publishableKey: repoEnv.EXPO_PUBLIC_CLERK_PUBLISHABLE_KEY ?? null, diff --git a/apps/mobile/src/features/agent-awareness/remoteRegistration.ts b/apps/mobile/src/features/agent-awareness/remoteRegistration.ts index fc24d180a3a04..321c219177bc1 100644 --- a/apps/mobile/src/features/agent-awareness/remoteRegistration.ts +++ b/apps/mobile/src/features/agent-awareness/remoteRegistration.ts @@ -140,7 +140,8 @@ function mergeAgentAwarenessRegistrationPreferences( } function readRelayConfig(): { readonly url: string } | null { - const relayUrl = resolveCloudPublicConfig().relay.url; + const { url, backgroundUrl } = resolveCloudPublicConfig().relay; + const relayUrl = backgroundUrl ?? url; if (!relayUrl) { return null; } diff --git a/apps/mobile/src/features/cloud/managedRelayLayer.test.ts b/apps/mobile/src/features/cloud/managedRelayLayer.test.ts index b7de6a355ff2e..56c4422220138 100644 --- a/apps/mobile/src/features/cloud/managedRelayLayer.test.ts +++ b/apps/mobile/src/features/cloud/managedRelayLayer.test.ts @@ -12,6 +12,10 @@ import { FetchHttpClient } from "effect/http"; import * as Dpop from "./dpop"; import * as ManagedRelayLayer from "./managedRelayLayer"; +vi.mock("expo-constants", () => ({ + default: { expoConfig: { extra: {} } }, +})); + vi.mock("expo-crypto", () => ({ CryptoDigestAlgorithm: { SHA256: "SHA-256" }, getRandomBytes: (byteCount: number) => new Uint8Array(NodeCrypto.randomBytes(byteCount)), diff --git a/apps/mobile/src/features/cloud/managedRelayLayer.ts b/apps/mobile/src/features/cloud/managedRelayLayer.ts index 9e1e82332acfa..b41bef58d28af 100644 --- a/apps/mobile/src/features/cloud/managedRelayLayer.ts +++ b/apps/mobile/src/features/cloud/managedRelayLayer.ts @@ -8,6 +8,7 @@ import * as Exit from "effect/Exit"; import * as Layer from "effect/Layer"; import { createDpopProof, loadOrCreateDpopProofKeyPair } from "./dpop"; +import { resolveCloudPublicConfig } from "./publicConfig"; import { managedRelayAccessTokenStore } from "./managedRelayTokenStore"; const layerRelayDpopSigner = Layer.effect( @@ -65,6 +66,7 @@ const layerRelayDpopSigner = Layer.effect( export const layer = (relayUrl: string) => ManagedRelay.layer({ relayUrl, + backgroundRelayUrl: resolveCloudPublicConfig().relay.backgroundUrl ?? undefined, clientId: RelayMobileClientId, accessTokenStore: managedRelayAccessTokenStore, }).pipe(Layer.provideMerge(layerRelayDpopSigner)); diff --git a/apps/mobile/src/features/cloud/publicConfig.test.ts b/apps/mobile/src/features/cloud/publicConfig.test.ts index 8c09819aa72f3..ea5c54f59a029 100644 --- a/apps/mobile/src/features/cloud/publicConfig.test.ts +++ b/apps/mobile/src/features/cloud/publicConfig.test.ts @@ -1,6 +1,6 @@ import { describe, expect, it, vi } from "vite-plus/test"; -import { resolveCloudPublicConfig } from "./publicConfig"; +import { BackgroundRelayUrlInvalidError, resolveCloudPublicConfig } from "./publicConfig"; vi.mock("expo-constants", () => ({ default: { @@ -11,6 +11,25 @@ vi.mock("expo-constants", () => ({ })); describe("resolveCloudPublicConfig", () => { + it("normalizes an independent background relay without changing T3 Connect", () => { + expect( + resolveCloudPublicConfig({ + relay: { + url: "https://relay.example.test", + backgroundUrl: " https://background.example.test/// ", + }, + }).relay, + ).toEqual({ + url: "https://relay.example.test", + backgroundUrl: "https://background.example.test", + }); + }); + + it("rejects invalid background configuration instead of silently using the primary relay", () => { + expect(() => + resolveCloudPublicConfig({ relay: { backgroundUrl: "http://background.example.test" } }), + ).toThrow(BackgroundRelayUrlInvalidError); + }); it("rejects an insecure relay URL", () => { expect( resolveCloudPublicConfig({ @@ -24,6 +43,7 @@ describe("resolveCloudPublicConfig", () => { }, relay: { url: null, + backgroundUrl: null, }, observability: { tracesUrl: null, diff --git a/apps/mobile/src/features/cloud/publicConfig.ts b/apps/mobile/src/features/cloud/publicConfig.ts index a91a8024d4851..73405584393b7 100644 --- a/apps/mobile/src/features/cloud/publicConfig.ts +++ b/apps/mobile/src/features/cloud/publicConfig.ts @@ -21,6 +21,7 @@ export interface CloudPublicConfig { }; readonly relay: { readonly url: string | null; + readonly backgroundUrl: string | null; }; readonly observability: { readonly tracesUrl: string | null; @@ -56,7 +57,22 @@ function normalizeSecureUrl(value: unknown): string | null { } } +export class BackgroundRelayUrlInvalidError extends Schema.TaggedError()( + "BackgroundRelayUrlInvalidError", + {}, +) { + override get message(): string { + return "T3CODE_BACKGROUND_RELAY_URL must be a secure absolute HTTPS origin."; + } +} + export function resolveCloudPublicConfig(extra: ExpoExtra = Constants.expoConfig?.extra) { + const rawBackgroundUrl = trimNonEmpty(extra?.relay?.backgroundUrl); + const backgroundUrl = + rawBackgroundUrl === null ? null : normalizeSecureRelayUrl(rawBackgroundUrl); + if (rawBackgroundUrl !== null && backgroundUrl === null) { + throw new BackgroundRelayUrlInvalidError(); + } return { clerk: { publishableKey: trimNonEmpty(extra?.clerk?.publishableKey), @@ -64,6 +80,7 @@ export function resolveCloudPublicConfig(extra: ExpoExtra = Constants.expoConfig }, relay: { url: normalizeSecureRelayUrl(trimNonEmpty(extra?.relay?.url) ?? ""), + backgroundUrl, }, observability: { tracesUrl: normalizeSecureUrl(extra?.observability?.tracesUrl), diff --git a/apps/server/src/relay/AgentAwarenessRelay.test.ts b/apps/server/src/relay/AgentAwarenessRelay.test.ts index 7f0e86bd6cf49..74ccfc2c39dc3 100644 --- a/apps/server/src/relay/AgentAwarenessRelay.test.ts +++ b/apps/server/src/relay/AgentAwarenessRelay.test.ts @@ -15,6 +15,7 @@ import { TurnItemId, } from "@t3tools/contracts"; import { RelayAgentActivityState } from "@t3tools/contracts/relay"; +import { BackgroundRelayConfig } from "./BackgroundRelayConfig.ts"; import * as DateTime from "effect/DateTime"; import * as Deferred from "effect/Deferred"; import * as Effect from "effect/Effect"; @@ -22,6 +23,7 @@ import * as Fiber from "effect/Fiber"; import * as Layer from "effect/Layer"; import * as Option from "effect/Option"; import * as Queue from "effect/Queue"; +import * as Redacted from "effect/Redacted"; import * as Ref from "effect/Ref"; import * as Schema from "effect/Schema"; import * as Stream from "effect/Stream"; @@ -97,6 +99,30 @@ const decodePublishPayload = Schema.decodeUnknownSync(Schema.fromJsonString(Publ const unused = () => Effect.die("Unexpected test dependency call"); describe("startup agent activity", () => { + it.effect("publishes only to the background relay and still honors the sharing switch", () => + Effect.gen(function* () { + const { relay, secrets, publications } = yield* makeTestRelay(); + yield* relay.publishThread(THREAD_ID); + assert.equal(publications.length, 1); + assert.isTrue(publications[0]?.url.startsWith("https://background.example.test/") ?? false); + assert.equal(publications[0]?.authorization, "Bearer background-credential"); + const publicCredential = yield* secrets.get(RELAY_ENVIRONMENT_CREDENTIAL_SECRET); + assert.equal( + Option.getOrNull(Option.map(publicCredential, (bytes) => new TextDecoder().decode(bytes))), + "credential-1", + ); + yield* secrets.set(PUBLISH_AGENT_ACTIVITY_SECRET, new TextEncoder().encode("false")); + yield* relay.publishThread(THREAD_ID); + assert.equal(publications.length, 1); + }).pipe( + Effect.provideService(BackgroundRelayConfig, { + url: "https://background.example.test", + issuer: "https://background.example.test", + environmentCredential: Redacted.make("background-credential"), + }), + ), + ); + it("publishes active work and only terminal runs completed after startup", () => { const startedAt = DateTime.toEpochMillis(DateTime.makeUnsafe(NOW)); const oldCompleted = ThreadId.make("old-completed"); diff --git a/apps/server/src/relay/AgentAwarenessRelay.ts b/apps/server/src/relay/AgentAwarenessRelay.ts index cbe7b35ef409e..4a64675983332 100644 --- a/apps/server/src/relay/AgentAwarenessRelay.ts +++ b/apps/server/src/relay/AgentAwarenessRelay.ts @@ -29,6 +29,7 @@ import * as Fiber from "effect/Fiber"; import * as Layer from "effect/Layer"; import * as Option from "effect/Option"; import * as Queue from "effect/Queue"; +import * as Redacted from "effect/Redacted"; import * as Ref from "effect/Ref"; import type * as Scope from "effect/Scope"; import * as Stream from "effect/Stream"; @@ -37,6 +38,7 @@ import * as HttpClient from "effect/http/HttpClient"; import * as HttpClientRequest from "effect/http/HttpClientRequest"; import * as HttpApiClient from "effect/http-api/HttpApiClient"; +import * as BackgroundRelayConfig from "./BackgroundRelayConfig.ts"; import * as ServerSecretStore from "../auth/ServerSecretStore.ts"; import { isAgentActivityPublishingEnabledValue, @@ -360,6 +362,7 @@ export function resolveAgentAwarenessRelayActiveThreadIds(input: { /** @public */ export const make = Effect.gen(function* () { + const backgroundRelay = yield* BackgroundRelayConfig.BackgroundRelayConfig; const secrets = yield* ServerSecretStore.ServerSecretStore; const serverEnvironment = yield* ServerEnvironment.ServerEnvironment; const threads = yield* ThreadManagement.ThreadManagementService; @@ -382,6 +385,12 @@ export const make = Effect.gen(function* () { ); const readRelayConfig = Effect.gen(function* () { + if (backgroundRelay) { + return { + ...backgroundRelay, + environmentCredential: Redacted.value(backgroundRelay.environmentCredential), + }; + } const [url, issuer, environmentCredential] = yield* Effect.all([ readSecretString(RELAY_URL_SECRET), readSecretString(RELAY_ISSUER_SECRET), @@ -847,4 +856,6 @@ export const make = Effect.gen(function* () { }); }); -export const layer = Layer.effect(AgentAwarenessRelay, make); +export const layer = Layer.effect(AgentAwarenessRelay, make).pipe( + Layer.provide(BackgroundRelayConfig.layer), +); diff --git a/apps/server/src/relay/BackgroundRelayConfig.test.ts b/apps/server/src/relay/BackgroundRelayConfig.test.ts new file mode 100644 index 0000000000000..b53439c9c32e8 --- /dev/null +++ b/apps/server/src/relay/BackgroundRelayConfig.test.ts @@ -0,0 +1,54 @@ +import { describe, expect, it } from "@effect/vitest"; +import * as ConfigProvider from "effect/ConfigProvider"; +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; +import * as Redacted from "effect/Redacted"; +import * as BackgroundRelayConfig from "./BackgroundRelayConfig.ts"; + +const load = (env: Record) => + Effect.service(BackgroundRelayConfig.BackgroundRelayConfig).pipe( + Effect.provide( + BackgroundRelayConfig.layer.pipe( + Layer.provide(ConfigProvider.layer(ConfigProvider.fromEnv({ env }))), + ), + ), + ); + +const valid = { + T3CODE_BACKGROUND_RELAY_URL: "https://background.example.test/", + T3CODE_BACKGROUND_RELAY_ENVIRONMENT_CREDENTIAL: "test-credential", +}; + +describe("BackgroundRelayConfig", () => { + it.effect("leaves existing relay configuration alone when unset", () => + Effect.gen(function* () { + expect(yield* load({})).toBeNull(); + }), + ); + it.effect("normalizes the origins and keeps the credential redacted", () => + Effect.gen(function* () { + const config = yield* load(valid); + expect(config?.url).toBe("https://background.example.test"); + expect(config?.issuer).toBe(config?.url); + expect(config && Redacted.value(config.environmentCredential)).toBe("test-credential"); + expect(JSON.stringify(config)).not.toContain("test-credential"); + const withIssuer = yield* load({ + ...valid, + T3CODE_BACKGROUND_RELAY_ISSUER: "https://issuer.example.test/", + }); + expect(withIssuer?.issuer).toBe("https://issuer.example.test"); + }), + ); + it.effect.each( + Object.entries({ + "URL without credential": { T3CODE_BACKGROUND_RELAY_URL: valid.T3CODE_BACKGROUND_RELAY_URL }, + "insecure URL": { ...valid, T3CODE_BACKGROUND_RELAY_URL: "http://background.example.test" }, + }), + )("rejects %s without disclosing credentials", ([_name, env]) => + Effect.gen(function* () { + const error = yield* load(env).pipe(Effect.flip); + expect(error._tag).toBe("BackgroundRelayConfigInvalid"); + expect(JSON.stringify(error)).not.toContain("test-credential"); + }), + ); +}); diff --git a/apps/server/src/relay/BackgroundRelayConfig.ts b/apps/server/src/relay/BackgroundRelayConfig.ts new file mode 100644 index 0000000000000..c5806a8c566de --- /dev/null +++ b/apps/server/src/relay/BackgroundRelayConfig.ts @@ -0,0 +1,53 @@ +import { normalizeSecureRelayUrl } from "@t3tools/shared/relayUrl"; +import * as Config from "effect/Config"; +import * as Context from "effect/Context"; +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; +import * as Option from "effect/Option"; +import * as Redacted from "effect/Redacted"; +import * as Schema from "effect/Schema"; + +export class BackgroundRelayConfigInvalid extends Schema.TaggedError()( + "BackgroundRelayConfigInvalid", + {}, +) { + override get message(): string { + return "Configure T3CODE_BACKGROUND_RELAY_URL and T3CODE_BACKGROUND_RELAY_ENVIRONMENT_CREDENTIAL together, using an HTTPS origin and a nonempty credential. The optional T3CODE_BACKGROUND_RELAY_ISSUER must also be an HTTPS origin."; + } +} + +export class BackgroundRelayConfig extends Context.Reference<{ + readonly url: string; + readonly issuer: string; + readonly environmentCredential: Redacted.Redacted; +} | null>("t3/relay/BackgroundRelayConfig", { defaultValue: () => null }) {} + +const make = Effect.gen(function* () { + const config = yield* Config.all({ + url: Config.String("T3CODE_BACKGROUND_RELAY_URL").pipe(Config.option), + issuer: Config.String("T3CODE_BACKGROUND_RELAY_ISSUER").pipe(Config.option), + environmentCredential: Config.Redacted("T3CODE_BACKGROUND_RELAY_ENVIRONMENT_CREDENTIAL").pipe( + Config.option, + ), + }); + if ( + Option.isNone(config.url) && + Option.isNone(config.environmentCredential) && + Option.isNone(config.issuer) + ) { + return null; + } + const url = Option.isSome(config.url) ? normalizeSecureRelayUrl(config.url.value) : null; + const issuer = Option.isSome(config.issuer) ? normalizeSecureRelayUrl(config.issuer.value) : url; + if ( + !url || + !issuer || + Option.isNone(config.environmentCredential) || + !Redacted.value(config.environmentCredential.value).trim() + ) { + return yield* new BackgroundRelayConfigInvalid(); + } + return { url, issuer, environmentCredential: config.environmentCredential.value }; +}); + +export const layer = Layer.effect(BackgroundRelayConfig, make); diff --git a/docs/operations/connect-setup.md b/docs/operations/connect-setup.md index 899d5c4a33a8e..820eb6dedd89f 100644 --- a/docs/operations/connect-setup.md +++ b/docs/operations/connect-setup.md @@ -34,6 +34,32 @@ Copy `infra/relay/.env.example` to `infra/relay/.env` for relay deployment setti Deploy `prod` before personal stages because it owns the retained database that their branches depend on. The stack's `PublishClientConfig` action writes the resulting relay URL back to the root `.env`. +## Separate background delivery relay + +A custom mobile build can send device listing/registration, Live Activity registration, and agent activity +snapshot requests to a different relay while keeping environment discovery, linking, and connections +on `T3CODE_RELAY_URL`. Set `T3CODE_BACKGROUND_RELAY_URL` to the background relay's HTTPS origin +before building the mobile app. Omit it to use the normal T3 Connect relay for everything. + +On each publishing server, supply these runtime environment variables through its service's secret +configuration, then restart the server: + +- `T3CODE_BACKGROUND_RELAY_URL`: the same background relay origin. +- `T3CODE_BACKGROUND_RELAY_ENVIRONMENT_CREDENTIAL`: that relay's environment credential. +- `T3CODE_BACKGROUND_RELAY_ISSUER`: optional relay issuer origin used as the publish proof's `aud` + claim; defaults to the background URL. The proof's `iss` remains `t3-env:`. + +The environment's existing agent activity publishing switch still controls sharing. A configured +background relay replaces the activity destination; it does not publish a second copy to T3 Connect. +Remove the runtime overrides and rebuild the mobile app without the build override to restore the +single-relay configuration. Existing T3 Connect credentials are not replaced. + +This only selects an already-provisioned relay. It must accept the mobile app's existing Clerk +identity, link the environment's signing key to that account, and have push credentials for the app's +bundle ID and signing environment. These options do not provision a deployment, migrate registrations, +or grant access to another relay's accounts. Register the app again after switching relays. Never put +the environment credential or APNs private key in the mobile build configuration. + ## CLI OAuth application In Clerk's OAuth applications settings: diff --git a/packages/client-runtime/src/relay/managedRelay.test.ts b/packages/client-runtime/src/relay/managedRelay.test.ts index 0506609229aab..9767701ff5787 100644 --- a/packages/client-runtime/src/relay/managedRelay.test.ts +++ b/packages/client-runtime/src/relay/managedRelay.test.ts @@ -16,6 +16,7 @@ function layerManagedRelayTest( fetchFn: typeof globalThis.fetch, relayUrl = "https://relay.example.test", accessTokenStore?: ManagedRelay.ManagedRelayAccessTokenStore, + backgroundRelayUrl?: string, ) { const layerHttpClient = RpcHttp.layerRemoteHttpClient(fetchFn); const layerSigner = Layer.succeed( @@ -29,6 +30,7 @@ function layerManagedRelayTest( return ManagedRelay.layer({ relayUrl, clientId: "t3-mobile", + ...(backgroundRelayUrl !== undefined ? { backgroundRelayUrl } : {}), ...(accessTokenStore ? { accessTokenStore } : {}), }).pipe(Layer.provide(layerSigner), Layer.provide(layerHttpClient)); } @@ -40,6 +42,124 @@ function clerkToken(subject: string, nonce: string): string { } describe("ManagedRelayClient", () => { + it.effect("routes background delivery separately and clears both caches", () => { + const requests: Array<{ url: string; authorization: string | null }> = []; + let exchanges = 0; + const fetchFn = ((input, init) => { + const url = String(input); + requests.push({ url, authorization: new Headers(init?.headers).get("authorization") }); + if (url.endsWith("/v1/client/dpop-token")) { + exchanges += 1; + const payload = new URLSearchParams( + init?.body instanceof Uint8Array + ? new TextDecoder().decode(init.body) + : String(init?.body), + ); + return Promise.resolve( + Response.json({ + access_token: `token:${new URL(url).origin}`, + issued_token_type: "urn:ietf:params:oauth:token-type:access_token", + token_type: "DPoP", + expires_in: 1800, + scope: payload.get("scope"), + }), + ); + } + if (url.endsWith("/status")) + return Promise.resolve( + Response.json({ + environmentId: "env-1", + endpoint: { + httpBaseUrl: "https://desktop.example.test/", + wsBaseUrl: "wss://desktop.example.test/ws", + providerKind: "cloudflare_tunnel", + }, + status: "offline", + checkedAt: "2026-06-05T20:00:00.000Z", + }), + ); + if (url.includes("agent-activity")) + return Promise.resolve(Response.json({ aggregate: null })); + if (url.endsWith("/environments")) + return Promise.resolve(Response.json({ environments: [] })); + if (url.endsWith("/devices") && init?.method === "GET") + return Promise.resolve(Response.json({ devices: [] })); + return Promise.resolve(Response.json({ ok: true })); + }) satisfies typeof globalThis.fetch; + const primaryUrl = "https://relay.example.test"; + const backgroundUrl = "https://background.example.test"; + return Effect.gen(function* () { + const client = yield* ManagedRelay.ManagedRelayClient; + const token = clerkToken("user-1", "session-1"); + const register = client.registerDevice({ + clerkToken: token, + payload: { + deviceId: "device-1", + label: "Phone", + platform: "ios", + iosMajorVersion: 18, + pushToken: "push-token", + preferences: { + liveActivitiesEnabled: true, + notificationsEnabled: true, + notifyOnApproval: true, + notifyOnInput: true, + notifyOnCompletion: true, + notifyOnFailure: true, + }, + }, + }); + const status = client.getEnvironmentStatus({ + clerkToken: token, + scopes: [RelayEnvironmentStatusScope], + environmentId: EnvironmentId.make("env-1"), + }); + yield* client.listEnvironments({ clerkToken: token }); + yield* status; + const primaryRequests = requests.length; + yield* client.listDevices({ clerkToken: token }); + yield* register; + yield* client.registerLiveActivity({ + clerkToken: token, + payload: { deviceId: "device-1", activityPushToken: "activity-token" }, + }); + yield* client.getAgentActivitySnapshot({ clerkToken: token }); + yield* client.unregisterDevice({ clerkToken: token, deviceId: "device-1" }); + expect(client.relayUrl).toBe(primaryUrl); + expect( + requests.slice(0, primaryRequests).every((request) => request.url.startsWith(primaryUrl)), + ).toBe(true); + expect( + requests.slice(primaryRequests).every((request) => request.url.startsWith(backgroundUrl)), + ).toBe(true); + for (const request of requests.filter( + (request) => + !request.url.endsWith("/dpop-token") && + !request.url.endsWith("/environments") && + !request.url.endsWith("/devices"), + )) { + expect(request.authorization).toBe(`DPoP token:${new URL(request.url).origin}`); + } + const cachedExchanges = exchanges; + yield* register; + yield* status; + expect(exchanges).toBe(cachedExchanges); + yield* client.resetTokenCache; + yield* register; + yield* status; + expect(exchanges).toBe(cachedExchanges + 2); + }).pipe( + Effect.provide( + layerManagedRelayTest( + fetchFn, + primaryUrl, + { load: Effect.succeed([]), save: () => Effect.void, clear: Effect.void }, + backgroundUrl, + ), + ), + ); + }); + it.effect("rejects unsafe relay URLs before sending credentials", () => { let requestCount = 0; const fetchFn = (() => { diff --git a/packages/client-runtime/src/relay/managedRelay.ts b/packages/client-runtime/src/relay/managedRelay.ts index 7c30ff640fb4c..e1ee963f5ed88 100644 --- a/packages/client-runtime/src/relay/managedRelay.ts +++ b/packages/client-runtime/src/relay/managedRelay.ts @@ -255,6 +255,7 @@ export interface ManagedRelayAuthorization { export interface ManagedRelayClientLayerOptions { readonly relayUrl: string; readonly clientId: RelayPublicClientId; + readonly backgroundRelayUrl?: string; readonly accessTokenStore?: ManagedRelayAccessTokenStore; } @@ -458,8 +459,7 @@ function disabledManagedRelayClient(relayUrl: string): ManagedRelayClient["Servi }); } -/** @public */ -export const make = Effect.fn("ManagedRelayClient.make")(function* ( +const makeSingleRelay = Effect.fn("ManagedRelayClient.make")(function* ( options: ManagedRelayClientLayerOptions, ) { const relayUrl = normalizeSecureRelayUrl(options.relayUrl); @@ -1000,5 +1000,33 @@ export const make = Effect.fn("ManagedRelayClient.make")(function* ( }); }); +/** @public */ +export const make = Effect.fn("ManagedRelayClient.makeWithBackgroundRelay")(function* ( + options: ManagedRelayClientLayerOptions, +) { + const primary = yield* makeSingleRelay(options); + if ( + options.backgroundRelayUrl === undefined || + normalizeSecureRelayUrl(options.backgroundRelayUrl) === primary.relayUrl + ) + return primary; + + // Background tokens stay in a separate cache; the primary persisted store belongs + // to T3 Connect. Invalid overrides fail at request time rather than falling back. + const background = yield* makeSingleRelay({ + relayUrl: options.backgroundRelayUrl, + clientId: options.clientId, + }); + return ManagedRelayClient.of({ + ...primary, + listDevices: background.listDevices, + registerDevice: background.registerDevice, + unregisterDevice: background.unregisterDevice, + registerLiveActivity: background.registerLiveActivity, + getAgentActivitySnapshot: background.getAgentActivitySnapshot, + resetTokenCache: primary.resetTokenCache.pipe(Effect.ensuring(background.resetTokenCache)), + }); +}); + export const layer = (options: ManagedRelayClientLayerOptions) => Layer.effect(ManagedRelayClient, make(options));