Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions apps/mobile/app.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
Expand Down
4 changes: 4 additions & 0 deletions apps/mobile/src/features/cloud/managedRelayLayer.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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)),
Expand Down
2 changes: 2 additions & 0 deletions apps/mobile/src/features/cloud/managedRelayLayer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down Expand Up @@ -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));
22 changes: 21 additions & 1 deletion apps/mobile/src/features/cloud/publicConfig.test.ts
Original file line number Diff line number Diff line change
@@ -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: {
Expand All @@ -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({
Expand All @@ -24,6 +43,7 @@ describe("resolveCloudPublicConfig", () => {
},
relay: {
url: null,
backgroundUrl: null,
},
observability: {
tracesUrl: null,
Expand Down
17 changes: 17 additions & 0 deletions apps/mobile/src/features/cloud/publicConfig.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ export interface CloudPublicConfig {
};
readonly relay: {
readonly url: string | null;
readonly backgroundUrl: string | null;
};
readonly observability: {
readonly tracesUrl: string | null;
Expand Down Expand Up @@ -56,14 +57,30 @@ function normalizeSecureUrl(value: unknown): string | null {
}
}

export class BackgroundRelayUrlInvalidError extends Schema.TaggedError<BackgroundRelayUrlInvalidError>()(
"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),
jwtTemplate: trimNonEmpty(extra?.clerk?.jwtTemplate),
},
relay: {
url: normalizeSecureRelayUrl(trimNonEmpty(extra?.relay?.url) ?? ""),
backgroundUrl,
},
observability: {
tracesUrl: normalizeSecureUrl(extra?.observability?.tracesUrl),
Expand Down
26 changes: 26 additions & 0 deletions apps/server/src/relay/AgentAwarenessRelay.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,13 +15,15 @@ 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";
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";
Expand Down Expand Up @@ -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");
Expand Down
13 changes: 12 additions & 1 deletion apps/server/src/relay/AgentAwarenessRelay.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand All @@ -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,
Expand Down Expand Up @@ -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;
Expand All @@ -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),
Expand Down Expand Up @@ -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),
);
54 changes: 54 additions & 0 deletions apps/server/src/relay/BackgroundRelayConfig.test.ts
Original file line number Diff line number Diff line change
@@ -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<string, string>) =>
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");
}),
);
});
53 changes: 53 additions & 0 deletions apps/server/src/relay/BackgroundRelayConfig.ts
Original file line number Diff line number Diff line change
@@ -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>()(
"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<string>;
} | 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);
26 changes: 26 additions & 0 deletions docs/operations/connect-setup.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:<environmentId>`.

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:
Expand Down
Loading