| author | |
| committer | |
| log | 0e4a43519fe4fa85ce60aa8d6d53c067e56d2fca |
| tree | a164e1a74db2ffc995ea8751fd3a69908c107345 |
| parent | 3ea6adf17d5528d9c90ed53b6dead650d5931941 |
| signature | Signed by PGP key B5690EEEBB952194 |
Co-authored-by: codex <codex@users.noreply.github.com>34 files changed, 810 insertions(+), 113 deletions(-)
.env.example+5| ... | ... | @@ -10,3 +10,8 @@ |
| 10 | 10 | |
| 11 | 11 | # Get this from your relay deployment. `infra/relay` deploys update it automatically. |
| 12 | 12 | # 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 | 161 | publishableKey: repoEnv.EXPO_PUBLIC_CLERK_PUBLISHABLE_KEY ?? null, |
| 162 | 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 | 169 | eas: { |
| 165 | 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 | 41 | } |
| 42 | 42 | |
| 43 | 43 | function readRelayConfig(): { readonly url: string } | null { |
| 44 | const relayUrl = resolveCloudPublicConfig().relayUrl; | |
| 44 | const relayUrl = resolveCloudPublicConfig().relay.url; | |
| 45 | 45 | if (!relayUrl) { |
| 46 | 46 | logRegistrationDebug("relay registration skipped; relay config missing"); |
| 47 | 47 | return null; |
apps/mobile/src/features/cloud/CloudAuthProvider.tsx+3-1| ... | ... | @@ -66,7 +66,9 @@ function CloudAuthBridge(props: { readonly children: ReactNode }) { |
| 66 | 66 | } |
| 67 | 67 | |
| 68 | 68 | export 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; | |
| 70 | 72 | |
| 71 | 73 | useEffect(() => { |
| 72 | 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 | 50 | } |
| 51 | 51 | |
| 52 | 52 | function readRelayUrl(): string | null { |
| 53 | return resolveCloudPublicConfig().relayUrl; | |
| 53 | return resolveCloudPublicConfig().relay.url; | |
| 54 | 54 | } |
| 55 | 55 | |
| 56 | 56 | export class CloudEnvironmentLinkError extends Data.TaggedError("CloudEnvironmentLinkError")<{ |
apps/mobile/src/features/cloud/managedRelayState.ts+2-13| ... | ... | @@ -1,7 +1,6 @@ |
| 1 | 1 | import { useAtomValue } from "@effect/atom-react"; |
| 2 | 2 | import { |
| 3 | 3 | createManagedRelayQueryManager, |
| 4 | ManagedRelayClient, | |
| 5 | 4 | managedRelaySessionAtom, |
| 6 | 5 | readManagedRelaySnapshotState, |
| 7 | 6 | } from "@t3tools/client-runtime"; |
| ... | ... | @@ -9,23 +8,13 @@ import type { |
| 9 | 8 | RelayClientEnvironmentRecord, |
| 10 | 9 | RelayEnvironmentStatusResponse, |
| 11 | 10 | } from "@t3tools/contracts/relay"; |
| 12 | import * as Context from "effect/Context"; | |
| 13 | import * as Effect from "effect/Effect"; | |
| 14 | import * as Layer from "effect/Layer"; | |
| 15 | 11 | import { AsyncResult, Atom } from "effect/unstable/reactivity"; |
| 16 | 12 | import { useCallback } from "react"; |
| 17 | 13 | |
| 18 | import { mobileRuntime } from "../../lib/runtime"; | |
| 14 | import { mobileRuntimeContextLayer } from "../../lib/runtime"; | |
| 19 | 15 | import { appAtomRegistry } from "../../state/atom-registry"; |
| 20 | 16 | |
| 21 | const managedRelayAtomRuntime = Atom.runtime( | |
| 22 | Layer.effect( | |
| 23 | ManagedRelayClient, | |
| 24 | mobileRuntime.contextEffect.pipe( | |
| 25 | Effect.map((context) => Context.get(context, ManagedRelayClient)), | |
| 26 | ), | |
| 27 | ), | |
| 28 | ); | |
| 17 | const managedRelayAtomRuntime = Atom.runtime(mobileRuntimeContextLayer); | |
| 29 | 18 | |
| 30 | 19 | export const managedRelayQueryManager = createManagedRelayQueryManager(managedRelayAtomRuntime); |
| 31 | 20 |
apps/mobile/src/features/cloud/publicConfig.test.ts+83-10| ... | ... | @@ -1,6 +1,6 @@ |
| 1 | 1 | import { describe, expect, it, vi } from "vite-plus/test"; |
| 2 | 2 | |
| 3 | import { resolveCloudPublicConfig } from "./publicConfig"; | |
| 3 | import { hasMobileTracingPublicConfig, resolveCloudPublicConfig } from "./publicConfig"; | |
| 4 | 4 | |
| 5 | 5 | vi.mock("expo-constants", () => ({ |
| 6 | 6 | default: { |
| ... | ... | @@ -13,9 +13,18 @@ vi.mock("expo-constants", () => ({ |
| 13 | 13 | describe("resolveCloudPublicConfig", () => { |
| 14 | 14 | it("returns no cloud configuration for an unconfigured build", () => { |
| 15 | 15 | expect(resolveCloudPublicConfig({})).toEqual({ |
| 16 | clerkPublishableKey: null, | |
| 17 | clerkJwtTemplate: null, | |
| 18 | relayUrl: null, | |
| 16 | clerk: { | |
| 17 | publishableKey: 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 | }); |
| 21 | 30 | |
| ... | ... | @@ -24,11 +33,25 @@ describe("resolveCloudPublicConfig", () => { |
| 24 | 33 | resolveCloudPublicConfig({ |
| 25 | 34 | clerk: { publishableKey: " pk_test_example ", jwtTemplate: " t3-relay " }, |
| 26 | 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 | 42 | ).toEqual({ |
| 29 | clerkPublishableKey: "pk_test_example", | |
| 30 | clerkJwtTemplate: "t3-relay", | |
| 31 | relayUrl: "https://relay.example.test", | |
| 43 | clerk: { | |
| 44 | publishableKey: "pk_test_example", | |
| 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 | }); |
| 34 | 57 | |
| ... | ... | @@ -39,9 +62,59 @@ describe("resolveCloudPublicConfig", () => { |
| 39 | 62 | relay: { url: "http://relay.example.test" }, |
| 40 | 63 | }), |
| 41 | 64 | ).toEqual({ |
| 42 | clerkPublishableKey: "pk_test_example", | |
| 43 | clerkJwtTemplate: "t3-relay", | |
| 44 | relayUrl: null, | |
| 65 | clerk: { | |
| 66 | publishableKey: "pk_test_example", | |
| 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 | 2 | import { relayClerkTokenOptions } from "@t3tools/shared/relayAuth"; |
| 3 | 3 | import { normalizeSecureRelayUrl } from "@t3tools/shared/relayUrl"; |
| 4 | 4 | |
| 5 | type ExpoExtra = Readonly<Record<string, unknown>> | undefined; | |
| 6 | ||
| 7 | 5 | export interface CloudPublicConfig { |
| 8 | readonly clerkPublishableKey: string | null; | |
| 9 | readonly clerkJwtTemplate: string | null; | |
| 10 | readonly relayUrl: string | null; | |
| 6 | readonly clerk: { | |
| 7 | readonly publishableKey: 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 | } |
| 12 | 19 | |
| 20 | type UntrustedSection<T> = { | |
| 21 | readonly [Key in keyof T]?: unknown; | |
| 22 | }; | |
| 23 | ||
| 24 | type ExpoExtra = | |
| 25 | | { | |
| 26 | readonly [Section in keyof CloudPublicConfig]?: UntrustedSection<CloudPublicConfig[Section]>; | |
| 27 | } | |
| 28 | | undefined; | |
| 29 | ||
| 13 | 30 | function trimNonEmpty(value: unknown): string | null { |
| 14 | 31 | return typeof value === "string" && value.trim() ? value.trim() : null; |
| 15 | 32 | } |
| 16 | 33 | |
| 17 | export function resolveCloudPublicConfig(extra: ExpoExtra = Constants.expoConfig?.extra) { | |
| 18 | const clerk = extra?.clerk as | |
| 19 | | { readonly publishableKey?: unknown; readonly jwtTemplate?: unknown } | |
| 20 | | undefined; | |
| 21 | const relay = extra?.relay as { readonly url?: unknown } | undefined; | |
| 34 | function normalizeSecureUrl(value: unknown): string | null { | |
| 35 | const raw = trimNonEmpty(value); | |
| 36 | if (raw === null) { | |
| 37 | return null; | |
| 38 | } | |
| 39 | try { | |
| 40 | const url = new URL(raw); | |
| 41 | return url.protocol === "https:" ? url.toString() : null; | |
| 42 | } catch { | |
| 43 | return null; | |
| 44 | } | |
| 45 | } | |
| 22 | 46 | |
| 47 | export function resolveCloudPublicConfig(extra: ExpoExtra = Constants.expoConfig?.extra) { | |
| 23 | 48 | return { |
| 24 | clerkPublishableKey: trimNonEmpty(clerk?.publishableKey), | |
| 25 | clerkJwtTemplate: trimNonEmpty(clerk?.jwtTemplate), | |
| 26 | relayUrl: normalizeSecureRelayUrl(trimNonEmpty(relay?.url) ?? ""), | |
| 49 | clerk: { | |
| 50 | publishableKey: trimNonEmpty(extra?.clerk?.publishableKey), | |
| 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 | 61 | } satisfies CloudPublicConfig; |
| 28 | 62 | } |
| 29 | 63 | |
| 30 | 64 | export function hasCloudPublicConfig(): boolean { |
| 31 | 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 | ||
| 69 | type Configured<T> = { | |
| 70 | readonly [Key in keyof T]: NonNullable<T[Key]>; | |
| 71 | }; | |
| 72 | ||
| 73 | type MobileTracingPublicConfig = Omit<CloudPublicConfig, "observability"> & { | |
| 74 | readonly observability: Configured<CloudPublicConfig["observability"]>; | |
| 75 | }; | |
| 76 | ||
| 77 | export 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 | } |
| 34 | 86 | |
| 35 | 87 | export function resolveRelayClerkTokenOptions() { |
| 36 | const { clerkJwtTemplate } = resolveCloudPublicConfig(); | |
| 37 | if (!clerkJwtTemplate) { | |
| 88 | const { jwtTemplate } = resolveCloudPublicConfig().clerk; | |
| 89 | if (!jwtTemplate) { | |
| 38 | 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 @@ |
| 1 | import { expect, it } from "@effect/vitest"; | |
| 2 | import * as Effect from "effect/Effect"; | |
| 3 | import * as Layer from "effect/Layer"; | |
| 4 | import { vi } from "vite-plus/test"; | |
| 5 | ||
| 6 | import { remoteHttpClientLayer } from "@t3tools/client-runtime"; | |
| 7 | ||
| 8 | import { makeMobileTracingLayer } from "./mobileTracing"; | |
| 9 | ||
| 10 | vi.mock("expo-constants", () => ({ | |
| 11 | default: { | |
| 12 | expoConfig: { | |
| 13 | extra: {}, | |
| 14 | }, | |
| 15 | }, | |
| 16 | })); | |
| 17 | ||
| 18 | it.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 @@ |
| 1 | import Constants from "expo-constants"; | |
| 2 | import * as Layer from "effect/Layer"; | |
| 3 | import type { HttpClient } from "effect/unstable/http"; | |
| 4 | import { OtlpSerialization, OtlpTracer } from "effect/unstable/observability"; | |
| 5 | ||
| 6 | import { hasMobileTracingPublicConfig, resolveCloudPublicConfig } from "../cloud/publicConfig"; | |
| 7 | ||
| 8 | export interface MobileTracingConfig { | |
| 9 | readonly tracesUrl: string; | |
| 10 | readonly tracesDataset: string; | |
| 11 | readonly tracesToken: string; | |
| 12 | } | |
| 13 | ||
| 14 | export interface MobileTracingResource { | |
| 15 | readonly serviceVersion?: string; | |
| 16 | readonly appVariant: string; | |
| 17 | } | |
| 18 | ||
| 19 | export 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 | ||
| 28 | export 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 | ||
| 54 | export 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 | 6 | import { mobileCryptoLayer } from "../features/cloud/dpop"; |
| 7 | 7 | import { mobileManagedRelayClientLayer } from "../features/cloud/managedRelayLayer"; |
| 8 | 8 | import { resolveCloudPublicConfig } from "../features/cloud/publicConfig"; |
| 9 | import { mobileTracingLayer } from "../features/observability/mobileTracing"; | |
| 9 | 10 | |
| 10 | 11 | function configuredRelayUrl(): string { |
| 11 | return resolveCloudPublicConfig().relayUrl ?? "http://relay.invalid"; | |
| 12 | return resolveCloudPublicConfig().relay.url ?? "http://relay.invalid"; | |
| 12 | 13 | } |
| 13 | 14 | |
| 14 | 15 | const mobileHttpClientLayer = remoteHttpClientLayer(fetch); |
| 15 | 16 | |
| 16 | 17 | export const mobileRuntime = ManagedRuntime.make( |
| 17 | Layer.mergeAll( | |
| 18 | mobileHttpClientLayer, | |
| 19 | mobileCryptoLayer, | |
| 20 | mobileManagedRelayClientLayer(configuredRelayUrl()).pipe( | |
| 21 | Layer.provide(Layer.mergeAll(mobileHttpClientLayer, mobileCryptoLayer)), | |
| 22 | ), | |
| 18 | mobileManagedRelayClientLayer(configuredRelayUrl()).pipe( | |
| 19 | Layer.provideMerge(mobileCryptoLayer), | |
| 20 | Layer.provideMerge(mobileHttpClientLayer), | |
| 21 | Layer.provideMerge(mobileTracingLayer.pipe(Layer.provide(mobileHttpClientLayer))), | |
| 23 | 22 | ), |
| 24 | 23 | ); |
| 24 | ||
| 25 | export const mobileRuntimeContextLayer = Layer.effectContext(mobileRuntime.contextEffect); |
apps/server/src/observability/Layers/Observability.ts+2| ... | ... | @@ -1,3 +1,4 @@ |
| 1 | import { httpHeaderRedactionLayer } from "@t3tools/shared/httpObservability"; | |
| 1 | 2 | import { makeLocalFileTracer, makeTraceSink } from "@t3tools/shared/observability"; |
| 2 | 3 | import * as Effect from "effect/Effect"; |
| 3 | 4 | import * as Layer from "effect/Layer"; |
| ... | ... | @@ -18,6 +19,7 @@ export const ObservabilityLive = Layer.unwrap( |
| 18 | 19 | const traceReferencesLayer = Layer.mergeAll( |
| 19 | 20 | Layer.succeed(Tracer.MinimumTraceLevel, config.traceMinLevel), |
| 20 | 21 | Layer.succeed(References.TracerTimingEnabled, config.traceTimingEnabled), |
| 22 | httpHeaderRedactionLayer, | |
| 21 | 23 | ); |
| 22 | 24 | |
| 23 | 25 | const tracerLayer = Layer.unwrap( |
apps/web/src/lib/runtime.ts+2| ... | ... | @@ -4,6 +4,7 @@ import * as Layer from "effect/Layer"; |
| 4 | 4 | import { FetchHttpClient } from "effect/unstable/http"; |
| 5 | 5 | |
| 6 | 6 | import { remoteHttpClientLayer } from "@t3tools/client-runtime"; |
| 7 | import { httpHeaderRedactionLayer } from "@t3tools/shared/httpObservability"; | |
| 7 | 8 | import { |
| 8 | 9 | PrimaryEnvironmentHttpClient, |
| 9 | 10 | primaryEnvironmentHttpClientLive, |
| ... | ... | @@ -28,6 +29,7 @@ const primaryHttpRuntime = ManagedRuntime.make( |
| 28 | 29 | Layer.mergeAll( |
| 29 | 30 | remoteHttpClientLayer((input, init) => globalThis.fetch(input, init)), |
| 30 | 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 | 8 | import * as Planetscale from "alchemy/Planetscale"; |
| 9 | 9 | |
| 10 | 10 | import { PlanetscaleDatabase, RelayHyperdrive } from "./src/db.ts"; |
| 11 | import { RelayObservability } from "./src/observability.ts"; | |
| 11 | 12 | import { ManagedEndpointZone, RelayApiZone } from "./src/zone.ts"; |
| 12 | 13 | import Api from "./src/worker.ts"; |
| 13 | 14 | |
| ... | ... | @@ -27,6 +28,7 @@ export default Alchemy.Stack( |
| 27 | 28 | const hyperdrive = yield* RelayHyperdrive; |
| 28 | 29 | const managedEndpointZone = yield* ManagedEndpointZone.pipe(Effect.orDie); |
| 29 | 30 | const relayApiZone = yield* RelayApiZone.pipe(Effect.orDie); |
| 31 | const observability = yield* RelayObservability; | |
| 30 | 32 | const api = yield* Api; |
| 31 | 33 | |
| 32 | 34 | return { |
| ... | ... | @@ -37,6 +39,9 @@ export default Alchemy.Stack( |
| 37 | 39 | url: api.url, |
| 38 | 40 | relayApiZoneId: relayApiZone.zoneId, |
| 39 | 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 | 1 | import { describe, expect, it } from "vite-plus/test"; |
| 2 | 2 | |
| 3 | import { hasDeployChanges, reconcileRootEnvRelayUrl, serializeGithubOutput } from "./deploy.ts"; | |
| 3 | import { | |
| 4 | hasDeployChanges, | |
| 5 | reconcileRootEnvPublicConfig, | |
| 6 | reconcileRootEnvRelayUrl, | |
| 7 | serializeGithubOutput, | |
| 8 | } from "./deploy.ts"; | |
| 4 | 9 | |
| 5 | 10 | describe("hasDeployChanges", () => { |
| 6 | 11 | it("detects resource, binding, and deletion changes", () => { |
| ... | ... | @@ -51,6 +56,52 @@ describe("reconcileRootEnvRelayUrl", () => { |
| 51 | 56 | }); |
| 52 | 57 | }); |
| 53 | 58 | |
| 59 | describe("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 | ||
| 54 | 105 | describe("serializeGithubOutput", () => { |
| 55 | 106 | it("serializes relay deploy metadata for GitHub Actions outputs", () => { |
| 56 | 107 | expect( |
infra/relay/scripts/deploy.ts+69-20| ... | ... | @@ -23,6 +23,7 @@ import * as FileSystem from "effect/FileSystem"; |
| 23 | 23 | import * as Layer from "effect/Layer"; |
| 24 | 24 | import * as Option from "effect/Option"; |
| 25 | 25 | import * as Path from "effect/Path"; |
| 26 | import * as Redacted from "effect/Redacted"; | |
| 26 | 27 | import { Command, Flag, Prompt } from "effect/unstable/cli"; |
| 27 | 28 | import * as FetchHttpClient from "effect/unstable/http/FetchHttpClient"; |
| 28 | 29 | |
| ... | ... | @@ -42,15 +43,49 @@ export interface RelayDeployOptions { |
| 42 | 43 | readonly githubOutput: boolean; |
| 43 | 44 | } |
| 44 | 45 | |
| 45 | export function reconcileRootEnvRelayUrl(contents: string, relayUrl: string): string { | |
| 46 | const entry = `T3CODE_RELAY_URL=${relayUrl}`; | |
| 47 | if (/^T3CODE_RELAY_URL=.*$/mu.test(contents)) { | |
| 48 | return contents.replace(/^T3CODE_RELAY_URL=.*$/mu, entry); | |
| 49 | } | |
| 50 | if (!contents) { | |
| 51 | return `${entry}\n`; | |
| 46 | export interface RelayPublicConfig { | |
| 47 | readonly relayUrl: string; | |
| 48 | readonly mobileTracingUrl: string; | |
| 49 | readonly mobileTracingDataset: string; | |
| 50 | readonly mobileTracingToken: string; | |
| 51 | } | |
| 52 | ||
| 53 | const 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 | ||
| 61 | export 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 | ||
| 79 | export 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 | } |
| 55 | 90 | |
| 56 | 91 | export function hasDeployChanges(plan: Plan.Plan): boolean { |
| ... | ... | @@ -68,7 +103,7 @@ export type RelayDeployResult = "applied" | "noop" | "dry-run" | "cancelled"; |
| 68 | 103 | export interface RelayDeployOutcome { |
| 69 | 104 | readonly result: RelayDeployResult; |
| 70 | 105 | readonly changed: boolean; |
| 71 | readonly relayUrl: Option.Option<string>; | |
| 106 | readonly publicConfig: Option.Option<RelayPublicConfig>; | |
| 72 | 107 | } |
| 73 | 108 | |
| 74 | 109 | export function serializeGithubOutput(entries: Readonly<Record<string, string | boolean>>): string { |
| ... | ... | @@ -106,15 +141,17 @@ const relayDeployStage = Config.nonEmptyString("stage").pipe( |
| 106 | 141 | ), |
| 107 | 142 | ); |
| 108 | 143 | |
| 109 | const reconcileRootEnv = Effect.fn("relay.deploy.reconcileRootEnv")(function* (relayUrl: string) { | |
| 144 | const reconcileRootEnv = Effect.fn("relay.deploy.reconcileRootEnv")(function* ( | |
| 145 | config: RelayPublicConfig, | |
| 146 | ) { | |
| 110 | 147 | const fs = yield* FileSystem.FileSystem; |
| 111 | 148 | const path = yield* Path.Path; |
| 112 | 149 | const root = yield* repoRoot; |
| 113 | 150 | const rootEnvPath = path.join(root, ".env"); |
| 114 | 151 | const contents = (yield* fs.exists(rootEnvPath)) ? yield* fs.readFileString(rootEnvPath) : ""; |
| 115 | 152 | |
| 116 | yield* fs.writeFileString(rootEnvPath, reconcileRootEnvRelayUrl(contents, relayUrl)); | |
| 117 | yield* Console.log(`Updated ${rootEnvPath} with T3CODE_RELAY_URL=${relayUrl}`); | |
| 153 | yield* fs.writeFileString(rootEnvPath, reconcileRootEnvPublicConfig(contents, config)); | |
| 154 | yield* Console.log(`Updated ${rootEnvPath} with relay URL and mobile tracing configuration`); | |
| 118 | 155 | }); |
| 119 | 156 | |
| 120 | 157 | const writeGithubOutput = Effect.fn("relay.deploy.writeGithubOutput")(function* ( |
| ... | ... | @@ -127,7 +164,9 @@ const writeGithubOutput = Effect.fn("relay.deploy.writeGithubOutput")(function* |
| 127 | 164 | serializeGithubOutput({ |
| 128 | 165 | changed: outcome.changed, |
| 129 | 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 | 171 | { flag: "a" }, |
| 133 | 172 | ); |
| ... | ... | @@ -160,7 +199,7 @@ const runRelayDeploy = Effect.fn("relay.deploy.run")( |
| 160 | 199 | return { |
| 161 | 200 | result: "dry-run", |
| 162 | 201 | changed, |
| 163 | relayUrl: Option.none<string>(), | |
| 202 | publicConfig: Option.none<RelayPublicConfig>(), | |
| 164 | 203 | } satisfies RelayDeployOutcome; |
| 165 | 204 | } |
| 166 | 205 | if (!options.yes && changed) { |
| ... | ... | @@ -175,20 +214,30 @@ const runRelayDeploy = Effect.fn("relay.deploy.run")( |
| 175 | 214 | return { |
| 176 | 215 | result: "cancelled", |
| 177 | 216 | changed, |
| 178 | relayUrl: Option.none<string>(), | |
| 217 | publicConfig: Option.none<RelayPublicConfig>(), | |
| 179 | 218 | } satisfies RelayDeployOutcome; |
| 180 | 219 | } |
| 181 | 220 | } |
| 182 | 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 | 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 | 232 | return { |
| 189 | 233 | result: changed ? "applied" : "noop", |
| 190 | 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 | 241 | } satisfies RelayDeployOutcome; |
| 193 | 242 | }, |
| 194 | 243 | (effect, options, configProvider, stage) => |
| ... | ... | @@ -216,8 +265,8 @@ export const deploy = Effect.fn("relay.deploy")(function* (options: RelayDeployO |
| 216 | 265 | ); |
| 217 | 266 | const stage = Option.getOrElse(options.stage, () => configuredStage); |
| 218 | 267 | const outcome = yield* runRelayDeploy(options, configProvider, stage); |
| 219 | if (Option.isSome(outcome.relayUrl)) { | |
| 220 | yield* reconcileRootEnv(outcome.relayUrl.value); | |
| 268 | if (Option.isSome(outcome.publicConfig)) { | |
| 269 | yield* reconcileRootEnv(outcome.publicConfig.value); | |
| 221 | 270 | } |
| 222 | 271 | if (options.githubOutput) { |
| 223 | 272 | yield* writeGithubOutput(outcome); |
infra/relay/src/agentActivity/AgentActivityPublisher.ts+7-2| ... | ... | @@ -101,8 +101,13 @@ const make = Effect.gen(function* () { |
| 101 | 101 | "relay.mobile.device_id": input.deviceId, |
| 102 | 102 | "relay.operation": "replayForLiveActivityRegistration", |
| 103 | 103 | }); |
| 104 | const activeStates = yield* rows.listForUser({ userId: input.userId }); | |
| 105 | const targets = yield* liveActivities.listTargets({ userId: input.userId }); | |
| 104 | const { activeStates, targets } = yield* Effect.all( | |
| 105 | { | |
| 106 | activeStates: rows.listForUser({ userId: input.userId }), | |
| 107 | targets: liveActivities.listTargets({ userId: input.userId }), | |
| 108 | }, | |
| 109 | { concurrency: 2 }, | |
| 110 | ); | |
| 106 | 111 | const target = targets.find((row) => row.device_id === input.deviceId) ?? null; |
| 107 | 112 | if (target === null) { |
| 108 | 113 | return null; |
infra/relay/src/db.ts+1-1| ... | ... | @@ -63,6 +63,6 @@ export const RelayHyperdrive = Effect.gen(function* () { |
| 63 | 63 | caching: { |
| 64 | 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 | 14 | import { describe, expect, it } from "@effect/vitest"; |
| 15 | 15 | import * as DateTime from "effect/DateTime"; |
| 16 | 16 | import { RELAY_HEALTH_RESPONSE_TYP, RELAY_MINT_RESPONSE_TYP } from "@t3tools/shared/relayJwt"; |
| 17 | import * as Deferred from "effect/Deferred"; | |
| 17 | 18 | import * as Duration from "effect/Duration"; |
| 18 | 19 | import * as Effect from "effect/Effect"; |
| 19 | 20 | import * as Fiber from "effect/Fiber"; |
| 20 | 21 | import * as Layer from "effect/Layer"; |
| 21 | 22 | import * as Redacted from "effect/Redacted"; |
| 23 | import * as Ref from "effect/Ref"; | |
| 22 | 24 | import * as Result from "effect/Result"; |
| 23 | 25 | import * as Schema from "effect/Schema"; |
| 24 | 26 | import * as TestClock from "effect/testing/TestClock"; |
| ... | ... | @@ -225,6 +227,58 @@ function makeLinks( |
| 225 | 227 | } |
| 226 | 228 | |
| 227 | 229 | describe("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 | 282 | it.effect("checks linked environment health through the managed endpoint", () => { |
| 229 | 283 | const seenUrls: Array<string> = []; |
| 230 | 284 | const seenProofs: Array<RelayCloudEnvironmentHealthProofPayload> = []; |
infra/relay/src/environments/EnvironmentConnector.ts+29-19| ... | ... | @@ -295,9 +295,9 @@ const make = Effect.gen(function* () { |
| 295 | 295 | ); |
| 296 | 296 | const resolveManagedEndpoint = Effect.fn("relay.environment_connector.resolve_managed_endpoint")( |
| 297 | 297 | function* (input: { |
| 298 | readonly userId: string; | |
| 299 | 298 | readonly operation: "connect" | "status"; |
| 300 | 299 | readonly link: EnvironmentLinks.RelayLinkedEnvironmentRecord; |
| 300 | readonly allocation: ManagedEndpointAllocations.ManagedEndpointAllocation | null; | |
| 301 | 301 | }) { |
| 302 | 302 | if (input.link.endpoint.providerKind !== "cloudflare_tunnel") { |
| 303 | 303 | yield* Effect.annotateCurrentSpan({ |
| ... | ... | @@ -309,11 +309,7 @@ const make = Effect.gen(function* () { |
| 309 | 309 | reason: "endpoint_provider_not_managed", |
| 310 | 310 | }); |
| 311 | 311 | } |
| 312 | const allocation = yield* allocations.get({ | |
| 313 | userId: input.userId, | |
| 314 | environmentId: input.link.environmentId, | |
| 315 | }); | |
| 316 | if (!allocation) { | |
| 312 | if (!input.allocation) { | |
| 317 | 313 | return yield* new EnvironmentConnectNotAuthorized({ |
| 318 | 314 | environmentId: input.link.environmentId, |
| 319 | 315 | operation: input.operation, |
| ... | ... | @@ -321,10 +317,10 @@ const make = Effect.gen(function* () { |
| 321 | 317 | }); |
| 322 | 318 | } |
| 323 | 319 | const allocationAttributes = { |
| 324 | "relay.authorization.allocation_hostname": allocation.hostname, | |
| 325 | "relay.authorization.allocation_has_ready_at": allocation.readyAt !== null, | |
| 326 | "relay.authorization.allocation_has_tunnel_id": allocation.tunnelId !== null, | |
| 327 | "relay.authorization.allocation_has_dns_record_id": allocation.dnsRecordId !== null, | |
| 320 | "relay.authorization.allocation_hostname": input.allocation.hostname, | |
| 321 | "relay.authorization.allocation_has_ready_at": input.allocation.readyAt !== null, | |
| 322 | "relay.authorization.allocation_has_tunnel_id": input.allocation.tunnelId !== null, | |
| 323 | "relay.authorization.allocation_has_dns_record_id": input.allocation.dnsRecordId !== null, | |
| 328 | 324 | } as const; |
| 329 | 325 | if (!settings.managedEndpointBaseDomain) { |
| 330 | 326 | yield* Effect.annotateCurrentSpan(allocationAttributes); |
| ... | ... | @@ -335,9 +331,9 @@ const make = Effect.gen(function* () { |
| 335 | 331 | }); |
| 336 | 332 | } |
| 337 | 333 | if ( |
| 338 | allocation.readyAt === null || | |
| 339 | allocation.tunnelId === null || | |
| 340 | allocation.dnsRecordId === null | |
| 334 | input.allocation.readyAt === null || | |
| 335 | input.allocation.tunnelId === null || | |
| 336 | input.allocation.dnsRecordId === null | |
| 341 | 337 | ) { |
| 342 | 338 | yield* Effect.annotateCurrentSpan(allocationAttributes); |
| 343 | 339 | return yield* new EnvironmentConnectNotAuthorized({ |
| ... | ... | @@ -346,7 +342,9 @@ const make = Effect.gen(function* () { |
| 346 | 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 | 348 | yield* Effect.annotateCurrentSpan({ |
| 351 | 349 | ...allocationAttributes, |
| 352 | 350 | "relay.authorization.managed_endpoint_base_domain": settings.managedEndpointBaseDomain, |
| ... | ... | @@ -358,7 +356,7 @@ const make = Effect.gen(function* () { |
| 358 | 356 | }); |
| 359 | 357 | } |
| 360 | 358 | const endpoint = ManagedEndpointAllocations.resolveReadyManagedEndpoint({ |
| 361 | allocation, | |
| 359 | allocation: input.allocation, | |
| 362 | 360 | baseDomain: settings.managedEndpointBaseDomain, |
| 363 | 361 | }); |
| 364 | 362 | if ( |
| ... | ... | @@ -393,7 +391,13 @@ const make = Effect.gen(function* () { |
| 393 | 391 | "relay.environment_id": input.environmentId, |
| 394 | 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 | 401 | if (!link) { |
| 398 | 402 | return yield* new EnvironmentConnectNotAuthorized({ |
| 399 | 403 | environmentId: input.environmentId, |
| ... | ... | @@ -402,9 +406,9 @@ const make = Effect.gen(function* () { |
| 402 | 406 | }); |
| 403 | 407 | } |
| 404 | 408 | const endpoint = yield* resolveManagedEndpoint({ |
| 405 | userId: input.userId, | |
| 406 | 409 | operation: "status", |
| 407 | 410 | link, |
| 411 | allocation, | |
| 408 | 412 | }); |
| 409 | 413 | const now = yield* DateTime.now; |
| 410 | 414 | const expiresAt = DateTime.add(now, { minutes: 2 }); |
| ... | ... | @@ -518,7 +522,13 @@ const make = Effect.gen(function* () { |
| 518 | 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 | 532 | if (!link) { |
| 523 | 533 | return yield* new EnvironmentConnectNotAuthorized({ |
| 524 | 534 | environmentId: input.environmentId, |
| ... | ... | @@ -527,9 +537,9 @@ const make = Effect.gen(function* () { |
| 527 | 537 | }); |
| 528 | 538 | } |
| 529 | 539 | const endpoint = yield* resolveManagedEndpoint({ |
| 530 | userId: input.userId, | |
| 531 | 540 | operation: "connect", |
| 532 | 541 | link, |
| 542 | allocation, | |
| 533 | 543 | }); |
| 534 | 544 | const now = yield* DateTime.now; |
| 535 | 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 | 181 | const request = HttpServerRequest.fromWeb( |
| 182 | 182 | new Request("https://relay.test/v1/mobile/devices?client=mobile", { |
| 183 | 183 | method: "POST", |
| 184 | headers: { | |
| 185 | authorization: "Bearer secret", | |
| 186 | dpop: "signed-proof", | |
| 187 | }, | |
| 184 | 188 | }), |
| 185 | 189 | ); |
| 186 | 190 | |
| ... | ... | @@ -192,6 +196,8 @@ describe("relay request tracing", () => { |
| 192 | 196 | expect(spans[0]?.kind).toBe("server"); |
| 193 | 197 | expect(spans[0]?.attributes.get("url.path")).toBe("/v1/mobile/devices"); |
| 194 | 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 | 201 | expect(Option.isNone(spans[0]!.parent)).toBe(true); |
| 196 | 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 | 3 | import * as Crypto from "effect/Crypto"; |
| 4 | 4 | import * as Context from "effect/Context"; |
| 5 | 5 | import * as DateTime from "effect/DateTime"; |
| 6 | import * as Duration from "effect/Duration"; | |
| 6 | 7 | import * as Effect from "effect/Effect"; |
| 7 | 8 | import * as Layer from "effect/Layer"; |
| 8 | 9 | import * as Record from "effect/Record"; |
| ... | ... | @@ -18,6 +19,7 @@ import * as HttpTraceContext from "effect/unstable/http/HttpTraceContext"; |
| 18 | 19 | import * as HttpApiBuilder from "effect/unstable/httpapi/HttpApiBuilder"; |
| 19 | 20 | import * as HttpApiError from "effect/unstable/httpapi/HttpApiError"; |
| 20 | 21 | import { encodeOAuthScope } from "@t3tools/shared/oauthScope"; |
| 22 | import { httpHeaderRedactionLayer } from "@t3tools/shared/httpObservability"; | |
| 21 | 23 | |
| 22 | 24 | import { |
| 23 | 25 | RelayApi, |
| ... | ... | @@ -67,6 +69,7 @@ import { withSpanAttributes } from "../observability.ts"; |
| 67 | 69 | import { RelayDb } from "../db.ts"; |
| 68 | 70 | |
| 69 | 71 | const relayCorsAllowedMethods = ["GET", "POST", "DELETE", "OPTIONS"] as const; |
| 72 | const RELAY_DPOP_ACCESS_TOKEN_TTL = "30 minutes"; | |
| 70 | 73 | const relayCorsAllowedHeaders = [ |
| 71 | 74 | "authorization", |
| 72 | 75 | "b3", |
| ... | ... | @@ -74,11 +77,7 @@ const relayCorsAllowedHeaders = [ |
| 74 | 77 | "content-type", |
| 75 | 78 | "dpop", |
| 76 | 79 | ] as const; |
| 77 | const relayCorsExposedHeaders = [ | |
| 78 | "traceparent", | |
| 79 | "x-t3-relay-auth-failure", | |
| 80 | "www-authenticate", | |
| 81 | ] as const; | |
| 80 | const relayCorsExposedHeaders = ["traceparent", "www-authenticate"] as const; | |
| 82 | 81 | |
| 83 | 82 | const relayCorsHeaders = { |
| 84 | 83 | "access-control-allow-origin": "*", |
| ... | ... | @@ -176,7 +175,10 @@ export const traceRelayHttpRequestWith = <E, R, LayerError, LayerRequirements>( |
| 176 | 175 | HttpServerRequest.HttpServerRequest | R |
| 177 | 176 | >, |
| 178 | 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 | ); | |
| 180 | 182 | |
| 181 | 183 | export const withoutCapturedParentSpan = <A, E, R>( |
| 182 | 184 | effect: Effect.Effect<A, E, R>, |
| ... | ... | @@ -351,12 +353,7 @@ export const healthApi = HttpApiBuilder.group( |
| 351 | 353 | "health", |
| 352 | 354 | Effect.fn("relay.api.health")( |
| 353 | 355 | function* () { |
| 354 | const startedAt = yield* Effect.clockWith((clock) => clock.currentTimeMillis); | |
| 355 | 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 | 357 | return { ok: true, service: "relay" as const }; |
| 361 | 358 | }, |
| 362 | 359 | Effect.catch(() => relayInternalErrorResponse("database_unavailable")), |
| ... | ... | @@ -602,7 +599,7 @@ export const tokenApi = HttpApiBuilder.group( |
| 602 | 599 | Effect.provideService(DpopProofs.DpopProofReplay, dpopProofs), |
| 603 | 600 | ); |
| 604 | 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 | 603 | const jti = yield* crypto.randomUUIDv4.pipe( |
| 607 | 604 | Effect.catch(() => relayInternalErrorResponse("internal_error")), |
| 608 | 605 | ); |
| ... | ... | @@ -620,7 +617,7 @@ export const tokenApi = HttpApiBuilder.group( |
| 620 | 617 | .pipe(Effect.catch(() => relayInternalErrorResponse("internal_error"))), |
| 621 | 618 | issued_token_type: RelayAccessTokenType, |
| 622 | 619 | token_type: "DPoP" as const, |
| 623 | expires_in: 300, | |
| 620 | expires_in: Duration.toSeconds(RELAY_DPOP_ACCESS_TOKEN_TTL), | |
| 624 | 621 | scope: encodeOAuthScope(requestedScopes), |
| 625 | 622 | }; |
| 626 | 623 | }, mapRelayCommonApiErrors("invalid_dpop")), |
infra/relay/src/observability.ts+10-2| ... | ... | @@ -34,7 +34,7 @@ export const RelayObservability = Effect.gen(function* () { |
| 34 | 34 | useRetentionPeriod: true, |
| 35 | 35 | }); |
| 36 | 36 | |
| 37 | const ingestToken = yield* Axiom.ApiToken("RelayAxiomIngestToken", { | |
| 37 | const workerIngestToken = yield* Axiom.ApiToken("RelayWorkerAxiomIngestToken", { | |
| 38 | 38 | name: relayResourceNameForStage("t3-code-relay-otel-ingest", stage), |
| 39 | 39 | description: "Owned by Alchemy. Scoped OTLP ingest token for relay HTTP spans.", |
| 40 | 40 | datasetCapabilities: Output.map(traces.name, (dataset) => ({ |
| ... | ... | @@ -42,6 +42,14 @@ export const RelayObservability = Effect.gen(function* () { |
| 42 | 42 | })), |
| 43 | 43 | }); |
| 44 | 44 | |
| 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 | 53 | yield* Axiom.View("RelayRecentSpansView", { |
| 46 | 54 | name: relayResourceNameForStage("t3-code-relay-recent-spans", stage), |
| 47 | 55 | description: "Recent relay HTTP request spans.", |
| ... | ... | @@ -49,7 +57,7 @@ export const RelayObservability = Effect.gen(function* () { |
| 49 | 57 | aplQuery: Output.map(traces.name, relayRecentSpansQuery), |
| 50 | 58 | }); |
| 51 | 59 | |
| 52 | return { traces, ingestToken } as const; | |
| 60 | return { traces, workerIngestToken, mobileIngestToken } as const; | |
| 53 | 61 | }); |
| 54 | 62 | |
| 55 | 63 | export const withSpanAttributes = |
infra/relay/src/worker.ts+1-1| ... | ... | @@ -129,7 +129,7 @@ export default class Api extends Cloudflare.Worker<Api>()( |
| 129 | 129 | const apnsDeliveryQueueSender = yield* Cloudflare.QueueBinding.bind(apnsDeliveryQueue); |
| 130 | 130 | |
| 131 | 131 | const axiomDatasetName = yield* observability.traces.name; |
| 132 | const axiomIngestToken = yield* observability.ingestToken.token; | |
| 132 | const axiomIngestToken = yield* observability.workerIngestToken.token; | |
| 133 | 133 | const axiomTracesEndpoint = yield* observability.traces.otelTracesEndpoint; |
| 134 | 134 | |
| 135 | 135 | const clerkSecretKey = yield* Config.redacted("CLERK_SECRET_KEY"); |
oxlint-plugin-t3code/index.ts+2| ... | ... | @@ -1,6 +1,7 @@ |
| 1 | 1 | import { definePlugin } from "@oxlint/plugins"; |
| 2 | 2 | |
| 3 | 3 | import noInlineSchemaCompile from "./rules/no-inline-schema-compile.ts"; |
| 4 | import noManualEffectRuntimeInTests from "./rules/no-manual-effect-runtime-in-tests.ts"; | |
| 4 | 5 | |
| 5 | 6 | export default definePlugin({ |
| 6 | 7 | meta: { |
| ... | ... | @@ -8,5 +9,6 @@ export default definePlugin({ |
| 8 | 9 | }, |
| 9 | 10 | rules: { |
| 10 | 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 @@ |
| 1 | import { assert, describe } from "@effect/vitest"; | |
| 2 | ||
| 3 | import { createOxlintRuleHarness } from "../test/utils.ts"; | |
| 4 | ||
| 5 | const rule = createOxlintRuleHarness("t3code/no-manual-effect-runtime-in-tests", { | |
| 6 | filename: "fixture.test.ts", | |
| 7 | }); | |
| 8 | ||
| 9 | describe("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 | ||
| 64 | const productionRule = createOxlintRuleHarness("t3code/no-manual-effect-runtime-in-tests"); | |
| 65 | ||
| 66 | productionRule.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 @@ |
| 1 | import { defineRule } from "@oxlint/plugins"; | |
| 2 | import * as Option from "effect/Option"; | |
| 3 | ||
| 4 | import { getPropertyName, isIdentifier, unwrapExpression } from "../utils.ts"; | |
| 5 | ||
| 6 | const TEST_FILE_PATTERN = /\.(?:test|spec)\.[cm]?[jt]sx?$/u; | |
| 7 | const 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. | |
| 24 | const 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 | ||
| 55 | const 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 | ||
| 63 | const 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 | ||
| 84 | export 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 | 48 | readonly invalid: (name: string, source: string, assertion?: (output: string) => void) => void; |
| 49 | 49 | } |
| 50 | 50 | |
| 51 | interface RuleHarnessOptions { | |
| 52 | readonly filename?: string; | |
| 53 | } | |
| 54 | ||
| 51 | 55 | const collectStreamAsString = <E>(stream: Stream.Stream<Uint8Array, E>): Effect.Effect<string, E> => |
| 52 | 56 | stream.pipe( |
| 53 | 57 | Stream.decodeText(), |
| ... | ... | @@ -73,7 +77,10 @@ const spawnAndCollectOutput = Effect.fnUntraced(function* (command: ChildProcess |
| 73 | 77 | return { exitCode, stdout, stderr }; |
| 74 | 78 | }, Effect.scoped); |
| 75 | 79 | |
| 76 | export const createOxlintRuleHarness = (ruleName: string): RuleHarness => { | |
| 80 | export const createOxlintRuleHarness = ( | |
| 81 | ruleName: string, | |
| 82 | options: RuleHarnessOptions = {}, | |
| 83 | ): RuleHarness => { | |
| 77 | 84 | const [pluginName, shortRuleName] = ruleName.split("/"); |
| 78 | 85 | const diagnosticRuleName = |
| 79 | 86 | pluginName && shortRuleName ? `${pluginName}\\(${shortRuleName}\\)` : ruleName; |
| ... | ... | @@ -84,7 +91,7 @@ export const createOxlintRuleHarness = (ruleName: string): RuleHarness => { |
| 84 | 91 | const path = yield* Path.Path; |
| 85 | 92 | const fixtureDir = yield* fs.makeTempDirectoryScoped({ prefix: "t3code-oxlint-" }); |
| 86 | 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 | 95 | const repoRoot = path.join(import.meta.dirname, "..", ".."); |
| 89 | 96 | const oxlintBin = path.join( |
| 90 | 97 | repoRoot, |
packages/client-runtime/src/remote.ts+5-1| ... | ... | @@ -15,6 +15,7 @@ import type { |
| 15 | 15 | EnvironmentScopeRequiredError, |
| 16 | 16 | } from "@t3tools/contracts"; |
| 17 | 17 | import { encodeOAuthScope } from "@t3tools/shared/oauthScope"; |
| 18 | import { httpHeaderRedactionLayer } from "@t3tools/shared/httpObservability"; | |
| 18 | 19 | import * as Data from "effect/Data"; |
| 19 | 20 | import * as Duration from "effect/Duration"; |
| 20 | 21 | import * as Effect from "effect/Effect"; |
| ... | ... | @@ -111,7 +112,10 @@ export type RemoteEnvironmentAuthError = |
| 111 | 112 | export const remoteHttpClientLayer = ( |
| 112 | 113 | fetchFn: typeof globalThis.fetch, |
| 113 | 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 | ); | |
| 115 | 119 | |
| 116 | 120 | const failRemoteRequest = ( |
| 117 | 121 | requestUrl: string, |
packages/shared/package.json+4| ... | ... | @@ -31,6 +31,10 @@ |
| 31 | 31 | "types": "./src/observability.ts", |
| 32 | 32 | "import": "./src/observability.ts" |
| 33 | 33 | }, |
| 34 | "./httpObservability": { | |
| 35 | "types": "./src/httpObservability.ts", | |
| 36 | "import": "./src/httpObservability.ts" | |
| 37 | }, | |
| 34 | 38 | "./shell": { |
| 35 | 39 | "types": "./src/shell.ts", |
| 36 | 40 | "import": "./src/shell.ts" |
packages/shared/src/httpObservability.ts created+8| ... | ... | @@ -0,0 +1,8 @@ |
| 1 | import * as Effect from "effect/Effect"; | |
| 2 | import * as Layer from "effect/Layer"; | |
| 3 | import * as Headers from "effect/unstable/http/Headers"; | |
| 4 | ||
| 5 | export 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 | 27 | expect(env.EXPO_PUBLIC_CLERK_JWT_TEMPLATE).toBeUndefined(); |
| 28 | 28 | expect(env.T3CODE_RELAY_URL).toBeUndefined(); |
| 29 | 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 | }); |
| 31 | 37 | |
| 32 | 38 | it("applies process, root local, and root precedence in that order", () => { |
| ... | ... | @@ -73,26 +79,41 @@ describe("loadRepoEnv", () => { |
| 73 | 79 | VITE_CLERK_JWT_TEMPLATE: "template_legacy", |
| 74 | 80 | T3CODE_CLERK_CLI_OAUTH_CLIENT_ID: "oauth_canonical", |
| 75 | 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 | 86 | ).toEqual({ |
| 78 | 87 | clerkPublishableKey: "pk_legacy", |
| 79 | 88 | clerkJwtTemplate: "template_legacy", |
| 80 | 89 | clerkCliOAuthClientId: "oauth_canonical", |
| 81 | 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 | }); |
| 84 | 96 | |
| 85 | it("projects only the configured aliases", () => { | |
| 97 | it("projects canonical mobile tracing values to Expo public aliases", () => { | |
| 86 | 98 | expect( |
| 87 | 99 | loadRepoEnv({ |
| 88 | 100 | baseEnv: { |
| 89 | 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 | 106 | repoRoot: makeTemporaryDirectory(), |
| 92 | 107 | }), |
| 93 | 108 | ).toEqual({ |
| 94 | 109 | T3CODE_RELAY_URL: "https://relay.example.test", |
| 95 | 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 | 9 | readonly clerkJwtTemplate: string | undefined; |
| 10 | 10 | readonly clerkCliOAuthClientId: string | undefined; |
| 11 | 11 | readonly relayUrl: string | undefined; |
| 12 | readonly mobileOtlpTracesUrl: string | undefined; | |
| 13 | readonly mobileOtlpTracesDataset: string | undefined; | |
| 14 | readonly mobileOtlpTracesToken: string | undefined; | |
| 12 | 15 | } |
| 13 | 16 | |
| 14 | 17 | type Environment = Readonly<Record<string, string | undefined>>; |
| ... | ... | @@ -57,6 +60,24 @@ export function loadRepoEnv({ |
| 57 | 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 | } |
| 62 | 83 | |
| ... | ... | @@ -76,6 +97,21 @@ export function resolvePublicConfig(...sources: readonly Environment[]): T3CodeP |
| 76 | 97 | ), |
| 77 | 98 | clerkCliOAuthClientId: firstNonEmpty(sources, "T3CODE_CLERK_CLI_OAUTH_CLIENT_ID"), |
| 78 | 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 | } |
| 81 | 117 |
vite.config.ts+1| ... | ... | @@ -91,6 +91,7 @@ export default defineConfig({ |
| 91 | 91 | "typescript/restrict-template-expressions": "off", |
| 92 | 92 | "typescript/unbound-method": "off", |
| 93 | 93 | "t3code/no-inline-schema-compile": "warn", |
| 94 | "t3code/no-manual-effect-runtime-in-tests": "error", | |
| 94 | 95 | }, |
| 95 | 96 | options: { |
| 96 | 97 | // Revisit once Oxlint's tsgolint path can integrate with @effect/tsgo diagnostics. |