authorgravatar for julius0216@outlook.comJulius Marminge <julius0216@outlook.com> 2026-06-07 20:21:47-07:00
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-06-07 20:21:47-07:00
log0e4a43519fe4fa85ce60aa8d6d53c067e56d2fca
treea164e1a74db2ffc995ea8751fd3a69908c107345
parent3ea6adf17d5528d9c90ed53b6dead650d5931941
signature Signed by PGP key B5690EEEBB952194

[codex] Extract infrastructure, telemetry, and test tooling (#2994)

Co-authored-by: codex <codex@users.noreply.github.com>

34 files changed, 810 insertions(+), 113 deletions(-)

.env.example+5
...@@ -10,3 +10,8 @@...@@ -10,3 +10,8 @@
1010
11# Get this from your relay deployment. `infra/relay` deploys update it automatically.11# Get this from your relay deployment. `infra/relay` deploys update it automatically.
12# T3CODE_RELAY_URL=https://relay.example.com12# T3CODE_RELAY_URL=https://relay.example.com
13
14# Public, ingest-only mobile OpenTelemetry configuration.
15# T3CODE_MOBILE_OTLP_TRACES_URL=https://api.axiom.co/v1/traces
16# T3CODE_MOBILE_OTLP_TRACES_DATASET=t3-code-mobile-traces-dev
17# T3CODE_MOBILE_OTLP_TRACES_TOKEN=xaat-...
apps/mobile/app.config.ts+5
...@@ -161,6 +161,11 @@ const config: ExpoConfig = {...@@ -161,6 +161,11 @@ const config: ExpoConfig = {
161 publishableKey: repoEnv.EXPO_PUBLIC_CLERK_PUBLISHABLE_KEY ?? null,161 publishableKey: repoEnv.EXPO_PUBLIC_CLERK_PUBLISHABLE_KEY ?? null,
162 jwtTemplate: repoEnv.EXPO_PUBLIC_CLERK_JWT_TEMPLATE ?? null,162 jwtTemplate: repoEnv.EXPO_PUBLIC_CLERK_JWT_TEMPLATE ?? null,
163 },163 },
164 observability: {
165 tracesUrl: repoEnv.EXPO_PUBLIC_OTLP_TRACES_URL ?? "https://api.axiom.co/v1/traces",
166 tracesDataset: repoEnv.EXPO_PUBLIC_OTLP_TRACES_DATASET ?? null,
167 tracesToken: repoEnv.EXPO_PUBLIC_OTLP_TRACES_TOKEN ?? null,
168 },
164 eas: {169 eas: {
165 projectId: "d763fcb8-d37c-41ea-a773-b54a0ab4a454",170 projectId: "d763fcb8-d37c-41ea-a773-b54a0ab4a454",
166 },171 },
apps/mobile/src/features/agent-awareness/remoteRegistration.ts+1-1
...@@ -41,7 +41,7 @@ export function normalizeAgentAwarenessRelayBaseUrl(...@@ -41,7 +41,7 @@ export function normalizeAgentAwarenessRelayBaseUrl(
41}41}
4242
43function readRelayConfig(): { readonly url: string } | null {43function readRelayConfig(): { readonly url: string } | null {
44 const relayUrl = resolveCloudPublicConfig().relayUrl;44 const relayUrl = resolveCloudPublicConfig().relay.url;
45 if (!relayUrl) {45 if (!relayUrl) {
46 logRegistrationDebug("relay registration skipped; relay config missing");46 logRegistrationDebug("relay registration skipped; relay config missing");
47 return null;47 return null;
apps/mobile/src/features/cloud/CloudAuthProvider.tsx+3-1
...@@ -66,7 +66,9 @@ function CloudAuthBridge(props: { readonly children: ReactNode }) {...@@ -66,7 +66,9 @@ function CloudAuthBridge(props: { readonly children: ReactNode }) {
66}66}
6767
68export function CloudAuthProvider(props: { readonly children: ReactNode }) {68export function CloudAuthProvider(props: { readonly children: ReactNode }) {
69 const { clerkPublishableKey: publishableKey, relayUrl } = resolveCloudPublicConfig();69 const config = resolveCloudPublicConfig();
70 const publishableKey = config.clerk.publishableKey;
71 const relayUrl = config.relay.url;
7072
71 useEffect(() => {73 useEffect(() => {
72 if (!publishableKey || !relayUrl) {74 if (!publishableKey || !relayUrl) {
apps/mobile/src/features/cloud/linkEnvironment.ts+1-1
...@@ -50,7 +50,7 @@ export function normalizeRelayBaseUrl(value: string | null | undefined): string...@@ -50,7 +50,7 @@ export function normalizeRelayBaseUrl(value: string | null | undefined): string
50}50}
5151
52function readRelayUrl(): string | null {52function readRelayUrl(): string | null {
53 return resolveCloudPublicConfig().relayUrl;53 return resolveCloudPublicConfig().relay.url;
54}54}
5555
56export class CloudEnvironmentLinkError extends Data.TaggedError("CloudEnvironmentLinkError")<{56export class CloudEnvironmentLinkError extends Data.TaggedError("CloudEnvironmentLinkError")<{
apps/mobile/src/features/cloud/managedRelayState.ts+2-13
...@@ -1,7 +1,6 @@...@@ -1,7 +1,6 @@
1import { useAtomValue } from "@effect/atom-react";1import { useAtomValue } from "@effect/atom-react";
2import {2import {
3 createManagedRelayQueryManager,3 createManagedRelayQueryManager,
4 ManagedRelayClient,
5 managedRelaySessionAtom,4 managedRelaySessionAtom,
6 readManagedRelaySnapshotState,5 readManagedRelaySnapshotState,
7} from "@t3tools/client-runtime";6} from "@t3tools/client-runtime";
...@@ -9,23 +8,13 @@ import type {...@@ -9,23 +8,13 @@ import type {
9 RelayClientEnvironmentRecord,8 RelayClientEnvironmentRecord,
10 RelayEnvironmentStatusResponse,9 RelayEnvironmentStatusResponse,
11} from "@t3tools/contracts/relay";10} from "@t3tools/contracts/relay";
12import * as Context from "effect/Context";
13import * as Effect from "effect/Effect";
14import * as Layer from "effect/Layer";
15import { AsyncResult, Atom } from "effect/unstable/reactivity";11import { AsyncResult, Atom } from "effect/unstable/reactivity";
16import { useCallback } from "react";12import { useCallback } from "react";
1713
18import { mobileRuntime } from "../../lib/runtime";14import { mobileRuntimeContextLayer } from "../../lib/runtime";
19import { appAtomRegistry } from "../../state/atom-registry";15import { appAtomRegistry } from "../../state/atom-registry";
2016
21const managedRelayAtomRuntime = Atom.runtime(17const managedRelayAtomRuntime = Atom.runtime(mobileRuntimeContextLayer);
22 Layer.effect(
23 ManagedRelayClient,
24 mobileRuntime.contextEffect.pipe(
25 Effect.map((context) => Context.get(context, ManagedRelayClient)),
26 ),
27 ),
28);
2918
30export const managedRelayQueryManager = createManagedRelayQueryManager(managedRelayAtomRuntime);19export const managedRelayQueryManager = createManagedRelayQueryManager(managedRelayAtomRuntime);
3120
apps/mobile/src/features/cloud/publicConfig.test.ts+83-10
...@@ -1,6 +1,6 @@...@@ -1,6 +1,6 @@
1import { describe, expect, it, vi } from "vite-plus/test";1import { describe, expect, it, vi } from "vite-plus/test";
22
3import { resolveCloudPublicConfig } from "./publicConfig";3import { hasMobileTracingPublicConfig, resolveCloudPublicConfig } from "./publicConfig";
44
5vi.mock("expo-constants", () => ({5vi.mock("expo-constants", () => ({
6 default: {6 default: {
...@@ -13,9 +13,18 @@ vi.mock("expo-constants", () => ({...@@ -13,9 +13,18 @@ vi.mock("expo-constants", () => ({
13describe("resolveCloudPublicConfig", () => {13describe("resolveCloudPublicConfig", () => {
14 it("returns no cloud configuration for an unconfigured build", () => {14 it("returns no cloud configuration for an unconfigured build", () => {
15 expect(resolveCloudPublicConfig({})).toEqual({15 expect(resolveCloudPublicConfig({})).toEqual({
16 clerkPublishableKey: null,16 clerk: {
17 clerkJwtTemplate: null,17 publishableKey: null,
18 relayUrl: null,18 jwtTemplate: null,
19 },
20 relay: {
21 url: null,
22 },
23 observability: {
24 tracesUrl: null,
25 tracesDataset: null,
26 tracesToken: null,
27 },
19 });28 });
20 });29 });
2130
...@@ -24,11 +33,25 @@ describe("resolveCloudPublicConfig", () => {...@@ -24,11 +33,25 @@ describe("resolveCloudPublicConfig", () => {
24 resolveCloudPublicConfig({33 resolveCloudPublicConfig({
25 clerk: { publishableKey: " pk_test_example ", jwtTemplate: " t3-relay " },34 clerk: { publishableKey: " pk_test_example ", jwtTemplate: " t3-relay " },
26 relay: { url: " https://relay.example.test/// " },35 relay: { url: " https://relay.example.test/// " },
36 observability: {
37 tracesUrl: " https://api.axiom.co/v1/traces ",
38 tracesDataset: " mobile-traces ",
39 tracesToken: " public-ingest-token ",
40 },
27 }),41 }),
28 ).toEqual({42 ).toEqual({
29 clerkPublishableKey: "pk_test_example",43 clerk: {
30 clerkJwtTemplate: "t3-relay",44 publishableKey: "pk_test_example",
31 relayUrl: "https://relay.example.test",45 jwtTemplate: "t3-relay",
46 },
47 relay: {
48 url: "https://relay.example.test",
49 },
50 observability: {
51 tracesUrl: "https://api.axiom.co/v1/traces",
52 tracesDataset: "mobile-traces",
53 tracesToken: "public-ingest-token",
54 },
32 });55 });
33 });56 });
3457
...@@ -39,9 +62,59 @@ describe("resolveCloudPublicConfig", () => {...@@ -39,9 +62,59 @@ describe("resolveCloudPublicConfig", () => {
39 relay: { url: "http://relay.example.test" },62 relay: { url: "http://relay.example.test" },
40 }),63 }),
41 ).toEqual({64 ).toEqual({
42 clerkPublishableKey: "pk_test_example",65 clerk: {
43 clerkJwtTemplate: "t3-relay",66 publishableKey: "pk_test_example",
44 relayUrl: null,67 jwtTemplate: "t3-relay",
68 },
69 relay: {
70 url: null,
71 },
72 observability: {
73 tracesUrl: null,
74 tracesDataset: null,
75 tracesToken: null,
76 },
45 });77 });
46 });78 });
79
80 it("rejects an insecure traces URL", () => {
81 expect(
82 resolveCloudPublicConfig({
83 observability: {
84 tracesUrl: "http://api.axiom.co/v1/traces",
85 tracesDataset: "mobile-traces",
86 tracesToken: "public-ingest-token",
87 },
88 }).observability,
89 ).toEqual({
90 tracesUrl: null,
91 tracesDataset: "mobile-traces",
92 tracesToken: "public-ingest-token",
93 });
94 });
95
96 it("keeps tracing disabled unless every public tracing value is configured", () => {
97 expect(hasMobileTracingPublicConfig(resolveCloudPublicConfig({}))).toBe(false);
98 expect(
99 hasMobileTracingPublicConfig(
100 resolveCloudPublicConfig({
101 observability: {
102 tracesUrl: "https://api.axiom.co/v1/traces",
103 tracesDataset: "mobile-traces",
104 },
105 }),
106 ),
107 ).toBe(false);
108 expect(
109 hasMobileTracingPublicConfig(
110 resolveCloudPublicConfig({
111 observability: {
112 tracesUrl: "https://api.axiom.co/v1/traces",
113 tracesDataset: "mobile-traces",
114 tracesToken: "public-ingest-token",
115 },
116 }),
117 ),
118 ).toBe(true);
119 });
47});120});
apps/mobile/src/features/cloud/publicConfig.ts+69-17
...@@ -2,40 +2,92 @@ import Constants from "expo-constants";...@@ -2,40 +2,92 @@ import Constants from "expo-constants";
2import { relayClerkTokenOptions } from "@t3tools/shared/relayAuth";2import { relayClerkTokenOptions } from "@t3tools/shared/relayAuth";
3import { normalizeSecureRelayUrl } from "@t3tools/shared/relayUrl";3import { normalizeSecureRelayUrl } from "@t3tools/shared/relayUrl";
44
5type ExpoExtra = Readonly<Record<string, unknown>> | undefined;
6
7export interface CloudPublicConfig {5export interface CloudPublicConfig {
8 readonly clerkPublishableKey: string | null;6 readonly clerk: {
9 readonly clerkJwtTemplate: string | null;7 readonly publishableKey: string | null;
10 readonly relayUrl: string | null;8 readonly jwtTemplate: string | null;
9 };
10 readonly relay: {
11 readonly url: string | null;
12 };
13 readonly observability: {
14 readonly tracesUrl: string | null;
15 readonly tracesDataset: string | null;
16 readonly tracesToken: string | null;
17 };
11}18}
1219
20type UntrustedSection<T> = {
21 readonly [Key in keyof T]?: unknown;
22};
23
24type ExpoExtra =
25 | {
26 readonly [Section in keyof CloudPublicConfig]?: UntrustedSection<CloudPublicConfig[Section]>;
27 }
28 | undefined;
29
13function trimNonEmpty(value: unknown): string | null {30function trimNonEmpty(value: unknown): string | null {
14 return typeof value === "string" && value.trim() ? value.trim() : null;31 return typeof value === "string" && value.trim() ? value.trim() : null;
15}32}
1633
17export function resolveCloudPublicConfig(extra: ExpoExtra = Constants.expoConfig?.extra) {34function normalizeSecureUrl(value: unknown): string | null {
18 const clerk = extra?.clerk as35 const raw = trimNonEmpty(value);
19 | { readonly publishableKey?: unknown; readonly jwtTemplate?: unknown }36 if (raw === null) {
20 | undefined;37 return null;
21 const relay = extra?.relay as { readonly url?: unknown } | undefined;38 }
39 try {
40 const url = new URL(raw);
41 return url.protocol === "https:" ? url.toString() : null;
42 } catch {
43 return null;
44 }
45}
2246
47export function resolveCloudPublicConfig(extra: ExpoExtra = Constants.expoConfig?.extra) {
23 return {48 return {
24 clerkPublishableKey: trimNonEmpty(clerk?.publishableKey),49 clerk: {
25 clerkJwtTemplate: trimNonEmpty(clerk?.jwtTemplate),50 publishableKey: trimNonEmpty(extra?.clerk?.publishableKey),
26 relayUrl: normalizeSecureRelayUrl(trimNonEmpty(relay?.url) ?? ""),51 jwtTemplate: trimNonEmpty(extra?.clerk?.jwtTemplate),
52 },
53 relay: {
54 url: normalizeSecureRelayUrl(trimNonEmpty(extra?.relay?.url) ?? ""),
55 },
56 observability: {
57 tracesUrl: normalizeSecureUrl(extra?.observability?.tracesUrl),
58 tracesDataset: trimNonEmpty(extra?.observability?.tracesDataset),
59 tracesToken: trimNonEmpty(extra?.observability?.tracesToken),
60 },
27 } satisfies CloudPublicConfig;61 } satisfies CloudPublicConfig;
28}62}
2963
30export function hasCloudPublicConfig(): boolean {64export function hasCloudPublicConfig(): boolean {
31 const config = resolveCloudPublicConfig();65 const config = resolveCloudPublicConfig();
32 return Boolean(config.clerkPublishableKey && config.clerkJwtTemplate && config.relayUrl);66 return Boolean(config.clerk.publishableKey && config.clerk.jwtTemplate && config.relay.url);
67}
68
69type Configured<T> = {
70 readonly [Key in keyof T]: NonNullable<T[Key]>;
71};
72
73type MobileTracingPublicConfig = Omit<CloudPublicConfig, "observability"> & {
74 readonly observability: Configured<CloudPublicConfig["observability"]>;
75};
76
77export function hasMobileTracingPublicConfig(
78 config: CloudPublicConfig = resolveCloudPublicConfig(),
79): config is MobileTracingPublicConfig {
80 return Boolean(
81 config.observability.tracesUrl &&
82 config.observability.tracesDataset &&
83 config.observability.tracesToken,
84 );
33}85}
3486
35export function resolveRelayClerkTokenOptions() {87export function resolveRelayClerkTokenOptions() {
36 const { clerkJwtTemplate } = resolveCloudPublicConfig();88 const { jwtTemplate } = resolveCloudPublicConfig().clerk;
37 if (!clerkJwtTemplate) {89 if (!jwtTemplate) {
38 throw new Error("T3CODE_CLERK_JWT_TEMPLATE is not configured.");90 throw new Error("T3CODE_CLERK_JWT_TEMPLATE is not configured.");
39 }91 }
40 return relayClerkTokenOptions(clerkJwtTemplate);92 return relayClerkTokenOptions(jwtTemplate);
41}93}
apps/mobile/src/features/observability/mobileTracing.test.ts created+52
...@@ -0,0 +1,52 @@
1import { expect, it } from "@effect/vitest";
2import * as Effect from "effect/Effect";
3import * as Layer from "effect/Layer";
4import { vi } from "vite-plus/test";
5
6import { remoteHttpClientLayer } from "@t3tools/client-runtime";
7
8import { makeMobileTracingLayer } from "./mobileTracing";
9
10vi.mock("expo-constants", () => ({
11 default: {
12 expoConfig: {
13 extra: {},
14 },
15 },
16}));
17
18it.effect("exports spans through the scoped mobile OTLP layer", () => {
19 const fetchFn = vi.fn<typeof fetch>(async () => new Response(null, { status: 202 }));
20 const tracingLayer = makeMobileTracingLayer(
21 {
22 tracesUrl: "https://api.axiom.test/v1/traces",
23 tracesDataset: "mobile-traces",
24 tracesToken: "public-ingest-token",
25 },
26 {
27 appVariant: "test",
28 serviceVersion: "1.2.3",
29 },
30 ).pipe(Layer.provide(remoteHttpClientLayer(fetchFn)));
31 const tracedApplication = Layer.effectDiscard(
32 Effect.void.pipe(Effect.withSpan("mobile.test.span")),
33 ).pipe(Layer.provide(tracingLayer));
34
35 return Effect.gen(function* () {
36 yield* Layer.build(tracedApplication);
37
38 expect(fetchFn).not.toHaveBeenCalled();
39 }).pipe(
40 Effect.scoped,
41 Effect.andThen(
42 Effect.sync(() => {
43 expect(fetchFn).toHaveBeenCalledOnce();
44 const [url, init] = fetchFn.mock.calls[0]!;
45 expect(String(url)).toBe("https://api.axiom.test/v1/traces");
46 expect(new Headers(init?.headers).get("authorization")).toBe("Bearer public-ingest-token");
47 expect(new Headers(init?.headers).get("x-axiom-dataset")).toBe("mobile-traces");
48 expect(new TextDecoder().decode(init?.body as Uint8Array)).toContain("mobile.test.span");
49 }),
50 ),
51 );
52});
apps/mobile/src/features/observability/mobileTracing.ts created+60
...@@ -0,0 +1,60 @@
1import Constants from "expo-constants";
2import * as Layer from "effect/Layer";
3import type { HttpClient } from "effect/unstable/http";
4import { OtlpSerialization, OtlpTracer } from "effect/unstable/observability";
5
6import { hasMobileTracingPublicConfig, resolveCloudPublicConfig } from "../cloud/publicConfig";
7
8export interface MobileTracingConfig {
9 readonly tracesUrl: string;
10 readonly tracesDataset: string;
11 readonly tracesToken: string;
12}
13
14export interface MobileTracingResource {
15 readonly serviceVersion?: string;
16 readonly appVariant: string;
17}
18
19export function resolveMobileTracingConfig(): MobileTracingConfig | null {
20 const config = resolveCloudPublicConfig();
21 if (!hasMobileTracingPublicConfig(config)) {
22 return null;
23 }
24 const { tracesUrl, tracesDataset, tracesToken } = config.observability;
25 return { tracesUrl, tracesDataset, tracesToken };
26}
27
28export function makeMobileTracingLayer(
29 config: MobileTracingConfig | null,
30 resource: MobileTracingResource,
31): Layer.Layer<never, never, HttpClient.HttpClient> {
32 if (config === null) {
33 return Layer.empty;
34 }
35
36 return OtlpTracer.layer({
37 url: config.tracesUrl,
38 headers: {
39 Authorization: `Bearer ${config.tracesToken}`,
40 "X-Axiom-Dataset": config.tracesDataset,
41 },
42 resource: {
43 serviceName: "t3-mobile",
44 serviceVersion: resource.serviceVersion,
45 attributes: {
46 "service.runtime": "react-native",
47 "service.component": "mobile",
48 "deployment.environment.name": resource.appVariant,
49 },
50 },
51 }).pipe(Layer.provide(OtlpSerialization.layerJson));
52}
53
54export const mobileTracingLayer = makeMobileTracingLayer(resolveMobileTracingConfig(), {
55 serviceVersion: Constants.expoConfig?.version,
56 appVariant:
57 typeof Constants.expoConfig?.extra?.appVariant === "string"
58 ? Constants.expoConfig.extra.appVariant
59 : "unknown",
60});
apps/mobile/src/lib/runtime.ts+8-7
...@@ -6,19 +6,20 @@ import { remoteHttpClientLayer } from "@t3tools/client-runtime";...@@ -6,19 +6,20 @@ import { remoteHttpClientLayer } from "@t3tools/client-runtime";
6import { mobileCryptoLayer } from "../features/cloud/dpop";6import { mobileCryptoLayer } from "../features/cloud/dpop";
7import { mobileManagedRelayClientLayer } from "../features/cloud/managedRelayLayer";7import { mobileManagedRelayClientLayer } from "../features/cloud/managedRelayLayer";
8import { resolveCloudPublicConfig } from "../features/cloud/publicConfig";8import { resolveCloudPublicConfig } from "../features/cloud/publicConfig";
9import { mobileTracingLayer } from "../features/observability/mobileTracing";
910
10function configuredRelayUrl(): string {11function configuredRelayUrl(): string {
11 return resolveCloudPublicConfig().relayUrl ?? "http://relay.invalid";12 return resolveCloudPublicConfig().relay.url ?? "http://relay.invalid";
12}13}
1314
14const mobileHttpClientLayer = remoteHttpClientLayer(fetch);15const mobileHttpClientLayer = remoteHttpClientLayer(fetch);
1516
16export const mobileRuntime = ManagedRuntime.make(17export const mobileRuntime = ManagedRuntime.make(
17 Layer.mergeAll(18 mobileManagedRelayClientLayer(configuredRelayUrl()).pipe(
18 mobileHttpClientLayer,19 Layer.provideMerge(mobileCryptoLayer),
19 mobileCryptoLayer,20 Layer.provideMerge(mobileHttpClientLayer),
20 mobileManagedRelayClientLayer(configuredRelayUrl()).pipe(21 Layer.provideMerge(mobileTracingLayer.pipe(Layer.provide(mobileHttpClientLayer))),
21 Layer.provide(Layer.mergeAll(mobileHttpClientLayer, mobileCryptoLayer)),
22 ),
23 ),22 ),
24);23);
24
25export const mobileRuntimeContextLayer = Layer.effectContext(mobileRuntime.contextEffect);
apps/server/src/observability/Layers/Observability.ts+2
...@@ -1,3 +1,4 @@...@@ -1,3 +1,4 @@
1import { httpHeaderRedactionLayer } from "@t3tools/shared/httpObservability";
1import { makeLocalFileTracer, makeTraceSink } from "@t3tools/shared/observability";2import { makeLocalFileTracer, makeTraceSink } from "@t3tools/shared/observability";
2import * as Effect from "effect/Effect";3import * as Effect from "effect/Effect";
3import * as Layer from "effect/Layer";4import * as Layer from "effect/Layer";
...@@ -18,6 +19,7 @@ export const ObservabilityLive = Layer.unwrap(...@@ -18,6 +19,7 @@ export const ObservabilityLive = Layer.unwrap(
18 const traceReferencesLayer = Layer.mergeAll(19 const traceReferencesLayer = Layer.mergeAll(
19 Layer.succeed(Tracer.MinimumTraceLevel, config.traceMinLevel),20 Layer.succeed(Tracer.MinimumTraceLevel, config.traceMinLevel),
20 Layer.succeed(References.TracerTimingEnabled, config.traceTimingEnabled),21 Layer.succeed(References.TracerTimingEnabled, config.traceTimingEnabled),
22 httpHeaderRedactionLayer,
21 );23 );
2224
23 const tracerLayer = Layer.unwrap(25 const tracerLayer = Layer.unwrap(
apps/web/src/lib/runtime.ts+2
...@@ -4,6 +4,7 @@ import * as Layer from "effect/Layer";...@@ -4,6 +4,7 @@ import * as Layer from "effect/Layer";
4import { FetchHttpClient } from "effect/unstable/http";4import { FetchHttpClient } from "effect/unstable/http";
55
6import { remoteHttpClientLayer } from "@t3tools/client-runtime";6import { remoteHttpClientLayer } from "@t3tools/client-runtime";
7import { httpHeaderRedactionLayer } from "@t3tools/shared/httpObservability";
7import {8import {
8 PrimaryEnvironmentHttpClient,9 PrimaryEnvironmentHttpClient,
9 primaryEnvironmentHttpClientLive,10 primaryEnvironmentHttpClientLive,
...@@ -28,6 +29,7 @@ const primaryHttpRuntime = ManagedRuntime.make(...@@ -28,6 +29,7 @@ const primaryHttpRuntime = ManagedRuntime.make(
28 Layer.mergeAll(29 Layer.mergeAll(
29 remoteHttpClientLayer((input, init) => globalThis.fetch(input, init)),30 remoteHttpClientLayer((input, init) => globalThis.fetch(input, init)),
30 Layer.succeed(FetchHttpClient.RequestInit, primaryEnvironmentRequestInit),31 Layer.succeed(FetchHttpClient.RequestInit, primaryEnvironmentRequestInit),
32 httpHeaderRedactionLayer,
31 ),33 ),
32 ),34 ),
33 ),35 ),
infra/relay/alchemy.run.ts+5
...@@ -8,6 +8,7 @@ import * as Layer from "effect/Layer";...@@ -8,6 +8,7 @@ import * as Layer from "effect/Layer";
8import * as Planetscale from "alchemy/Planetscale";8import * as Planetscale from "alchemy/Planetscale";
99
10import { PlanetscaleDatabase, RelayHyperdrive } from "./src/db.ts";10import { PlanetscaleDatabase, RelayHyperdrive } from "./src/db.ts";
11import { RelayObservability } from "./src/observability.ts";
11import { ManagedEndpointZone, RelayApiZone } from "./src/zone.ts";12import { ManagedEndpointZone, RelayApiZone } from "./src/zone.ts";
12import Api from "./src/worker.ts";13import Api from "./src/worker.ts";
1314
...@@ -27,6 +28,7 @@ export default Alchemy.Stack(...@@ -27,6 +28,7 @@ export default Alchemy.Stack(
27 const hyperdrive = yield* RelayHyperdrive;28 const hyperdrive = yield* RelayHyperdrive;
28 const managedEndpointZone = yield* ManagedEndpointZone.pipe(Effect.orDie);29 const managedEndpointZone = yield* ManagedEndpointZone.pipe(Effect.orDie);
29 const relayApiZone = yield* RelayApiZone.pipe(Effect.orDie);30 const relayApiZone = yield* RelayApiZone.pipe(Effect.orDie);
31 const observability = yield* RelayObservability;
30 const api = yield* Api;32 const api = yield* Api;
3133
32 return {34 return {
...@@ -37,6 +39,9 @@ export default Alchemy.Stack(...@@ -37,6 +39,9 @@ export default Alchemy.Stack(
37 url: api.url,39 url: api.url,
38 relayApiZoneId: relayApiZone.zoneId,40 relayApiZoneId: relayApiZone.zoneId,
39 managedEndpointZoneId: managedEndpointZone.zoneId,41 managedEndpointZoneId: managedEndpointZone.zoneId,
42 mobileTracingUrl: observability.traces.otelTracesEndpoint,
43 mobileTracingDataset: observability.traces.name,
44 mobileTracingToken: observability.mobileIngestToken.token,
40 };45 };
41 }),46 }),
42);47);
infra/relay/scripts/deploy.test.ts+52-1
...@@ -1,6 +1,11 @@...@@ -1,6 +1,11 @@
1import { describe, expect, it } from "vite-plus/test";1import { describe, expect, it } from "vite-plus/test";
22
3import { hasDeployChanges, reconcileRootEnvRelayUrl, serializeGithubOutput } from "./deploy.ts";3import {
4 hasDeployChanges,
5 reconcileRootEnvPublicConfig,
6 reconcileRootEnvRelayUrl,
7 serializeGithubOutput,
8} from "./deploy.ts";
49
5describe("hasDeployChanges", () => {10describe("hasDeployChanges", () => {
6 it("detects resource, binding, and deletion changes", () => {11 it("detects resource, binding, and deletion changes", () => {
...@@ -51,6 +56,52 @@ describe("reconcileRootEnvRelayUrl", () => {...@@ -51,6 +56,52 @@ describe("reconcileRootEnvRelayUrl", () => {
51 });56 });
52});57});
5358
59describe("reconcileRootEnvPublicConfig", () => {
60 const config = {
61 relayUrl: "https://relay.example.test",
62 mobileTracingUrl: "https://api.axiom.co/v1/traces",
63 mobileTracingDataset: "t3-code-mobile-traces-dev",
64 mobileTracingToken: "xaat-public-ingest",
65 } as const;
66
67 it("adds the complete local client config", () => {
68 expect(reconcileRootEnvPublicConfig("", config)).toBe(
69 [
70 "T3CODE_RELAY_URL=https://relay.example.test",
71 "T3CODE_MOBILE_OTLP_TRACES_URL=https://api.axiom.co/v1/traces",
72 "T3CODE_MOBILE_OTLP_TRACES_DATASET=t3-code-mobile-traces-dev",
73 "T3CODE_MOBILE_OTLP_TRACES_TOKEN=xaat-public-ingest",
74 "",
75 ].join("\n"),
76 );
77 });
78
79 it("replaces stale values while preserving unrelated entries", () => {
80 expect(
81 reconcileRootEnvPublicConfig(
82 [
83 "T3CODE_CLERK_PUBLISHABLE_KEY=pk_test_example",
84 "T3CODE_RELAY_URL=https://old.example.test",
85 "T3CODE_MOBILE_OTLP_TRACES_URL=https://old.example.test/v1/traces",
86 "T3CODE_MOBILE_OTLP_TRACES_DATASET=old-dataset",
87 "T3CODE_MOBILE_OTLP_TRACES_TOKEN=old-token",
88 "",
89 ].join("\n"),
90 config,
91 ),
92 ).toBe(
93 [
94 "T3CODE_CLERK_PUBLISHABLE_KEY=pk_test_example",
95 "T3CODE_RELAY_URL=https://relay.example.test",
96 "T3CODE_MOBILE_OTLP_TRACES_URL=https://api.axiom.co/v1/traces",
97 "T3CODE_MOBILE_OTLP_TRACES_DATASET=t3-code-mobile-traces-dev",
98 "T3CODE_MOBILE_OTLP_TRACES_TOKEN=xaat-public-ingest",
99 "",
100 ].join("\n"),
101 );
102 });
103});
104
54describe("serializeGithubOutput", () => {105describe("serializeGithubOutput", () => {
55 it("serializes relay deploy metadata for GitHub Actions outputs", () => {106 it("serializes relay deploy metadata for GitHub Actions outputs", () => {
56 expect(107 expect(
infra/relay/scripts/deploy.ts+69-20
...@@ -23,6 +23,7 @@ import * as FileSystem from "effect/FileSystem";...@@ -23,6 +23,7 @@ import * as FileSystem from "effect/FileSystem";
23import * as Layer from "effect/Layer";23import * as Layer from "effect/Layer";
24import * as Option from "effect/Option";24import * as Option from "effect/Option";
25import * as Path from "effect/Path";25import * as Path from "effect/Path";
26import * as Redacted from "effect/Redacted";
26import { Command, Flag, Prompt } from "effect/unstable/cli";27import { Command, Flag, Prompt } from "effect/unstable/cli";
27import * as FetchHttpClient from "effect/unstable/http/FetchHttpClient";28import * as FetchHttpClient from "effect/unstable/http/FetchHttpClient";
2829
...@@ -42,15 +43,49 @@ export interface RelayDeployOptions {...@@ -42,15 +43,49 @@ export interface RelayDeployOptions {
42 readonly githubOutput: boolean;43 readonly githubOutput: boolean;
43}44}
4445
45export function reconcileRootEnvRelayUrl(contents: string, relayUrl: string): string {46export interface RelayPublicConfig {
46 const entry = `T3CODE_RELAY_URL=${relayUrl}`;47 readonly relayUrl: string;
47 if (/^T3CODE_RELAY_URL=.*$/mu.test(contents)) {48 readonly mobileTracingUrl: string;
48 return contents.replace(/^T3CODE_RELAY_URL=.*$/mu, entry);49 readonly mobileTracingDataset: string;
49 }50 readonly mobileTracingToken: string;
50 if (!contents) {51}
51 return `${entry}\n`;52
53const publicConfigEnvEntries = (config: RelayPublicConfig) =>
54 ({
55 T3CODE_RELAY_URL: config.relayUrl,
56 T3CODE_MOBILE_OTLP_TRACES_URL: config.mobileTracingUrl,
57 T3CODE_MOBILE_OTLP_TRACES_DATASET: config.mobileTracingDataset,
58 T3CODE_MOBILE_OTLP_TRACES_TOKEN: config.mobileTracingToken,
59 }) as const;
60
61export function reconcileRootEnvPublicConfig(contents: string, config: RelayPublicConfig): string {
62 let next = contents;
63 for (const [name, value] of Object.entries(publicConfigEnvEntries(config))) {
64 const entry = `${name}=${value}`;
65 const pattern = new RegExp(`^${name}=.*$`, "mu");
66 if (pattern.test(next)) {
67 next = next.replace(pattern, entry);
68 continue;
69 }
70 if (!next) {
71 next = `${entry}\n`;
72 continue;
73 }
74 next = `${next}${next.endsWith("\n") ? "" : "\n"}${entry}\n`;
52 }75 }
53 return `${contents}${contents.endsWith("\n") ? "" : "\n"}${entry}\n`;76 return next;
77}
78
79export function reconcileRootEnvRelayUrl(contents: string, relayUrl: string): string {
80 return reconcileRootEnvPublicConfig(contents, {
81 relayUrl,
82 mobileTracingUrl: "",
83 mobileTracingDataset: "",
84 mobileTracingToken: "",
85 })
86 .split("\n")
87 .filter((line) => !line.startsWith("T3CODE_MOBILE_OTLP_TRACES_"))
88 .join("\n");
54}89}
5590
56export function hasDeployChanges(plan: Plan.Plan): boolean {91export function hasDeployChanges(plan: Plan.Plan): boolean {
...@@ -68,7 +103,7 @@ export type RelayDeployResult = "applied" | "noop" | "dry-run" | "cancelled";...@@ -68,7 +103,7 @@ export type RelayDeployResult = "applied" | "noop" | "dry-run" | "cancelled";
68export interface RelayDeployOutcome {103export interface RelayDeployOutcome {
69 readonly result: RelayDeployResult;104 readonly result: RelayDeployResult;
70 readonly changed: boolean;105 readonly changed: boolean;
71 readonly relayUrl: Option.Option<string>;106 readonly publicConfig: Option.Option<RelayPublicConfig>;
72}107}
73108
74export function serializeGithubOutput(entries: Readonly<Record<string, string | boolean>>): string {109export function serializeGithubOutput(entries: Readonly<Record<string, string | boolean>>): string {
...@@ -106,15 +141,17 @@ const relayDeployStage = Config.nonEmptyString("stage").pipe(...@@ -106,15 +141,17 @@ const relayDeployStage = Config.nonEmptyString("stage").pipe(
106 ),141 ),
107);142);
108143
109const reconcileRootEnv = Effect.fn("relay.deploy.reconcileRootEnv")(function* (relayUrl: string) {144const reconcileRootEnv = Effect.fn("relay.deploy.reconcileRootEnv")(function* (
145 config: RelayPublicConfig,
146) {
110 const fs = yield* FileSystem.FileSystem;147 const fs = yield* FileSystem.FileSystem;
111 const path = yield* Path.Path;148 const path = yield* Path.Path;
112 const root = yield* repoRoot;149 const root = yield* repoRoot;
113 const rootEnvPath = path.join(root, ".env");150 const rootEnvPath = path.join(root, ".env");
114 const contents = (yield* fs.exists(rootEnvPath)) ? yield* fs.readFileString(rootEnvPath) : "";151 const contents = (yield* fs.exists(rootEnvPath)) ? yield* fs.readFileString(rootEnvPath) : "";
115152
116 yield* fs.writeFileString(rootEnvPath, reconcileRootEnvRelayUrl(contents, relayUrl));153 yield* fs.writeFileString(rootEnvPath, reconcileRootEnvPublicConfig(contents, config));
117 yield* Console.log(`Updated ${rootEnvPath} with T3CODE_RELAY_URL=${relayUrl}`);154 yield* Console.log(`Updated ${rootEnvPath} with relay URL and mobile tracing configuration`);
118});155});
119156
120const writeGithubOutput = Effect.fn("relay.deploy.writeGithubOutput")(function* (157const writeGithubOutput = Effect.fn("relay.deploy.writeGithubOutput")(function* (
...@@ -127,7 +164,9 @@ const writeGithubOutput = Effect.fn("relay.deploy.writeGithubOutput")(function*...@@ -127,7 +164,9 @@ const writeGithubOutput = Effect.fn("relay.deploy.writeGithubOutput")(function*
127 serializeGithubOutput({164 serializeGithubOutput({
128 changed: outcome.changed,165 changed: outcome.changed,
129 result: outcome.result,166 result: outcome.result,
130 ...(Option.isSome(outcome.relayUrl) ? { relay_url: outcome.relayUrl.value } : {}),167 ...(Option.isSome(outcome.publicConfig)
168 ? { relay_url: outcome.publicConfig.value.relayUrl }
169 : {}),
131 }),170 }),
132 { flag: "a" },171 { flag: "a" },
133 );172 );
...@@ -160,7 +199,7 @@ const runRelayDeploy = Effect.fn("relay.deploy.run")(...@@ -160,7 +199,7 @@ const runRelayDeploy = Effect.fn("relay.deploy.run")(
160 return {199 return {
161 result: "dry-run",200 result: "dry-run",
162 changed,201 changed,
163 relayUrl: Option.none<string>(),202 publicConfig: Option.none<RelayPublicConfig>(),
164 } satisfies RelayDeployOutcome;203 } satisfies RelayDeployOutcome;
165 }204 }
166 if (!options.yes && changed) {205 if (!options.yes && changed) {
...@@ -175,20 +214,30 @@ const runRelayDeploy = Effect.fn("relay.deploy.run")(...@@ -175,20 +214,30 @@ const runRelayDeploy = Effect.fn("relay.deploy.run")(
175 return {214 return {
176 result: "cancelled",215 result: "cancelled",
177 changed,216 changed,
178 relayUrl: Option.none<string>(),217 publicConfig: Option.none<RelayPublicConfig>(),
179 } satisfies RelayDeployOutcome;218 } satisfies RelayDeployOutcome;
180 }219 }
181 }220 }
182 const output = yield* Apply.apply(plan).pipe(Effect.provide(stack.services));221 const output = yield* Apply.apply(plan).pipe(Effect.provide(stack.services));
183 if (output.url === undefined) {222 if (
223 output.url === undefined ||
224 output.mobileTracingUrl === undefined ||
225 output.mobileTracingDataset === undefined ||
226 output.mobileTracingToken === undefined
227 ) {
184 return yield* new RelayDeployError({228 return yield* new RelayDeployError({
185 message: "Alchemy relay deploy output did not include a URL",229 message: "Alchemy relay deploy output did not include complete public client config",
186 });230 });
187 }231 }
188 return {232 return {
189 result: changed ? "applied" : "noop",233 result: changed ? "applied" : "noop",
190 changed,234 changed,
191 relayUrl: Option.some(output.url),235 publicConfig: Option.some({
236 relayUrl: output.url,
237 mobileTracingUrl: output.mobileTracingUrl,
238 mobileTracingDataset: output.mobileTracingDataset,
239 mobileTracingToken: Redacted.value(output.mobileTracingToken),
240 }),
192 } satisfies RelayDeployOutcome;241 } satisfies RelayDeployOutcome;
193 },242 },
194 (effect, options, configProvider, stage) =>243 (effect, options, configProvider, stage) =>
...@@ -216,8 +265,8 @@ export const deploy = Effect.fn("relay.deploy")(function* (options: RelayDeployO...@@ -216,8 +265,8 @@ export const deploy = Effect.fn("relay.deploy")(function* (options: RelayDeployO
216 );265 );
217 const stage = Option.getOrElse(options.stage, () => configuredStage);266 const stage = Option.getOrElse(options.stage, () => configuredStage);
218 const outcome = yield* runRelayDeploy(options, configProvider, stage);267 const outcome = yield* runRelayDeploy(options, configProvider, stage);
219 if (Option.isSome(outcome.relayUrl)) {268 if (Option.isSome(outcome.publicConfig)) {
220 yield* reconcileRootEnv(outcome.relayUrl.value);269 yield* reconcileRootEnv(outcome.publicConfig.value);
221 }270 }
222 if (options.githubOutput) {271 if (options.githubOutput) {
223 yield* writeGithubOutput(outcome);272 yield* writeGithubOutput(outcome);
infra/relay/src/agentActivity/AgentActivityPublisher.ts+7-2
...@@ -101,8 +101,13 @@ const make = Effect.gen(function* () {...@@ -101,8 +101,13 @@ const make = Effect.gen(function* () {
101 "relay.mobile.device_id": input.deviceId,101 "relay.mobile.device_id": input.deviceId,
102 "relay.operation": "replayForLiveActivityRegistration",102 "relay.operation": "replayForLiveActivityRegistration",
103 });103 });
104 const activeStates = yield* rows.listForUser({ userId: input.userId });104 const { activeStates, targets } = yield* Effect.all(
105 const targets = yield* liveActivities.listTargets({ userId: input.userId });105 {
106 activeStates: rows.listForUser({ userId: input.userId }),
107 targets: liveActivities.listTargets({ userId: input.userId }),
108 },
109 { concurrency: 2 },
110 );
106 const target = targets.find((row) => row.device_id === input.deviceId) ?? null;111 const target = targets.find((row) => row.device_id === input.deviceId) ?? null;
107 if (target === null) {112 if (target === null) {
108 return null;113 return null;
infra/relay/src/db.ts+1-1
...@@ -63,6 +63,6 @@ export const RelayHyperdrive = Effect.gen(function* () {...@@ -63,6 +63,6 @@ export const RelayHyperdrive = Effect.gen(function* () {
63 caching: {63 caching: {
64 disabled: true,64 disabled: true,
65 },65 },
66 originConnectionLimit: 5,66 originConnectionLimit: 20,
67 });67 });
68});68});
infra/relay/src/environments/EnvironmentConnector.test.ts+54
...@@ -14,11 +14,13 @@ import {...@@ -14,11 +14,13 @@ import {
14import { describe, expect, it } from "@effect/vitest";14import { describe, expect, it } from "@effect/vitest";
15import * as DateTime from "effect/DateTime";15import * as DateTime from "effect/DateTime";
16import { RELAY_HEALTH_RESPONSE_TYP, RELAY_MINT_RESPONSE_TYP } from "@t3tools/shared/relayJwt";16import { RELAY_HEALTH_RESPONSE_TYP, RELAY_MINT_RESPONSE_TYP } from "@t3tools/shared/relayJwt";
17import * as Deferred from "effect/Deferred";
17import * as Duration from "effect/Duration";18import * as Duration from "effect/Duration";
18import * as Effect from "effect/Effect";19import * as Effect from "effect/Effect";
19import * as Fiber from "effect/Fiber";20import * as Fiber from "effect/Fiber";
20import * as Layer from "effect/Layer";21import * as Layer from "effect/Layer";
21import * as Redacted from "effect/Redacted";22import * as Redacted from "effect/Redacted";
23import * as Ref from "effect/Ref";
22import * as Result from "effect/Result";24import * as Result from "effect/Result";
23import * as Schema from "effect/Schema";25import * as Schema from "effect/Schema";
24import * as TestClock from "effect/testing/TestClock";26import * as TestClock from "effect/testing/TestClock";
...@@ -225,6 +227,58 @@ function makeLinks(...@@ -225,6 +227,58 @@ function makeLinks(
225}227}
226228
227describe("EnvironmentConnector", () => {229describe("EnvironmentConnector", () => {
230 it.effect("loads the environment link and managed allocation concurrently", () =>
231 Effect.gen(function* () {
232 const started = yield* Ref.make(0);
233 const bothStarted = yield* Deferred.make<void>();
234 const release = yield* Deferred.make<void>();
235 const waitForPeer = Effect.gen(function* () {
236 const count = yield* Ref.updateAndGet(started, (value) => value + 1);
237 if (count === 2) {
238 yield* Deferred.succeed(bothStarted, undefined);
239 }
240 yield* Deferred.await(release);
241 });
242 const links = makeLinks();
243 const allocations = makeAllocations();
244 const execute = (request: HttpClientRequest.HttpClientRequest) =>
245 Effect.sync(() => {
246 const healthRequest = decodeHealthRequestBody(requestBodyText(request));
247 return HttpClientResponse.fromWeb(
248 request,
249 Response.json(signHealthResponse(healthRequest), { status: 200 }),
250 );
251 });
252 const status = Effect.gen(function* () {
253 const connector = yield* EnvironmentConnector.EnvironmentConnector;
254 return yield* connector.status({
255 userId: "user_123",
256 environmentId: "env-connector-test" as never,
257 });
258 }).pipe(
259 Effect.provide(
260 connectorTestLayer(execute, {
261 links: {
262 ...links,
263 getForUser: (input) => waitForPeer.pipe(Effect.andThen(links.getForUser(input))),
264 },
265 allocations: {
266 ...allocations,
267 get: (input) => waitForPeer.pipe(Effect.andThen(allocations.get(input))),
268 },
269 }),
270 ),
271 );
272
273 const fiber = yield* Effect.forkChild(status);
274 yield* Deferred.await(bothStarted);
275 yield* Deferred.succeed(release, undefined);
276 yield* Fiber.join(fiber);
277
278 expect(yield* Ref.get(started)).toBe(2);
279 }),
280 );
281
228 it.effect("checks linked environment health through the managed endpoint", () => {282 it.effect("checks linked environment health through the managed endpoint", () => {
229 const seenUrls: Array<string> = [];283 const seenUrls: Array<string> = [];
230 const seenProofs: Array<RelayCloudEnvironmentHealthProofPayload> = [];284 const seenProofs: Array<RelayCloudEnvironmentHealthProofPayload> = [];
infra/relay/src/environments/EnvironmentConnector.ts+29-19
...@@ -295,9 +295,9 @@ const make = Effect.gen(function* () {...@@ -295,9 +295,9 @@ const make = Effect.gen(function* () {
295 );295 );
296 const resolveManagedEndpoint = Effect.fn("relay.environment_connector.resolve_managed_endpoint")(296 const resolveManagedEndpoint = Effect.fn("relay.environment_connector.resolve_managed_endpoint")(
297 function* (input: {297 function* (input: {
298 readonly userId: string;
299 readonly operation: "connect" | "status";298 readonly operation: "connect" | "status";
300 readonly link: EnvironmentLinks.RelayLinkedEnvironmentRecord;299 readonly link: EnvironmentLinks.RelayLinkedEnvironmentRecord;
300 readonly allocation: ManagedEndpointAllocations.ManagedEndpointAllocation | null;
301 }) {301 }) {
302 if (input.link.endpoint.providerKind !== "cloudflare_tunnel") {302 if (input.link.endpoint.providerKind !== "cloudflare_tunnel") {
303 yield* Effect.annotateCurrentSpan({303 yield* Effect.annotateCurrentSpan({
...@@ -309,11 +309,7 @@ const make = Effect.gen(function* () {...@@ -309,11 +309,7 @@ const make = Effect.gen(function* () {
309 reason: "endpoint_provider_not_managed",309 reason: "endpoint_provider_not_managed",
310 });310 });
311 }311 }
312 const allocation = yield* allocations.get({312 if (!input.allocation) {
313 userId: input.userId,
314 environmentId: input.link.environmentId,
315 });
316 if (!allocation) {
317 return yield* new EnvironmentConnectNotAuthorized({313 return yield* new EnvironmentConnectNotAuthorized({
318 environmentId: input.link.environmentId,314 environmentId: input.link.environmentId,
319 operation: input.operation,315 operation: input.operation,
...@@ -321,10 +317,10 @@ const make = Effect.gen(function* () {...@@ -321,10 +317,10 @@ const make = Effect.gen(function* () {
321 });317 });
322 }318 }
323 const allocationAttributes = {319 const allocationAttributes = {
324 "relay.authorization.allocation_hostname": allocation.hostname,320 "relay.authorization.allocation_hostname": input.allocation.hostname,
325 "relay.authorization.allocation_has_ready_at": allocation.readyAt !== null,321 "relay.authorization.allocation_has_ready_at": input.allocation.readyAt !== null,
326 "relay.authorization.allocation_has_tunnel_id": allocation.tunnelId !== null,322 "relay.authorization.allocation_has_tunnel_id": input.allocation.tunnelId !== null,
327 "relay.authorization.allocation_has_dns_record_id": allocation.dnsRecordId !== null,323 "relay.authorization.allocation_has_dns_record_id": input.allocation.dnsRecordId !== null,
328 } as const;324 } as const;
329 if (!settings.managedEndpointBaseDomain) {325 if (!settings.managedEndpointBaseDomain) {
330 yield* Effect.annotateCurrentSpan(allocationAttributes);326 yield* Effect.annotateCurrentSpan(allocationAttributes);
...@@ -335,9 +331,9 @@ const make = Effect.gen(function* () {...@@ -335,9 +331,9 @@ const make = Effect.gen(function* () {
335 });331 });
336 }332 }
337 if (333 if (
338 allocation.readyAt === null ||334 input.allocation.readyAt === null ||
339 allocation.tunnelId === null ||335 input.allocation.tunnelId === null ||
340 allocation.dnsRecordId === null336 input.allocation.dnsRecordId === null
341 ) {337 ) {
342 yield* Effect.annotateCurrentSpan(allocationAttributes);338 yield* Effect.annotateCurrentSpan(allocationAttributes);
343 return yield* new EnvironmentConnectNotAuthorized({339 return yield* new EnvironmentConnectNotAuthorized({
...@@ -346,7 +342,9 @@ const make = Effect.gen(function* () {...@@ -346,7 +342,9 @@ const make = Effect.gen(function* () {
346 reason: "managed_endpoint_allocation_not_ready",342 reason: "managed_endpoint_allocation_not_ready",
347 });343 });
348 }344 }
349 if (!isManagedEndpointHostname(allocation.hostname, settings.managedEndpointBaseDomain)) {345 if (
346 !isManagedEndpointHostname(input.allocation.hostname, settings.managedEndpointBaseDomain)
347 ) {
350 yield* Effect.annotateCurrentSpan({348 yield* Effect.annotateCurrentSpan({
351 ...allocationAttributes,349 ...allocationAttributes,
352 "relay.authorization.managed_endpoint_base_domain": settings.managedEndpointBaseDomain,350 "relay.authorization.managed_endpoint_base_domain": settings.managedEndpointBaseDomain,
...@@ -358,7 +356,7 @@ const make = Effect.gen(function* () {...@@ -358,7 +356,7 @@ const make = Effect.gen(function* () {
358 });356 });
359 }357 }
360 const endpoint = ManagedEndpointAllocations.resolveReadyManagedEndpoint({358 const endpoint = ManagedEndpointAllocations.resolveReadyManagedEndpoint({
361 allocation,359 allocation: input.allocation,
362 baseDomain: settings.managedEndpointBaseDomain,360 baseDomain: settings.managedEndpointBaseDomain,
363 });361 });
364 if (362 if (
...@@ -393,7 +391,13 @@ const make = Effect.gen(function* () {...@@ -393,7 +391,13 @@ const make = Effect.gen(function* () {
393 "relay.environment_id": input.environmentId,391 "relay.environment_id": input.environmentId,
394 "relay.operation": "status",392 "relay.operation": "status",
395 });393 });
396 const link = yield* links.getForUser(input);394 const { link, allocation } = yield* Effect.all(
395 {
396 link: links.getForUser(input),
397 allocation: allocations.get(input),
398 },
399 { concurrency: 2 },
400 );
397 if (!link) {401 if (!link) {
398 return yield* new EnvironmentConnectNotAuthorized({402 return yield* new EnvironmentConnectNotAuthorized({
399 environmentId: input.environmentId,403 environmentId: input.environmentId,
...@@ -402,9 +406,9 @@ const make = Effect.gen(function* () {...@@ -402,9 +406,9 @@ const make = Effect.gen(function* () {
402 });406 });
403 }407 }
404 const endpoint = yield* resolveManagedEndpoint({408 const endpoint = yield* resolveManagedEndpoint({
405 userId: input.userId,
406 operation: "status",409 operation: "status",
407 link,410 link,
411 allocation,
408 });412 });
409 const now = yield* DateTime.now;413 const now = yield* DateTime.now;
410 const expiresAt = DateTime.add(now, { minutes: 2 });414 const expiresAt = DateTime.add(now, { minutes: 2 });
...@@ -518,7 +522,13 @@ const make = Effect.gen(function* () {...@@ -518,7 +522,13 @@ const make = Effect.gen(function* () {
518 reason: "client_proof_key_thumbprint_missing",522 reason: "client_proof_key_thumbprint_missing",
519 });523 });
520 }524 }
521 const link = yield* links.getForUser(input);525 const { link, allocation } = yield* Effect.all(
526 {
527 link: links.getForUser(input),
528 allocation: allocations.get(input),
529 },
530 { concurrency: 2 },
531 );
522 if (!link) {532 if (!link) {
523 return yield* new EnvironmentConnectNotAuthorized({533 return yield* new EnvironmentConnectNotAuthorized({
524 environmentId: input.environmentId,534 environmentId: input.environmentId,
...@@ -527,9 +537,9 @@ const make = Effect.gen(function* () {...@@ -527,9 +537,9 @@ const make = Effect.gen(function* () {
527 });537 });
528 }538 }
529 const endpoint = yield* resolveManagedEndpoint({539 const endpoint = yield* resolveManagedEndpoint({
530 userId: input.userId,
531 operation: "connect",540 operation: "connect",
532 link,541 link,
542 allocation,
533 });543 });
534 const now = yield* DateTime.now;544 const now = yield* DateTime.now;
535 const expiresAt = DateTime.add(now, { minutes: 2 });545 const expiresAt = DateTime.add(now, { minutes: 2 });
infra/relay/src/http/Api.test.ts+6
...@@ -181,6 +181,10 @@ describe("relay request tracing", () => {...@@ -181,6 +181,10 @@ describe("relay request tracing", () => {
181 const request = HttpServerRequest.fromWeb(181 const request = HttpServerRequest.fromWeb(
182 new Request("https://relay.test/v1/mobile/devices?client=mobile", {182 new Request("https://relay.test/v1/mobile/devices?client=mobile", {
183 method: "POST",183 method: "POST",
184 headers: {
185 authorization: "Bearer secret",
186 dpop: "signed-proof",
187 },
184 }),188 }),
185 );189 );
186190
...@@ -192,6 +196,8 @@ describe("relay request tracing", () => {...@@ -192,6 +196,8 @@ describe("relay request tracing", () => {
192 expect(spans[0]?.kind).toBe("server");196 expect(spans[0]?.kind).toBe("server");
193 expect(spans[0]?.attributes.get("url.path")).toBe("/v1/mobile/devices");197 expect(spans[0]?.attributes.get("url.path")).toBe("/v1/mobile/devices");
194 expect(spans[0]?.attributes.get("http.response.status_code")).toBe(204);198 expect(spans[0]?.attributes.get("http.response.status_code")).toBe(204);
199 expect(spans[0]?.attributes.get("http.request.header.authorization")).toBe("<redacted>");
200 expect(spans[0]?.attributes.get("http.request.header.dpop")).toBe("<redacted>");
195 expect(Option.isNone(spans[0]!.parent)).toBe(true);201 expect(Option.isNone(spans[0]!.parent)).toBe(true);
196 expect(Option.getOrUndefined(spans[1]!.parent)?.spanId).toBe(spans[0]?.spanId);202 expect(Option.getOrUndefined(spans[1]!.parent)?.spanId).toBe(spans[0]?.spanId);
197 }),203 }),
infra/relay/src/http/Api.ts+10-13
...@@ -3,6 +3,7 @@ import { sql as drizzleSql } from "drizzle-orm";...@@ -3,6 +3,7 @@ import { sql as drizzleSql } from "drizzle-orm";
3import * as Crypto from "effect/Crypto";3import * as Crypto from "effect/Crypto";
4import * as Context from "effect/Context";4import * as Context from "effect/Context";
5import * as DateTime from "effect/DateTime";5import * as DateTime from "effect/DateTime";
6import * as Duration from "effect/Duration";
6import * as Effect from "effect/Effect";7import * as Effect from "effect/Effect";
7import * as Layer from "effect/Layer";8import * as Layer from "effect/Layer";
8import * as Record from "effect/Record";9import * as Record from "effect/Record";
...@@ -18,6 +19,7 @@ import * as HttpTraceContext from "effect/unstable/http/HttpTraceContext";...@@ -18,6 +19,7 @@ import * as HttpTraceContext from "effect/unstable/http/HttpTraceContext";
18import * as HttpApiBuilder from "effect/unstable/httpapi/HttpApiBuilder";19import * as HttpApiBuilder from "effect/unstable/httpapi/HttpApiBuilder";
19import * as HttpApiError from "effect/unstable/httpapi/HttpApiError";20import * as HttpApiError from "effect/unstable/httpapi/HttpApiError";
20import { encodeOAuthScope } from "@t3tools/shared/oauthScope";21import { encodeOAuthScope } from "@t3tools/shared/oauthScope";
22import { httpHeaderRedactionLayer } from "@t3tools/shared/httpObservability";
2123
22import {24import {
23 RelayApi,25 RelayApi,
...@@ -67,6 +69,7 @@ import { withSpanAttributes } from "../observability.ts";...@@ -67,6 +69,7 @@ import { withSpanAttributes } from "../observability.ts";
67import { RelayDb } from "../db.ts";69import { RelayDb } from "../db.ts";
6870
69const relayCorsAllowedMethods = ["GET", "POST", "DELETE", "OPTIONS"] as const;71const relayCorsAllowedMethods = ["GET", "POST", "DELETE", "OPTIONS"] as const;
72const RELAY_DPOP_ACCESS_TOKEN_TTL = "30 minutes";
70const relayCorsAllowedHeaders = [73const relayCorsAllowedHeaders = [
71 "authorization",74 "authorization",
72 "b3",75 "b3",
...@@ -74,11 +77,7 @@ const relayCorsAllowedHeaders = [...@@ -74,11 +77,7 @@ const relayCorsAllowedHeaders = [
74 "content-type",77 "content-type",
75 "dpop",78 "dpop",
76] as const;79] as const;
77const relayCorsExposedHeaders = [80const relayCorsExposedHeaders = ["traceparent", "www-authenticate"] as const;
78 "traceparent",
79 "x-t3-relay-auth-failure",
80 "www-authenticate",
81] as const;
8281
83const relayCorsHeaders = {82const relayCorsHeaders = {
84 "access-control-allow-origin": "*",83 "access-control-allow-origin": "*",
...@@ -176,7 +175,10 @@ export const traceRelayHttpRequestWith = <E, R, LayerError, LayerRequirements>(...@@ -176,7 +175,10 @@ export const traceRelayHttpRequestWith = <E, R, LayerError, LayerRequirements>(
176 HttpServerRequest.HttpServerRequest | R175 HttpServerRequest.HttpServerRequest | R
177 >,176 >,
178 tracerLayer: Layer.Layer<never, LayerError, LayerRequirements>,177 tracerLayer: Layer.Layer<never, LayerError, LayerRequirements>,
179) => traceRelayHttpRequest(httpEffect).pipe(Effect.provide(tracerLayer));178) =>
179 traceRelayHttpRequest(httpEffect).pipe(
180 Effect.provide(Layer.merge(tracerLayer, httpHeaderRedactionLayer)),
181 );
180182
181export const withoutCapturedParentSpan = <A, E, R>(183export const withoutCapturedParentSpan = <A, E, R>(
182 effect: Effect.Effect<A, E, R>,184 effect: Effect.Effect<A, E, R>,
...@@ -351,12 +353,7 @@ export const healthApi = HttpApiBuilder.group(...@@ -351,12 +353,7 @@ export const healthApi = HttpApiBuilder.group(
351 "health",353 "health",
352 Effect.fn("relay.api.health")(354 Effect.fn("relay.api.health")(
353 function* () {355 function* () {
354 const startedAt = yield* Effect.clockWith((clock) => clock.currentTimeMillis);
355 yield* db.execute(drizzleSql`SELECT 1`);356 yield* db.execute(drizzleSql`SELECT 1`);
356 const completedAt = yield* Effect.clockWith((clock) => clock.currentTimeMillis);
357 yield* Effect.logInfo("relay health db probe completed", {
358 durationMs: completedAt - startedAt,
359 });
360 return { ok: true, service: "relay" as const };357 return { ok: true, service: "relay" as const };
361 },358 },
362 Effect.catch(() => relayInternalErrorResponse("database_unavailable")),359 Effect.catch(() => relayInternalErrorResponse("database_unavailable")),
...@@ -602,7 +599,7 @@ export const tokenApi = HttpApiBuilder.group(...@@ -602,7 +599,7 @@ export const tokenApi = HttpApiBuilder.group(
602 Effect.provideService(DpopProofs.DpopProofReplay, dpopProofs),599 Effect.provideService(DpopProofs.DpopProofReplay, dpopProofs),
603 );600 );
604 const now = yield* DateTime.now;601 const now = yield* DateTime.now;
605 const expiresAt = DateTime.add(now, { minutes: 5 });602 const expiresAt = DateTime.addDuration(now, RELAY_DPOP_ACCESS_TOKEN_TTL);
606 const jti = yield* crypto.randomUUIDv4.pipe(603 const jti = yield* crypto.randomUUIDv4.pipe(
607 Effect.catch(() => relayInternalErrorResponse("internal_error")),604 Effect.catch(() => relayInternalErrorResponse("internal_error")),
608 );605 );
...@@ -620,7 +617,7 @@ export const tokenApi = HttpApiBuilder.group(...@@ -620,7 +617,7 @@ export const tokenApi = HttpApiBuilder.group(
620 .pipe(Effect.catch(() => relayInternalErrorResponse("internal_error"))),617 .pipe(Effect.catch(() => relayInternalErrorResponse("internal_error"))),
621 issued_token_type: RelayAccessTokenType,618 issued_token_type: RelayAccessTokenType,
622 token_type: "DPoP" as const,619 token_type: "DPoP" as const,
623 expires_in: 300,620 expires_in: Duration.toSeconds(RELAY_DPOP_ACCESS_TOKEN_TTL),
624 scope: encodeOAuthScope(requestedScopes),621 scope: encodeOAuthScope(requestedScopes),
625 };622 };
626 }, mapRelayCommonApiErrors("invalid_dpop")),623 }, mapRelayCommonApiErrors("invalid_dpop")),
infra/relay/src/observability.ts+10-2
...@@ -34,7 +34,7 @@ export const RelayObservability = Effect.gen(function* () {...@@ -34,7 +34,7 @@ export const RelayObservability = Effect.gen(function* () {
34 useRetentionPeriod: true,34 useRetentionPeriod: true,
35 });35 });
3636
37 const ingestToken = yield* Axiom.ApiToken("RelayAxiomIngestToken", {37 const workerIngestToken = yield* Axiom.ApiToken("RelayWorkerAxiomIngestToken", {
38 name: relayResourceNameForStage("t3-code-relay-otel-ingest", stage),38 name: relayResourceNameForStage("t3-code-relay-otel-ingest", stage),
39 description: "Owned by Alchemy. Scoped OTLP ingest token for relay HTTP spans.",39 description: "Owned by Alchemy. Scoped OTLP ingest token for relay HTTP spans.",
40 datasetCapabilities: Output.map(traces.name, (dataset) => ({40 datasetCapabilities: Output.map(traces.name, (dataset) => ({
...@@ -42,6 +42,14 @@ export const RelayObservability = Effect.gen(function* () {...@@ -42,6 +42,14 @@ export const RelayObservability = Effect.gen(function* () {
42 })),42 })),
43 });43 });
4444
45 const mobileIngestToken = yield* Axiom.ApiToken("RelayMobileAxiomIngestToken", {
46 name: relayResourceNameForStage("t3-code-mobile-otel-ingest", stage),
47 description: "Owned by Alchemy. Scoped OTLP ingest token for T3 Code mobile spans.",
48 datasetCapabilities: Output.map(traces.name, (dataset) => ({
49 [dataset]: { ingest: ["create" as const] },
50 })),
51 });
52
45 yield* Axiom.View("RelayRecentSpansView", {53 yield* Axiom.View("RelayRecentSpansView", {
46 name: relayResourceNameForStage("t3-code-relay-recent-spans", stage),54 name: relayResourceNameForStage("t3-code-relay-recent-spans", stage),
47 description: "Recent relay HTTP request spans.",55 description: "Recent relay HTTP request spans.",
...@@ -49,7 +57,7 @@ export const RelayObservability = Effect.gen(function* () {...@@ -49,7 +57,7 @@ export const RelayObservability = Effect.gen(function* () {
49 aplQuery: Output.map(traces.name, relayRecentSpansQuery),57 aplQuery: Output.map(traces.name, relayRecentSpansQuery),
50 });58 });
5159
52 return { traces, ingestToken } as const;60 return { traces, workerIngestToken, mobileIngestToken } as const;
53});61});
5462
55export const withSpanAttributes =63export const withSpanAttributes =
infra/relay/src/worker.ts+1-1
...@@ -129,7 +129,7 @@ export default class Api extends Cloudflare.Worker<Api>()(...@@ -129,7 +129,7 @@ export default class Api extends Cloudflare.Worker<Api>()(
129 const apnsDeliveryQueueSender = yield* Cloudflare.QueueBinding.bind(apnsDeliveryQueue);129 const apnsDeliveryQueueSender = yield* Cloudflare.QueueBinding.bind(apnsDeliveryQueue);
130130
131 const axiomDatasetName = yield* observability.traces.name;131 const axiomDatasetName = yield* observability.traces.name;
132 const axiomIngestToken = yield* observability.ingestToken.token;132 const axiomIngestToken = yield* observability.workerIngestToken.token;
133 const axiomTracesEndpoint = yield* observability.traces.otelTracesEndpoint;133 const axiomTracesEndpoint = yield* observability.traces.otelTracesEndpoint;
134134
135 const clerkSecretKey = yield* Config.redacted("CLERK_SECRET_KEY");135 const clerkSecretKey = yield* Config.redacted("CLERK_SECRET_KEY");
oxlint-plugin-t3code/index.ts+2
...@@ -1,6 +1,7 @@...@@ -1,6 +1,7 @@
1import { definePlugin } from "@oxlint/plugins";1import { definePlugin } from "@oxlint/plugins";
22
3import noInlineSchemaCompile from "./rules/no-inline-schema-compile.ts";3import noInlineSchemaCompile from "./rules/no-inline-schema-compile.ts";
4import noManualEffectRuntimeInTests from "./rules/no-manual-effect-runtime-in-tests.ts";
45
5export default definePlugin({6export default definePlugin({
6 meta: {7 meta: {
...@@ -8,5 +9,6 @@ export default definePlugin({...@@ -8,5 +9,6 @@ export default definePlugin({
8 },9 },
9 rules: {10 rules: {
10 "no-inline-schema-compile": noInlineSchemaCompile,11 "no-inline-schema-compile": noInlineSchemaCompile,
12 "no-manual-effect-runtime-in-tests": noManualEffectRuntimeInTests,
11 },13 },
12});14});
oxlint-plugin-t3code/rules/no-manual-effect-runtime-in-tests.test.ts created+73
...@@ -0,0 +1,73 @@
1import { assert, describe } from "@effect/vitest";
2
3import { createOxlintRuleHarness } from "../test/utils.ts";
4
5const rule = createOxlintRuleHarness("t3code/no-manual-effect-runtime-in-tests", {
6 filename: "fixture.test.ts",
7});
8
9describe("t3code/no-manual-effect-runtime-in-tests", () => {
10 rule.valid(
11 "allows @effect/vitest effect tests",
12 `
13 import { it } from "@effect/vitest";
14 import * as Effect from "effect/Effect";
15
16 it.effect("runs an Effect", () => Effect.succeed("ok"));
17 `,
18 );
19
20 const runtimeMethods = [
21 "runCallback",
22 "runCallbackWith",
23 "runFork",
24 "runForkWith",
25 "runPromise",
26 "runPromiseExit",
27 "runPromiseExitWith",
28 "runPromiseWith",
29 "runSync",
30 "runSyncExit",
31 "runSyncExitWith",
32 "runSyncWith",
33 ] as const;
34
35 for (const method of runtimeMethods) {
36 rule.invalid(
37 `reports Effect.${method}`,
38 `
39 import * as Effect from "effect/Effect";
40
41 test("runs an Effect", () => {
42 Effect.${method}(Effect.succeed("ok"));
43 });
44 `,
45 (output) => {
46 assert.match(output, /Use @effect\/vitest with it\.effect/);
47 },
48 );
49 }
50
51 rule.invalid(
52 "reports ManagedRuntime.make",
53 `
54 import * as Layer from "effect/Layer";
55 import * as ManagedRuntime from "effect/ManagedRuntime";
56
57 test("makes a runtime", () => {
58 ManagedRuntime.make(Layer.empty);
59 });
60 `,
61 );
62});
63
64const productionRule = createOxlintRuleHarness("t3code/no-manual-effect-runtime-in-tests");
65
66productionRule.valid(
67 "allows production runtime boundaries",
68 `
69 import * as Effect from "effect/Effect";
70
71 export const main = () => Effect.runPromise(Effect.void);
72 `,
73);
oxlint-plugin-t3code/rules/no-manual-effect-runtime-in-tests.ts created+113
...@@ -0,0 +1,113 @@
1import { defineRule } from "@oxlint/plugins";
2import * as Option from "effect/Option";
3
4import { getPropertyName, isIdentifier, unwrapExpression } from "../utils.ts";
5
6const TEST_FILE_PATTERN = /\.(?:test|spec)\.[cm]?[jt]sx?$/u;
7const EFFECT_RUNTIME_METHODS = new Set([
8 "runCallback",
9 "runCallbackWith",
10 "runFork",
11 "runForkWith",
12 "runPromise",
13 "runPromiseExit",
14 "runPromiseExitWith",
15 "runPromiseWith",
16 "runSync",
17 "runSyncExit",
18 "runSyncExitWith",
19 "runSyncWith",
20]);
21
22// Existing manual runners are tracked as debt. The rule permits no net-new
23// occurrences in these files, while unlisted test files must have zero.
24const LEGACY_BASELINE = new Map<string, number>([
25 ["apps/mobile/src/features/agent-awareness/liveActivityPreferences.test.ts", 1],
26 ["apps/mobile/src/features/agent-awareness/remoteRegistration.test.ts", 2],
27 ["apps/mobile/src/state/use-remote-environment-registry.test.ts", 2],
28 ["apps/server/src/checkpointing/Layers/CheckpointDiffQuery.test.ts", 5],
29 ["apps/server/src/orchestration/commandInvariants.test.ts", 6],
30 ["apps/server/src/orchestration/Layers/CheckpointReactor.test.ts", 42],
31 ["apps/server/src/orchestration/Layers/OrchestrationEngine.test.ts", 5],
32 ["apps/server/src/orchestration/Layers/OrchestrationReactor.test.ts", 4],
33 ["apps/server/src/orchestration/Layers/ProviderCommandReactor.test.ts", 70],
34 ["apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.test.ts", 31],
35 ["apps/server/src/orchestration/Layers/ThreadDeletionReactor.test.ts", 2],
36 ["apps/server/src/orchestration/projector.test.ts", 20],
37 ["apps/server/src/project/Layers/ProjectSetupScriptRunner.test.ts", 4],
38 ["apps/server/src/provider/acp/CursorAcpSupport.test.ts", 1],
39 ["apps/server/src/provider/Layers/ClaudeAdapter.test.ts", 2],
40 ["apps/server/src/provider/Layers/CodexAdapter.test.ts", 1],
41 ["apps/server/src/provider/Layers/CodexSessionRuntime.test.ts", 5],
42 ["apps/server/src/provider/Layers/CursorAdapter.test.ts", 1],
43 ["apps/server/src/provider/Layers/CursorProvider.test.ts", 4],
44 ["apps/server/src/provider/Layers/ProviderService.test.ts", 2],
45 ["apps/server/src/provider/Layers/ProviderSessionReaper.test.ts", 21],
46 ["apps/server/src/relay/AgentAwarenessRelay.test.ts", 4],
47 ["apps/server/src/server.test.ts", 1],
48 ["apps/web/src/cloud/dpop.test.ts", 2],
49 ["apps/web/src/environments/runtime/service.addSavedEnvironment.test.ts", 1],
50 ["oxlint-plugin-t3code/rules/no-manual-effect-runtime-in-tests.test.ts", 7],
51 ["packages/client-runtime/src/managedRelayState.test.ts", 1],
52 ["packages/client-runtime/src/wsTransport.test.ts", 2],
53]);
54
55const baselineFor = (filename: string): number => {
56 const normalized = filename.replaceAll("\\", "/");
57 for (const [suffix, count] of LEGACY_BASELINE) {
58 if (normalized.endsWith(suffix)) return count;
59 }
60 return 0;
61};
62
63const manualRunnerName = (callee: unknown): Option.Option<string> => {
64 const expression = unwrapExpression(callee);
65 if (Option.isNone(expression) || expression.value.type !== "MemberExpression") {
66 return Option.none();
67 }
68
69 const object = unwrapExpression(expression.value.object);
70 const property = getPropertyName(expression.value.property);
71 if (Option.isNone(property)) return Option.none();
72
73 if (isIdentifier(object, "Effect") && EFFECT_RUNTIME_METHODS.has(property.value)) {
74 return Option.some(`Effect.${property.value}`);
75 }
76
77 if (isIdentifier(object, "ManagedRuntime") && property.value === "make") {
78 return Option.some("ManagedRuntime.make");
79 }
80
81 return Option.none();
82};
83
84export default defineRule({
85 meta: {
86 type: "problem",
87 docs: {
88 description:
89 "Disallow manually creating or running Effect runtimes in tests; use @effect/vitest.",
90 },
91 },
92 create(context) {
93 if (!TEST_FILE_PATTERN.test(context.filename)) return {};
94
95 const allowedCount = baselineFor(context.filename);
96 let occurrenceCount = 0;
97
98 return {
99 CallExpression(node) {
100 const runner = manualRunnerName(node.callee);
101 if (Option.isNone(runner)) return;
102
103 occurrenceCount++;
104 if (occurrenceCount <= allowedCount) return;
105
106 context.report({
107 node: node.callee,
108 message: `Do not use ${runner.value} in tests. Use @effect/vitest with it.effect(...) and test layers instead.`,
109 });
110 },
111 };
112 },
113});
oxlint-plugin-t3code/test/utils.ts+9-2
...@@ -48,6 +48,10 @@ interface RuleHarness {...@@ -48,6 +48,10 @@ interface RuleHarness {
48 readonly invalid: (name: string, source: string, assertion?: (output: string) => void) => void;48 readonly invalid: (name: string, source: string, assertion?: (output: string) => void) => void;
49}49}
5050
51interface RuleHarnessOptions {
52 readonly filename?: string;
53}
54
51const collectStreamAsString = <E>(stream: Stream.Stream<Uint8Array, E>): Effect.Effect<string, E> =>55const collectStreamAsString = <E>(stream: Stream.Stream<Uint8Array, E>): Effect.Effect<string, E> =>
52 stream.pipe(56 stream.pipe(
53 Stream.decodeText(),57 Stream.decodeText(),
...@@ -73,7 +77,10 @@ const spawnAndCollectOutput = Effect.fnUntraced(function* (command: ChildProcess...@@ -73,7 +77,10 @@ const spawnAndCollectOutput = Effect.fnUntraced(function* (command: ChildProcess
73 return { exitCode, stdout, stderr };77 return { exitCode, stdout, stderr };
74}, Effect.scoped);78}, Effect.scoped);
7579
76export const createOxlintRuleHarness = (ruleName: string): RuleHarness => {80export const createOxlintRuleHarness = (
81 ruleName: string,
82 options: RuleHarnessOptions = {},
83): RuleHarness => {
77 const [pluginName, shortRuleName] = ruleName.split("/");84 const [pluginName, shortRuleName] = ruleName.split("/");
78 const diagnosticRuleName =85 const diagnosticRuleName =
79 pluginName && shortRuleName ? `${pluginName}\\(${shortRuleName}\\)` : ruleName;86 pluginName && shortRuleName ? `${pluginName}\\(${shortRuleName}\\)` : ruleName;
...@@ -84,7 +91,7 @@ export const createOxlintRuleHarness = (ruleName: string): RuleHarness => {...@@ -84,7 +91,7 @@ export const createOxlintRuleHarness = (ruleName: string): RuleHarness => {
84 const path = yield* Path.Path;91 const path = yield* Path.Path;
85 const fixtureDir = yield* fs.makeTempDirectoryScoped({ prefix: "t3code-oxlint-" });92 const fixtureDir = yield* fs.makeTempDirectoryScoped({ prefix: "t3code-oxlint-" });
86 const configPath = path.join(fixtureDir, ".oxlintrc.json");93 const configPath = path.join(fixtureDir, ".oxlintrc.json");
87 const sourcePath = path.join(fixtureDir, "fixture.ts");94 const sourcePath = path.join(fixtureDir, options.filename ?? "fixture.ts");
88 const repoRoot = path.join(import.meta.dirname, "..", "..");95 const repoRoot = path.join(import.meta.dirname, "..", "..");
89 const oxlintBin = path.join(96 const oxlintBin = path.join(
90 repoRoot,97 repoRoot,
packages/client-runtime/src/remote.ts+5-1
...@@ -15,6 +15,7 @@ import type {...@@ -15,6 +15,7 @@ import type {
15 EnvironmentScopeRequiredError,15 EnvironmentScopeRequiredError,
16} from "@t3tools/contracts";16} from "@t3tools/contracts";
17import { encodeOAuthScope } from "@t3tools/shared/oauthScope";17import { encodeOAuthScope } from "@t3tools/shared/oauthScope";
18import { httpHeaderRedactionLayer } from "@t3tools/shared/httpObservability";
18import * as Data from "effect/Data";19import * as Data from "effect/Data";
19import * as Duration from "effect/Duration";20import * as Duration from "effect/Duration";
20import * as Effect from "effect/Effect";21import * as Effect from "effect/Effect";
...@@ -111,7 +112,10 @@ export type RemoteEnvironmentAuthError =...@@ -111,7 +112,10 @@ export type RemoteEnvironmentAuthError =
111export const remoteHttpClientLayer = (112export const remoteHttpClientLayer = (
112 fetchFn: typeof globalThis.fetch,113 fetchFn: typeof globalThis.fetch,
113): Layer.Layer<HttpClient.HttpClient> =>114): Layer.Layer<HttpClient.HttpClient> =>
114 FetchHttpClient.layer.pipe(Layer.provide(Layer.succeed(FetchHttpClient.Fetch, fetchFn)));115 Layer.merge(
116 FetchHttpClient.layer.pipe(Layer.provide(Layer.succeed(FetchHttpClient.Fetch, fetchFn))),
117 httpHeaderRedactionLayer,
118 );
115119
116const failRemoteRequest = (120const failRemoteRequest = (
117 requestUrl: string,121 requestUrl: string,
packages/shared/package.json+4
...@@ -31,6 +31,10 @@...@@ -31,6 +31,10 @@
31 "types": "./src/observability.ts",31 "types": "./src/observability.ts",
32 "import": "./src/observability.ts"32 "import": "./src/observability.ts"
33 },33 },
34 "./httpObservability": {
35 "types": "./src/httpObservability.ts",
36 "import": "./src/httpObservability.ts"
37 },
34 "./shell": {38 "./shell": {
35 "types": "./src/shell.ts",39 "types": "./src/shell.ts",
36 "import": "./src/shell.ts"40 "import": "./src/shell.ts"
packages/shared/src/httpObservability.ts created+8
...@@ -0,0 +1,8 @@
1import * as Effect from "effect/Effect";
2import * as Layer from "effect/Layer";
3import * as Headers from "effect/unstable/http/Headers";
4
5export const httpHeaderRedactionLayer = Layer.effect(
6 Headers.CurrentRedactedNames,
7 Effect.map(Headers.CurrentRedactedNames, (names) => [...names, "dpop"]),
8);
scripts/lib/public-config.test.ts+22-1
...@@ -27,6 +27,12 @@ describe("loadRepoEnv", () => {...@@ -27,6 +27,12 @@ describe("loadRepoEnv", () => {
27 expect(env.EXPO_PUBLIC_CLERK_JWT_TEMPLATE).toBeUndefined();27 expect(env.EXPO_PUBLIC_CLERK_JWT_TEMPLATE).toBeUndefined();
28 expect(env.T3CODE_RELAY_URL).toBeUndefined();28 expect(env.T3CODE_RELAY_URL).toBeUndefined();
29 expect(env.VITE_T3CODE_RELAY_URL).toBeUndefined();29 expect(env.VITE_T3CODE_RELAY_URL).toBeUndefined();
30 expect(env.T3CODE_MOBILE_OTLP_TRACES_URL).toBeUndefined();
31 expect(env.T3CODE_MOBILE_OTLP_TRACES_DATASET).toBeUndefined();
32 expect(env.T3CODE_MOBILE_OTLP_TRACES_TOKEN).toBeUndefined();
33 expect(env.EXPO_PUBLIC_OTLP_TRACES_URL).toBeUndefined();
34 expect(env.EXPO_PUBLIC_OTLP_TRACES_DATASET).toBeUndefined();
35 expect(env.EXPO_PUBLIC_OTLP_TRACES_TOKEN).toBeUndefined();
30 });36 });
3137
32 it("applies process, root local, and root precedence in that order", () => {38 it("applies process, root local, and root precedence in that order", () => {
...@@ -73,26 +79,41 @@ describe("loadRepoEnv", () => {...@@ -73,26 +79,41 @@ describe("loadRepoEnv", () => {
73 VITE_CLERK_JWT_TEMPLATE: "template_legacy",79 VITE_CLERK_JWT_TEMPLATE: "template_legacy",
74 T3CODE_CLERK_CLI_OAUTH_CLIENT_ID: "oauth_canonical",80 T3CODE_CLERK_CLI_OAUTH_CLIENT_ID: "oauth_canonical",
75 VITE_T3CODE_RELAY_URL: "https://legacy.example.test",81 VITE_T3CODE_RELAY_URL: "https://legacy.example.test",
82 EXPO_PUBLIC_OTLP_TRACES_URL: "https://api.axiom.co/v1/traces",
83 EXPO_PUBLIC_OTLP_TRACES_DATASET: "mobile-traces",
84 EXPO_PUBLIC_OTLP_TRACES_TOKEN: "mobile-token",
76 }),85 }),
77 ).toEqual({86 ).toEqual({
78 clerkPublishableKey: "pk_legacy",87 clerkPublishableKey: "pk_legacy",
79 clerkJwtTemplate: "template_legacy",88 clerkJwtTemplate: "template_legacy",
80 clerkCliOAuthClientId: "oauth_canonical",89 clerkCliOAuthClientId: "oauth_canonical",
81 relayUrl: "https://legacy.example.test",90 relayUrl: "https://legacy.example.test",
91 mobileOtlpTracesUrl: "https://api.axiom.co/v1/traces",
92 mobileOtlpTracesDataset: "mobile-traces",
93 mobileOtlpTracesToken: "mobile-token",
82 });94 });
83 });95 });
8496
85 it("projects only the configured aliases", () => {97 it("projects canonical mobile tracing values to Expo public aliases", () => {
86 expect(98 expect(
87 loadRepoEnv({99 loadRepoEnv({
88 baseEnv: {100 baseEnv: {
89 T3CODE_RELAY_URL: "https://relay.example.test",101 T3CODE_RELAY_URL: "https://relay.example.test",
102 T3CODE_MOBILE_OTLP_TRACES_URL: "https://api.axiom.co/v1/traces",
103 T3CODE_MOBILE_OTLP_TRACES_DATASET: "mobile-traces",
104 T3CODE_MOBILE_OTLP_TRACES_TOKEN: "mobile-token",
90 },105 },
91 repoRoot: makeTemporaryDirectory(),106 repoRoot: makeTemporaryDirectory(),
92 }),107 }),
93 ).toEqual({108 ).toEqual({
94 T3CODE_RELAY_URL: "https://relay.example.test",109 T3CODE_RELAY_URL: "https://relay.example.test",
95 VITE_T3CODE_RELAY_URL: "https://relay.example.test",110 VITE_T3CODE_RELAY_URL: "https://relay.example.test",
111 T3CODE_MOBILE_OTLP_TRACES_URL: "https://api.axiom.co/v1/traces",
112 T3CODE_MOBILE_OTLP_TRACES_DATASET: "mobile-traces",
113 T3CODE_MOBILE_OTLP_TRACES_TOKEN: "mobile-token",
114 EXPO_PUBLIC_OTLP_TRACES_URL: "https://api.axiom.co/v1/traces",
115 EXPO_PUBLIC_OTLP_TRACES_DATASET: "mobile-traces",
116 EXPO_PUBLIC_OTLP_TRACES_TOKEN: "mobile-token",
96 });117 });
97 });118 });
98});119});
scripts/lib/public-config.ts+36
...@@ -9,6 +9,9 @@ export interface T3CodePublicConfig {...@@ -9,6 +9,9 @@ export interface T3CodePublicConfig {
9 readonly clerkJwtTemplate: string | undefined;9 readonly clerkJwtTemplate: string | undefined;
10 readonly clerkCliOAuthClientId: string | undefined;10 readonly clerkCliOAuthClientId: string | undefined;
11 readonly relayUrl: string | undefined;11 readonly relayUrl: string | undefined;
12 readonly mobileOtlpTracesUrl: string | undefined;
13 readonly mobileOtlpTracesDataset: string | undefined;
14 readonly mobileOtlpTracesToken: string | undefined;
12}15}
1316
14type Environment = Readonly<Record<string, string | undefined>>;17type Environment = Readonly<Record<string, string | undefined>>;
...@@ -57,6 +60,24 @@ export function loadRepoEnv({...@@ -57,6 +60,24 @@ export function loadRepoEnv({
57 VITE_T3CODE_RELAY_URL: config.relayUrl,60 VITE_T3CODE_RELAY_URL: config.relayUrl,
58 }61 }
59 : {}),62 : {}),
63 ...(config.mobileOtlpTracesUrl
64 ? {
65 T3CODE_MOBILE_OTLP_TRACES_URL: config.mobileOtlpTracesUrl,
66 EXPO_PUBLIC_OTLP_TRACES_URL: config.mobileOtlpTracesUrl,
67 }
68 : {}),
69 ...(config.mobileOtlpTracesDataset
70 ? {
71 T3CODE_MOBILE_OTLP_TRACES_DATASET: config.mobileOtlpTracesDataset,
72 EXPO_PUBLIC_OTLP_TRACES_DATASET: config.mobileOtlpTracesDataset,
73 }
74 : {}),
75 ...(config.mobileOtlpTracesToken
76 ? {
77 T3CODE_MOBILE_OTLP_TRACES_TOKEN: config.mobileOtlpTracesToken,
78 EXPO_PUBLIC_OTLP_TRACES_TOKEN: config.mobileOtlpTracesToken,
79 }
80 : {}),
60 };81 };
61}82}
6283
...@@ -76,6 +97,21 @@ export function resolvePublicConfig(...sources: readonly Environment[]): T3CodeP...@@ -76,6 +97,21 @@ export function resolvePublicConfig(...sources: readonly Environment[]): T3CodeP
76 ),97 ),
77 clerkCliOAuthClientId: firstNonEmpty(sources, "T3CODE_CLERK_CLI_OAUTH_CLIENT_ID"),98 clerkCliOAuthClientId: firstNonEmpty(sources, "T3CODE_CLERK_CLI_OAUTH_CLIENT_ID"),
78 relayUrl: firstNonEmpty(sources, "T3CODE_RELAY_URL", "VITE_T3CODE_RELAY_URL"),99 relayUrl: firstNonEmpty(sources, "T3CODE_RELAY_URL", "VITE_T3CODE_RELAY_URL"),
100 mobileOtlpTracesUrl: firstNonEmpty(
101 sources,
102 "T3CODE_MOBILE_OTLP_TRACES_URL",
103 "EXPO_PUBLIC_OTLP_TRACES_URL",
104 ),
105 mobileOtlpTracesDataset: firstNonEmpty(
106 sources,
107 "T3CODE_MOBILE_OTLP_TRACES_DATASET",
108 "EXPO_PUBLIC_OTLP_TRACES_DATASET",
109 ),
110 mobileOtlpTracesToken: firstNonEmpty(
111 sources,
112 "T3CODE_MOBILE_OTLP_TRACES_TOKEN",
113 "EXPO_PUBLIC_OTLP_TRACES_TOKEN",
114 ),
79 };115 };
80}116}
81117
vite.config.ts+1
...@@ -91,6 +91,7 @@ export default defineConfig({...@@ -91,6 +91,7 @@ export default defineConfig({
91 "typescript/restrict-template-expressions": "off",91 "typescript/restrict-template-expressions": "off",
92 "typescript/unbound-method": "off",92 "typescript/unbound-method": "off",
93 "t3code/no-inline-schema-compile": "warn",93 "t3code/no-inline-schema-compile": "warn",
94 "t3code/no-manual-effect-runtime-in-tests": "error",
94 },95 },
95 options: {96 options: {
96 // Revisit once Oxlint's tsgolint path can integrate with @effect/tsgo diagnostics.97 // Revisit once Oxlint's tsgolint path can integrate with @effect/tsgo diagnostics.