Skip to content
Source
Sign inSign up

clientConfig.ts

117 lines5.0 KB

1// @effect-diagnostics nodeBuiltinImport:off - one sha256 over two strings; Effect.Crypto is async and the digest feeds a synchronous Output.map.
2import * as NodeCrypto from "node:crypto";
3​
4import * as Alchemy from "alchemy";
5import * as Config from "effect/Config";
6import * as Console from "effect/Console";
7import * as Effect from "effect/Effect";
8import * as FileSystem from "effect/FileSystem";
9import * as Option from "effect/Option";
10import * as Path from "effect/Path";
11import * as Redacted from "effect/Redacted";
12import * as Schema from "effect/Schema";
13​
14export interface RelayClientConfig {
15 readonly url: string | undefined;
16 readonly mobileTracingUrl: string;
17 readonly mobileTracingDataset: string;
18 readonly mobileTracingToken: Redacted.Redacted<string>;
19 readonly clientTracingUrl: string;
20 readonly clientTracingDataset: string;
21 readonly clientTracingToken: Redacted.Redacted<string>;
22 /** Alchemy hashes JSON inputs; Redacted hides token rotation, so hash the tokens without persisting secrets. */
23 readonly tokenDigest: string;
25​
26export class RelayUrlUnavailableError extends Schema.TaggedError<RelayUrlUnavailableError>()(
27 "RelayUrlUnavailableError",
28 {},
29) {
30 override get message(): string {
31 return "The relay worker has no URL yet; deploy again once the worker exists.";
32 }
34​
35const relayClientConfigEnv = (config: RelayClientConfig & { readonly url: string }) =>
36 ({
37 T3CODE_RELAY_URL: config.url,
38 T3CODE_MOBILE_OTLP_TRACES_URL: config.mobileTracingUrl,
39 T3CODE_MOBILE_OTLP_TRACES_DATASET: config.mobileTracingDataset,
40 T3CODE_MOBILE_OTLP_TRACES_TOKEN: Redacted.value(config.mobileTracingToken),
41 T3CODE_RELAY_CLIENT_OTLP_TRACES_URL: config.clientTracingUrl,
42 T3CODE_RELAY_CLIENT_OTLP_TRACES_DATASET: config.clientTracingDataset,
43 T3CODE_RELAY_CLIENT_OTLP_TRACES_TOKEN: Redacted.value(config.clientTracingToken),
44 }) as const;
45​
46export class EnvValueNotSingleLineError extends Schema.TaggedError<EnvValueNotSingleLineError>()(
47 "EnvValueNotSingleLineError",
48 { key: Schema.String },
49) {
50 override get message(): string {
51 return `${this.key} contains a line break and cannot be written as one .env assignment.`;
52 }
54​
55/** Remove every duplicate: parseEnv takes the last value, which could otherwise override the update. */
56export function reconcileEnvFile(
57 contents: string,
58 entries: Readonly<Record<string, string>>,
59): string {
60 const lines = contents === "" ? [] : contents.replace(/\n$/u, "").split("\n");
61 const assignment = /^\s*(?:export\s+)?([A-Za-z_][A-Za-z0-9_]*)\s*=\s*(.*)$/u;
62 const pending = new Map(Object.entries(entries));
63 const out: string[] = [];
64 for (let index = 0; index < lines.length; index += 1) {
65 const line = lines[index]!;
66 const match = assignment.exec(line);
67 const name = match?.[1];
68 if (match === null || name === undefined || !(name in entries)) {
69 out.push(line);
70 continue;
71 }
72 // Multiline assignments end at their closing quote; parseEnv consumes only the opening line if it is unclosed.
73 const rawValue = match[2] ?? "";
74 const quote = /^(['"`])/u.exec(rawValue)?.[1];
75 if (quote !== undefined && !closesQuote(rawValue, quote)) {
76 const closing = lines.findIndex((candidate, at) => at > index && candidate.includes(quote));
77 if (closing !== -1) index = closing;
78 }
79 const value = pending.get(name);
80 if (value !== undefined) {
81 out.push(`${name}=${value}`);
82 pending.delete(name);
83 }
84 }
85 for (const [name, value] of pending) out.push(`${name}=${value}`);
86 return out.length === 0 ? "" : `${out.join("\n")}\n`;
88​
89const closesQuote = (value: string, quote: string): boolean =>
90 value.length > 1 && value.slice(1).includes(quote);
91​
92export const tokenDigest = (tokens: ReadonlyArray<Redacted.Redacted<string>>): string =>
93 NodeCrypto.createHash("sha256").update(tokens.map(Redacted.value).join("\n")).digest("hex");
94​
95export const PublishClientConfig = Alchemy.Action(
96 "PublishClientConfig",
97 Effect.gen(function* () {
98 const fs = yield* FileSystem.FileSystem;
99 const path = yield* Path.Path;
100 const override = yield* Config.String("T3CODE_RELAY_CLIENT_CONFIG_ENV").pipe(Config.option);
101 const repoRootEnv = path.fromFileUrl(new URL("../../../.env", import.meta.url));
102 const target = Option.isSome(override) ? override.value : yield* repoRootEnv;
103 return Effect.fn(function* (input: RelayClientConfig) {
104 const url = input.url;
105 if (url === undefined) return yield* new RelayUrlUnavailableError();
106 const entries = relayClientConfigEnv({ ...input, url });
107 // Reject line breaks in provider values to prevent injected assignments.
108 for (const [name, value] of Object.entries(entries)) {
109 if (/[\r\n]/u.test(value)) return yield* new EnvValueNotSingleLineError({ key: name });
110 }
111 const existing = (yield* fs.exists(target)) ? yield* fs.readFileString(target) : "";
112 yield* fs.writeFileString(target, reconcileEnvFile(existing, entries));
113 yield* Console.log(`Wrote relay client configuration to ${target}`);
114 return { path: target };
115 });
116 }),
117);