History

feat(cli): support env-backed server headers (#1539)

brosand committed 3 months ago

Commit 01663af1 parent 8ba64f610 files · +538 −43Browse files

Showing the changes to apps/cli/src (5 of 10 files). Show all

Unified · Split

5 files
  1. apps/cli/src/device-login.test.ts +118 −2
  2. apps/cli/src/device-login.ts +57 −8
  3. apps/cli/src/main.ts +100 −32
  4. apps/cli/src/server-profile.test.ts +15 −0
  5. apps/cli/src/server-profile.ts +6 −0
apps/cli/src/device-login.test.ts+118−2
@@ -1,6 +1,44 @@
1−import { describe, expect, it } from "@effect/vitest";
1+import { afterEach, describe, expect, it } from "@effect/vitest";
22
3−import { browserOpenCommand } from "./device-login";
3+import {
4+ browserOpenCommand,
5+ discoverCliLogin,
6+ refreshDeviceTokens,
7+ requestDeviceCode,
8+ type CliLoginDiscovery,
9+} from "./device-login";
10+
11+const originalFetch = globalThis.fetch;
12+
13+interface FetchCall {
14+ readonly url: string;
15+ readonly headers: Record<string, string>;
16+}
17+
18+const responseJson = (body: Record<string, unknown>, status = 200): Response =>
19+ new Response(JSON.stringify(body), {
20+ status,
21+ headers: { "content-type": "application/json" },
22+ });
23+
24+const installFetch = (handler: (url: string, init: RequestInit | undefined) => Response) => {
25+ globalThis.fetch = ((input: RequestInfo | URL, init?: RequestInit) => {
26+ const url =
27+ typeof input === "string" ? input : input instanceof URL ? input.toString() : input.url;
28+ return Promise.resolve(handler(url, init));
29+ }) as typeof fetch;
30+};
31+
32+const recordCall = (calls: Array<FetchCall>, url: string, init: RequestInit | undefined): void => {
33+ calls.push({
34+ url,
35+ headers: Object.fromEntries(new Headers(init?.headers).entries()),
36+ });
37+};
38+
39+afterEach(() => {
40+ globalThis.fetch = originalFetch;
41+});
442
543 describe("browserOpenCommand", () => {
644 it("opens Windows browser URLs without cmd.exe", () => {
@@ -32,3 +70,81 @@ describe("browserOpenCommand", () => { · 25 unmodified lines
3270 expect(browserOpenCommand("not a url", "win32")).toBeUndefined();
3371 });
3472 });
73+
74+describe("device login headers", () => {
75+ it("sends configured headers when discovering CLI login", async () => {
76+ const calls: Array<FetchCall> = [];
77+ installFetch((url, init) => {
78+ recordCall(calls, url, init);
79+ return responseJson({
80+ provider: "better-auth",
81+ deviceAuthorizationEndpoint: "https://executor.example/api/auth/device/code",
82+ tokenEndpoint: "https://executor.example/api/auth/device/token",
83+ clientId: "executor-cli",
84+ requestFormat: "json",
85+ });
86+ });
87+
88+ const discovery = await discoverCliLogin("https://executor.example", {
89+ headers: { "CF-Access-Client-Id": "client-id" },
90+ });
91+
92+ expect(discovery.clientId).toBe("executor-cli");
93+ expect(calls).toHaveLength(1);
94+ expect(calls[0]?.url).toBe("https://executor.example/api/auth/cli-login");
95+ expect(calls[0]?.headers).toMatchObject({
96+ accept: "application/json",
97+ "cf-access-client-id": "client-id",
98+ });
99+ });
100+
101+ it("sends configured headers only to same-origin device endpoints", async () => {
102+ const calls: Array<FetchCall> = [];
103+ installFetch((url, init) => {
104+ recordCall(calls, url, init);
105+ if (url.endsWith("/api/auth/device/code")) {
106+ return responseJson({
107+ device_code: "device-code",
108+ user_code: "USER-CODE",
109+ verification_uri: "https://executor.example/device",
110+ expires_in: 300,
111+ interval: 5,
112+ });
113+ }
114+ return responseJson({
115+ access_token: "access-token",
116+ refresh_token: "refresh-token-2",
117+ expires_in: 600,
118+ });
119+ });
120+ const discovery: CliLoginDiscovery = {
121+ provider: "better-auth",
122+ deviceAuthorizationEndpoint: "https://executor.example/api/auth/device/code",
123+ tokenEndpoint: "https://accounts.example/oauth/token",
124+ clientId: "executor-cli",
125+ requestFormat: "form",
126+ };
127+ const headers = { "CF-Access-Client-Id": "client-id" };
128+
129+ await requestDeviceCode(discovery, { serverOrigin: "https://executor.example", headers });
130+ await refreshDeviceTokens({
131+ tokenEndpoint: discovery.tokenEndpoint,
132+ clientId: discovery.clientId,
133+ refreshToken: "refresh-token",
134+ serverOrigin: "https://executor.example",
135+ headers,
136+ });
137+
138+ expect(calls).toHaveLength(2);
139+ expect(calls[0]?.headers).toMatchObject({
140+ accept: "application/json",
141+ "content-type": "application/x-www-form-urlencoded",
142+ "cf-access-client-id": "client-id",
143+ });
144+ expect(calls[1]?.headers).toMatchObject({
145+ accept: "application/json",
146+ "content-type": "application/x-www-form-urlencoded",
147+ });
148+ expect(calls[1]?.headers["cf-access-client-id"]).toBeUndefined();
149+ });
150+});
apps/cli/src/device-login.ts+57−8
@@ -50,6 +50,15 @@ export interface DeviceTokens { · 49 unmodified lines
5050 readonly organizationId?: string;
5151 }
5252
53+export interface DeviceLoginHttpOptions {
54+ readonly headers?: Readonly<Record<string, string>>;
55+ readonly serverOrigin?: string;
56+}
57+
58+export interface PollForDeviceTokensOptions extends DeviceLoginHttpOptions {
59+ readonly now?: () => number;
60+}
61+
5362 const DEVICE_CODE_GRANT_TYPE = "urn:ietf:params:oauth:grant-type:device_code";
5463 const DEFAULT_INTERVAL_SECONDS = 5;
5564
@@ -126,17 +135,43 @@ const definedFields = (fields: Record<string, string | undefined>): Record<strin · 70 unmodified lines
126135 string
127136 >;
128137
138+const isSameOrigin = (url: string, origin: string): boolean => {
139+ try {
140+ return new URL(url).origin === new URL(origin).origin;
141+ } catch {
142+ return false;
143+ }
144+};
145+
146+const headersForUrl = (
147+ url: string,
148+ baseHeaders: Record<string, string>,
149+ options: DeviceLoginHttpOptions = {},
150+): Record<string, string> => {
151+ const configured =
152+ options.headers && (!options.serverOrigin || isSameOrigin(url, options.serverOrigin))
153+ ? options.headers
154+ : {};
155+ return { ...configured, ...baseHeaders };
156+};
157+
129158 const post = async (
130159 url: string,
131160 fields: Record<string, string | undefined>,
132161 format: "form" | "json",
162+ options: DeviceLoginHttpOptions = {},
133163 ) =>
134164 fetch(url, {
135165 method: "POST",
136− headers: {
137− "content-type": format === "json" ? "application/json" : "application/x-www-form-urlencoded",
138− accept: "application/json",
139− },
166+ headers: headersForUrl(
167+ url,
168+ {
169+ "content-type":
170+ format === "json" ? "application/json" : "application/x-www-form-urlencoded",
171+ accept: "application/json",
172+ },
173+ options,
174+ ),
140175 body: format === "json" ? JSON.stringify(definedFields(fields)) : formBody(fields),
141176 });
142177
@@ -150,10 +185,16 @@ const readJson = async (response: Response): Promise<Record<string, unknown>> => · 7 unmodified lines
150185 }
151186 };
152187
153−export const discoverCliLogin = async (origin: string): Promise<CliLoginDiscovery> => {
188+export const discoverCliLogin = async (
189+ origin: string,
190+ options: DeviceLoginHttpOptions = {},
191+): Promise<CliLoginDiscovery> => {
154192 let response: Response;
193+ const url = cliLoginUrl(origin);
155194 try {
156− response = await fetch(cliLoginUrl(origin), { headers: { accept: "application/json" } });
195+ response = await fetch(url, {
196+ headers: headersForUrl(url, { accept: "application/json" }, options),
197+ });
157198 } catch (cause) {
158199 throw new DeviceLoginError(
159200 `Could not reach ${origin} to start login: ${cause instanceof Error ? cause.message : String(cause)}`,
@@ -182,11 +223,15 @@ export const discoverCliLogin = async (origin: string): Promise<CliLoginDiscover · 22 unmodified lines
182223 };
183224 };
184225
185−export const requestDeviceCode = async (discovery: CliLoginDiscovery): Promise<DeviceCodeGrant> => {
226+export const requestDeviceCode = async (
227+ discovery: CliLoginDiscovery,
228+ options: DeviceLoginHttpOptions = {},
229+): Promise<DeviceCodeGrant> => {
186230 const response = await post(
187231 discovery.deviceAuthorizationEndpoint,
188232 { client_id: discovery.clientId, scope: discovery.scope },
189233 discovery.requestFormat,
234+ options,
190235 );
191236 const body = await readJson(response);
192237 if (!response.ok) {
@@ -220,7 +265,7 @@ const sleep = (ms: number): Promise<void> => new Promise((resolve) => setTimeout · 27 unmodified lines
220265 export const pollForDeviceTokens = async (
221266 discovery: CliLoginDiscovery,
222267 grant: DeviceCodeGrant,
223− options: { readonly now?: () => number } = {},
268+ options: PollForDeviceTokensOptions = {},
224269 ): Promise<DeviceTokens> => {
225270 const now = options.now ?? (() => Date.now());
226271 const deadline = now() + grant.expiresInSeconds * 1000;
@@ -240,6 +285,7 @@ export const pollForDeviceTokens = async ( · 13 unmodified lines
240285 client_id: discovery.clientId,
241286 },
242287 discovery.requestFormat,
288+ options,
243289 );
244290 const body = await readJson(response);
245291
@@ -286,6 +332,8 @@ export const refreshDeviceTokens = async (input: { · 40 unmodified lines
286332 readonly tokenEndpoint: string;
287333 readonly clientId: string;
288334 readonly refreshToken: string;
335+ readonly headers?: Readonly<Record<string, string>>;
336+ readonly serverOrigin?: string;
289337 }): Promise<DeviceTokens> => {
290338 const response = await post(
291339 input.tokenEndpoint,
@@ -295,6 +343,7 @@ export const refreshDeviceTokens = async (input: { · 3 unmodified lines
295343 client_id: input.clientId,
296344 },
297345 "form",
346+ input,
298347 );
299348 const body = await readJson(response);
300349 if (!response.ok) {
apps/cli/src/main.ts+100−32
@@ -84,10 +84,13 @@ import { · 83 unmodified lines
8484 getExecutorServerAuthorizationHeader,
8585 normalizeExecutorServerConnection,
8686 normalizeExecutorServerOrigin,
87+ resolveExecutorServerConfiguredHeaders,
88+ resolveExecutorServerRequestHeaders,
8789 type ExecutorLocalServerKind,
8890 type ExecutorLocalServerManifest,
8991 type ExecutorServerConnection,
9092 type ExecutorServerConnectionInput,
93+ type ExecutorServerHeaders,
9194 } from "@executor-js/sdk/shared";
9295 import {
9396 decodeAccessTokenClaims,
@@ -752,7 +755,7 @@ const OAUTH_REFRESH_SKEW_SECONDS = 60; · 658 unmodified lines
752755
753756 const refreshOAuthConnection = (
754757 connection: ExecutorServerConnection,
755−): Effect.Effect<ExecutorServerConnection, never, FileSystem.FileSystem | PlatformPath.Path> =>
758+): Effect.Effect<ExecutorServerConnection, Error, FileSystem.FileSystem | PlatformPath.Path> =>
756759 Effect.gen(function* () {
757760 const auth = connection.auth;
758761 if (!auth || auth.kind !== "oauth") return connection;
@@ -763,8 +766,19 @@ const refreshOAuthConnection = ( · 4 unmodified lines
763766 const { refreshToken, tokenEndpoint, clientId } = auth;
764767 if (!refreshToken || !tokenEndpoint || !clientId) return connection;
765768
769+ const headers = yield* resolveExecutorServerConfiguredHeaders(connection, process.env).pipe(
770+ Effect.mapError(toError),
771+ );
772+
766773 const refreshed = yield* Effect.tryPromise({
767− try: () => refreshDeviceTokens({ tokenEndpoint, clientId, refreshToken }),
774+ try: () =>
775+ refreshDeviceTokens({
776+ tokenEndpoint,
777+ clientId,
778+ refreshToken,
779+ serverOrigin: connection.origin,
780+ headers,
781+ }),
768782 catch: toError,
769783 // On a failed refresh, keep the existing token and let the eventual 401
770784 // surface, better than blocking the command on a transient hiccup.
@@ -1054,31 +1068,36 @@ const printExecutionOutcome = (input: { · 283 unmodified lines
10541068 // Typed API client
10551069 // ---------------------------------------------------------------------------
10561070
1057−const makeApiClient = (connection: ExecutorServerConnection, target: ServerTarget = {}) => {
1058− const authorization = getExecutorServerAuthorizationHeader(connection);
1059− return HttpApiClient.make(ExecutorApi, {
1060− baseUrl: connection.apiBaseUrl,
1061− ...(authorization
1062− ? {
1063− transformClient: HttpClient.mapRequest((request) =>
1064− HttpClientRequest.setHeader(request, "authorization", authorization),
1065− ),
1066− }
1067− : {}),
1068− // A 401 on an endpoint that doesn't model it is a sign-in problem: rewrite
1069− // the transport-level error into the login hint. Without this the client
1070− // fails decoding the unexpected status and prints the opaque
1071− // `Decode error (401 GET .../api/tools)`. Declared 401s (typed API errors)
1072− // decode before this catch and pass through untouched.
1073− transformResponse: (effect) =>
1074− Effect.catchIf(
1075− effect,
1076− (cause) => HttpClientError.isHttpClientError(cause) && cause.response?.status === 401,
1077− () =>
1078− Effect.fail(new Error(describeUnauthorizedCliServer({ connection, cliPrefix, target }))),
1079− ),
1071+const makeApiClient = (connection: ExecutorServerConnection, target: ServerTarget = {}) =>
1072+ Effect.gen(function* () {
1073+ const headers = yield* resolveExecutorServerRequestHeaders(connection, process.env).pipe(
1074+ Effect.mapError(toError),
1075+ );
1076+ return yield* HttpApiClient.make(ExecutorApi, {
1077+ baseUrl: connection.apiBaseUrl,
1078+ ...(Object.keys(headers).length > 0
1079+ ? {
1080+ transformClient: HttpClient.mapRequest((request) =>
1081+ HttpClientRequest.setHeaders(request, headers),
1082+ ),
1083+ }
1084+ : {}),
1085+ // A 401 on an endpoint that doesn't model it is a sign-in problem: rewrite
1086+ // the transport-level error into the login hint. Without this the client
1087+ // fails decoding the unexpected status and prints the opaque
1088+ // `Decode error (401 GET .../api/tools)`. Declared 401s (typed API errors)
1089+ // decode before this catch and pass through untouched.
1090+ transformResponse: (effect) =>
1091+ Effect.catchIf(
1092+ effect,
1093+ (cause) => HttpClientError.isHttpClientError(cause) && cause.response?.status === 401,
1094+ () =>
1095+ Effect.fail(
1096+ new Error(describeUnauthorizedCliServer({ connection, cliPrefix, target })),
1097+ ),
1098+ ),
1099+ });
10801100 }).pipe(Effect.provide(FetchHttpClient.layer));
1081−};
10821101
10831102 // ---------------------------------------------------------------------------
10841103 // Foreground session
@@ -2177,17 +2196,47 @@ const toolsCommand = Command.make("tools").pipe( · 1092 unmodified lines
21772196 Command.withDescription("Discover available tools and integrations"),
21782197 );
21792198
2199+const HEADER_NAME_PATTERN = /^[!#$%&'*+.^_`|~0-9A-Za-z-]+$/;
2200+const ENV_NAME_PATTERN = /^[A-Za-z_][A-Za-z0-9_]*$/;
2201+
2202+const parseHeaderEnvOption = (
2203+ headerEnv: Option.Option<Record<string, string>>,
2204+): ExecutorServerHeaders | undefined => {
2205+ const values = Option.getOrUndefined(headerEnv);
2206+ if (!values) return undefined;
2207+ const headers: Record<string, { readonly kind: "env"; readonly name: string }> = {};
2208+ for (const [rawHeaderName, rawEnvName] of Object.entries(values)) {
2209+ const headerName = rawHeaderName.trim();
2210+ const envName = rawEnvName.trim();
2211+ if (!HEADER_NAME_PATTERN.test(headerName)) {
2212+ throw new Error(
2213+ `Invalid --header-env header name "${rawHeaderName}". Use an HTTP header token like CF-Access-Client-Id.`,
2214+ );
2215+ }
2216+ if (!ENV_NAME_PATTERN.test(envName)) {
2217+ throw new Error(
2218+ `Invalid --header-env env name "${rawEnvName}". Use an environment variable name like EXECUTOR_CF_ACCESS_CLIENT_ID.`,
2219+ );
2220+ }
2221+ headers[headerName] = { kind: "env", name: envName };
2222+ }
2223+ return Object.keys(headers).length > 0 ? headers : undefined;
2224+};
2225+
21802226 const profileConnectionInput = (input: {
21812227 readonly origin: string;
21822228 readonly displayName: Option.Option<string>;
21832229 readonly kind: Option.Option<"http" | "desktop-sidecar">;
2230+ readonly headerEnv: Option.Option<Record<string, string>>;
21842231 }): ExecutorServerConnectionInput => {
21852232 const selectedKind = Option.getOrUndefined(input.kind);
21862233 const displayName = Option.getOrUndefined(input.displayName);
2234+ const headers = parseHeaderEnvOption(input.headerEnv);
21872235 return {
21882236 kind: selectedKind ?? "http",
21892237 origin: input.origin,
21902238 ...(displayName ? { displayName } : {}),
2239+ ...(headers ? { headers } : {}),
21912240 };
21922241 };
21932242
@@ -2207,13 +2256,16 @@ const printServerProfiles = () => · 13 unmodified lines
22072256 origin: profile.connection.origin,
22082257 displayName: profile.connection.displayName,
22092258 auth: profile.connection.auth ? "stored-auth" : "env-auth",
2259+ headers: profile.connection.headers
2260+ ? `${Object.keys(profile.connection.headers).length} header-env`
2261+ : "no-headers",
22102262 }));
22112263 const nameWidth = rows.reduce((max, row) => Math.max(max, row.name.length), 4);
22122264 const kindWidth = rows.reduce((max, row) => Math.max(max, row.kind.length), 4);
22132265
22142266 for (const row of rows) {
22152267 console.log(
2216− `${row.marker} ${row.name.padEnd(nameWidth)} ${row.kind.padEnd(kindWidth)} ${row.origin} ${row.displayName} ${row.auth}`,
2268+ `${row.marker} ${row.name.padEnd(nameWidth)} ${row.kind.padEnd(kindWidth)} ${row.origin} ${row.displayName} ${row.auth} ${row.headers}`,
22172269 );
22182270 }
22192271 });
@@ -2231,17 +2283,23 @@ const serverAddCommand = Command.make( · 11 unmodified lines
22312283 Options.optional,
22322284 Options.withDescription("Server kind. Defaults to http."),
22332285 ),
2286+ headerEnv: Options.keyValuePair("header-env").pipe(
2287+ Options.optional,
2288+ Options.withDescription(
2289+ "HTTP header mapping header-name=ENV_VAR. Repeat for Cloudflare Access service tokens.",
2290+ ),
2291+ ),
22342292 makeDefault: Options.boolean("default").pipe(
22352293 Options.withDefault(false),
22362294 Options.withDescription("Make this profile the default server."),
22372295 ),
22382296 },
2239− ({ name, origin, displayName, kind, makeDefault }) =>
2297+ ({ name, origin, displayName, kind, headerEnv, makeDefault }) =>
22402298 Effect.gen(function* () {
22412299 const profileName = validateCliServerConnectionProfileName(name);
22422300 const store = yield* upsertCliServerConnectionProfile({
22432301 name: profileName,
2244− connection: profileConnectionInput({ origin, displayName, kind }),
2302+ connection: profileConnectionInput({ origin, displayName, kind, headerEnv }),
22452303 makeDefault,
22462304 });
22472305 const profile = findCliServerConnectionProfile(store, profileName);
@@ -2440,12 +2498,18 @@ const loginCommand = Command.make( · 192 unmodified lines
24402498 // The target may have been picked implicitly (default profile, or the
24412499 // hosted fallback): say where the login is going before the device flow.
24422500 console.log(`Signing in to ${target.origin}`);
2501+ const targetProfile = target.profile;
2502+ const headers = targetProfile
2503+ ? yield* resolveExecutorServerConfiguredHeaders(targetProfile.connection, process.env).pipe(
2504+ Effect.mapError(toError),
2505+ )
2506+ : {};
24432507 const discovery = yield* Effect.tryPromise({
2444− try: () => discoverCliLogin(target.origin),
2508+ try: () => discoverCliLogin(target.origin, { headers }),
24452509 catch: toError,
24462510 });
24472511 const grant = yield* Effect.tryPromise({
2448− try: () => requestDeviceCode(discovery),
2512+ try: () => requestDeviceCode(discovery, { serverOrigin: target.origin, headers }),
24492513 catch: toError,
24502514 });
24512515 const verifyUrl = grant.verificationUriComplete ?? grant.verificationUri;
@@ -2456,7 +2520,7 @@ const loginCommand = Command.make( · 4 unmodified lines
24562520 if (!noBrowser) openBrowser(verifyUrl);
24572521 console.log("Waiting for you to approve in the browser...");
24582522 const tokens = yield* Effect.tryPromise({
2459− try: () => pollForDeviceTokens(discovery, grant),
2523+ try: () => pollForDeviceTokens(discovery, grant, { serverOrigin: target.origin, headers }),
24602524 catch: toError,
24612525 });
24622526
@@ -2481,6 +2545,9 @@ const loginCommand = Command.make( · 18 unmodified lines
24812545 kind: "http",
24822546 origin: target.origin,
24832547 ...(email ? { displayName: email } : {}),
2548+ ...(target.profile?.connection.headers
2549+ ? { headers: target.profile.connection.headers }
2550+ : {}),
24842551 auth: {
24852552 kind: "oauth",
24862553 accessToken: tokens.accessToken,
@@ -2543,6 +2610,7 @@ const logoutCommand = Command.make( · 56 unmodified lines
25432610 kind: profile.connection.kind,
25442611 origin: profile.connection.origin,
25452612 displayName: profile.connection.displayName,
2613+ ...(profile.connection.headers ? { headers: profile.connection.headers } : {}),
25462614 },
25472615 makeDefault: store.defaultProfile === profile.name,
25482616 });
apps/cli/src/server-profile.test.ts+15−0
@@ -36,6 +36,9 @@ describe("CLI server connection profiles", () => { · 35 unmodified lines
3636 connection: {
3737 origin: "https://executor.example/api",
3838 auth: { kind: "bearer", token: "key_123" },
39+ headers: {
40+ "CF-Access-Client-Id": { kind: "env", name: "EXECUTOR_CF_ACCESS_CLIENT_ID" },
41+ },
3942 },
4043 makeDefault: true,
4144 });
@@ -50,6 +53,9 @@ describe("CLI server connection profiles", () => { · 8 unmodified lines
5053 kind: "bearer",
5154 token: "key_123",
5255 });
56+ expect(store.profiles[0]?.connection.headers).toEqual({
57+ "CF-Access-Client-Id": { kind: "env", name: "EXECUTOR_CF_ACCESS_CLIENT_ID" },
58+ });
5359 expect(defaultCliServerConnectionProfile(store)?.name).toBe("remote");
5460 } finally {
5561 rmSync(dataDir, { recursive: true, force: true });
@@ -114,6 +120,12 @@ describe("CLI server connection profiles", () => { · 58 unmodified lines
114120 key: "desktop-sidecar",
115121 origin: "http://127.0.0.1:4789",
116122 auth: { kind: "basic", username: "executor", password: "secret" },
123+ headers: {
124+ "CF-Access-Client-Id": {
125+ kind: "env",
126+ name: "EXECUTOR_CF_ACCESS_CLIENT_ID",
127+ },
128+ },
117129 },
118130 },
119131 ],
@@ -127,5 +139,8 @@ describe("CLI server connection profiles", () => { · 7 unmodified lines
127139 username: "executor",
128140 password: "secret",
129141 });
142+ expect(store.profiles[0]?.connection.headers).toEqual({
143+ "CF-Access-Client-Id": { kind: "env", name: "EXECUTOR_CF_ACCESS_CLIENT_ID" },
144+ });
130145 });
131146 });
apps/cli/src/server-profile.ts+6−0
@@ -62,6 +62,11 @@ const PersistedAuth = Schema.Union([ · 61 unmodified lines
6262 }),
6363 ]);
6464
65+const PersistedHeader = Schema.Struct({
66+ kind: Schema.Literal("env"),
67+ name: Schema.String,
68+});
69+
6570 const PersistedConnection = Schema.Struct({
6671 kind: Schema.optional(Schema.Literals(["http", "desktop-sidecar"])),
6772 key: Schema.optional(Schema.String),
@@ -69,6 +74,7 @@ const PersistedConnection = Schema.Struct({ · 1 unmodified line
6974 apiBaseUrl: Schema.optional(Schema.String),
7075 displayName: Schema.optional(Schema.String),
7176 auth: Schema.optional(PersistedAuth),
77+ headers: Schema.optional(Schema.Record(Schema.String, PersistedHeader)),
7278 });
7379
7480 const PersistedProfile = Schema.Struct({

Keyboard shortcuts

General

Show keyboard shortcuts
?
Focus the current search
/

Issues and pull requests

Open filters
f

Issues, pull requests and Inbox

Select next item
j↓
Select previous item
k↑
Open selected item
Enter
Dismiss filters or clear selection
Esc

Repository code

Go to file
t