@zgeoff/atc 2.17.0 → 2.20.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,13 +1,26 @@
1
1
  import { z } from 'zod';
2
2
  import type { AgentID } from './agent-id';
3
3
  import { buildOptionalStringArray } from './build-optional-string-array';
4
+ import type { AuthProfile } from './collect-auth-profiles';
4
5
  import { isRecord } from './report';
6
+ import { resolveAuthProfiles } from './resolve-auth-profiles';
7
+
8
+ /**
9
+ * The auth profiles a gateway's sessions are bound to through impd's
10
+ * broker, and the variables each session gets in place of a credential,
11
+ * every value the fixed placeholder.
12
+ */
13
+ export interface GatewayAuth {
14
+ readonly profiles: readonly string[];
15
+ readonly placeholderEnv: Readonly<Record<string, string>>;
16
+ }
5
17
 
6
18
  /**
7
19
  * A Claude-compatible backend the Claude CLI is pointed at: its own agent id,
8
20
  * its own row in the spawn menu, and its own generated settings file. The
9
- * credential is never held here — a helper command supplies it at run time,
10
- * so it stays out of the file atc writes.
21
+ * credential is never held here: a helper command supplies it at run time,
22
+ * or, with `auth`, impd's credential broker adds it to each request on the
23
+ * host's side, so it stays out of the file atc writes.
11
24
  */
12
25
  export interface GatewayConfig {
13
26
  readonly id: AgentID;
@@ -19,6 +32,14 @@ export interface GatewayConfig {
19
32
  readonly apiKeyHelper?: string;
20
33
  readonly env: Readonly<Record<string, string>>;
21
34
  readonly settings?: Readonly<Record<string, unknown>>;
35
+ readonly auth?: GatewayAuth;
36
+ }
37
+
38
+ interface Gateways {
39
+ readonly gateways: GatewayConfig[];
40
+
41
+ // The problems that kept a gateway with `auth` out, one per problem.
42
+ readonly errors: string[];
22
43
  }
23
44
 
24
45
  // Ids the built-in adapters answer to; a gateway may not take one.
@@ -41,18 +62,22 @@ const GATEWAY_ENTRY_SCHEMA = z.object({
41
62
  /**
42
63
  * Reads the gateway map into menu order. An entry without a base URL, or
43
64
  * under an id another adapter already answers to, is left out rather than
44
- * registered half-formed: every gateway in the result can be spawned.
65
+ * registered half-formed: every gateway in the result can be spawned. A
66
+ * gateway with `auth` is checked against the auth profiles, and one that
67
+ * fails a check is left out with an error for each problem.
45
68
  */
46
69
  export function collectGateways(
47
70
  raw: unknown,
48
71
  claudeBin: string,
49
72
  claudeArgs: readonly string[],
50
- ): GatewayConfig[] {
73
+ authProfiles: ReadonlyMap<string, AuthProfile> = new Map(),
74
+ ): Gateways {
51
75
  if (!isRecord(raw)) {
52
- return [];
76
+ return { gateways: [], errors: [] };
53
77
  }
54
78
 
55
79
  const gateways: GatewayConfig[] = [];
80
+ const errors: string[] = [];
56
81
 
57
82
  for (const [id, entry] of Object.entries(raw)) {
58
83
  if (id === '' || BUILT_IN_IDS.has(id)) {
@@ -65,6 +90,19 @@ export function collectGateways(
65
90
  continue;
66
91
  }
67
92
 
93
+ let auth: GatewayAuth | undefined;
94
+
95
+ if (isRecord(entry) && entry['auth'] !== undefined) {
96
+ const checked = checkGatewayAuth(entry['auth'], parsed.data, authProfiles);
97
+
98
+ if (Array.isArray(checked)) {
99
+ errors.push(...checked.map((problem) => `gateways.${id}: ${problem}`));
100
+ continue;
101
+ }
102
+
103
+ auth = checked;
104
+ }
105
+
68
106
  const firstOfMark = (parsed.data.mark ?? id).codePointAt(0);
69
107
 
70
108
  gateways.push({
@@ -79,10 +117,189 @@ export function collectGateways(
79
117
  ...(parsed.data.apiKeyHelper === undefined ? {} : { apiKeyHelper: parsed.data.apiKeyHelper }),
80
118
  env: parsed.data.env,
81
119
  ...(parsed.data.settings === undefined ? {} : { settings: parsed.data.settings }),
120
+ ...(auth === undefined ? {} : { auth }),
82
121
  });
83
122
  }
84
123
 
85
- return gateways;
124
+ return { gateways, errors };
125
+ }
126
+
127
+ interface GatewayEntry {
128
+ readonly baseURL?: string | undefined;
129
+ readonly apiKeyHelper?: string | undefined;
130
+ readonly env: Readonly<Record<string, string>>;
131
+ readonly settings?: Readonly<Record<string, unknown>> | undefined;
132
+ }
133
+
134
+ // The gateway's auth, or every problem that refuses it. The checks cover
135
+ // each place a session's environment comes from: the gateway env, the
136
+ // settings env, and the placeholders, and which of them would win.
137
+ function checkGatewayAuth(
138
+ raw: unknown,
139
+ entry: GatewayEntry,
140
+ authProfiles: ReadonlyMap<string, AuthProfile>,
141
+ ): GatewayAuth | string[] {
142
+ const auth = parseGatewayAuth(raw);
143
+
144
+ if (typeof auth === 'string') {
145
+ return [auth];
146
+ }
147
+
148
+ const problems: string[] = [];
149
+
150
+ if (entry.apiKeyHelper !== undefined) {
151
+ problems.push(
152
+ 'apiKeyHelper cannot be set together with auth, which supplies the credential through the broker',
153
+ );
154
+ }
155
+
156
+ if (entry.settings?.['apiKeyHelper'] !== undefined) {
157
+ problems.push(
158
+ 'settings.apiKeyHelper cannot be set together with auth, which supplies the credential through the broker',
159
+ );
160
+ }
161
+
162
+ const settingsEnv = toEnvKeys(entry.settings?.['env']);
163
+
164
+ problems.push(
165
+ ...collectEnvProblems('env', Object.keys(entry.env)),
166
+ ...collectEnvProblems('settings.env', settingsEnv),
167
+ ...collectPlaceholderProblems(auth.placeholderEnv),
168
+ );
169
+
170
+ for (const key of Object.keys(auth.placeholderEnv)) {
171
+ for (const [source, keys] of [
172
+ ['env', Object.keys(entry.env)],
173
+ ['settings.env', settingsEnv],
174
+ ] as const) {
175
+ if (keys.includes(key)) {
176
+ problems.push(`placeholderEnv.${key} is also set in ${source}, which would override it`);
177
+ }
178
+ }
179
+ }
180
+
181
+ const resolution = resolveAuthProfiles(authProfiles, auth.profiles);
182
+
183
+ if ('problem' in resolution) {
184
+ problems.push(resolution.problem.message);
185
+ } else {
186
+ const hostProblem = findBaseURLProblem(entry.baseURL ?? '', resolution.resolved.hosts);
187
+
188
+ if (hostProblem !== null) {
189
+ problems.push(hostProblem);
190
+ }
191
+ }
192
+
193
+ return problems.length > 0 ? problems : auth;
194
+ }
195
+
196
+ // The value every placeholder variable holds; impd's broker swaps the real
197
+ // credential in on the host's side.
198
+ const PLACEHOLDER = 'imp-broker-placeholder';
199
+
200
+ function parseGatewayAuth(raw: unknown): GatewayAuth | string {
201
+ const profiles = isRecord(raw) ? raw['profiles'] : undefined;
202
+
203
+ if (
204
+ !isRecord(raw) ||
205
+ !Array.isArray(profiles) ||
206
+ profiles.length === 0 ||
207
+ !profiles.every((name) => typeof name === 'string')
208
+ ) {
209
+ return 'auth must be an object with a non-empty profiles array';
210
+ }
211
+
212
+ const placeholderEnv = raw['placeholderEnv'] ?? {};
213
+
214
+ if (!isRecord(placeholderEnv) || Array.isArray(placeholderEnv)) {
215
+ return 'placeholderEnv must be an object of variable names';
216
+ }
217
+
218
+ const env: Record<string, string> = {};
219
+
220
+ for (const [key, value] of Object.entries(placeholderEnv)) {
221
+ if (value !== PLACEHOLDER) {
222
+ return `placeholderEnv.${key} must be ${PLACEHOLDER}`;
223
+ }
224
+
225
+ env[key] = value;
226
+ }
227
+
228
+ return { profiles: profiles.map(String), placeholderEnv: env };
229
+ }
230
+
231
+ // A settings env's variable names; anything but an object sets none.
232
+ function toEnvKeys(value: unknown): string[] {
233
+ return isRecord(value) && !Array.isArray(value) ? Object.keys(value) : [];
234
+ }
235
+
236
+ // Variables Claude reads its endpoint and credential from. Only the
237
+ // placeholders may set a credential variable, and the base URL comes from
238
+ // `baseURL` alone, whose host is checked against the profiles.
239
+ const CREDENTIAL_VARIABLES: ReadonlySet<string> = new Set([
240
+ 'ANTHROPIC_AUTH_TOKEN',
241
+ 'ANTHROPIC_API_KEY',
242
+ ]);
243
+
244
+ const BASE_URL_VARIABLE = 'ANTHROPIC_BASE_URL';
245
+
246
+ function collectEnvProblems(source: string, keys: readonly string[]): string[] {
247
+ return keys
248
+ .filter(
249
+ (key) => isBrokerVariable(key) || CREDENTIAL_VARIABLES.has(key) || key === BASE_URL_VARIABLE,
250
+ )
251
+ .map((key) => `${source} must not set ${key}`);
252
+ }
253
+
254
+ // Variables impd sets in a brokered exec to route requests through the
255
+ // broker and trust its CA. impd lets a variable the caller sets win, so a
256
+ // configured one would route around the broker or mask a failed CA install.
257
+ const BROKER_VARIABLES: ReadonlySet<string> = new Set([
258
+ 'HTTPS_PROXY',
259
+ 'HTTP_PROXY',
260
+ 'ALL_PROXY',
261
+ 'NO_PROXY',
262
+ 'NODE_USE_ENV_PROXY',
263
+ 'SSL_CERT_FILE',
264
+ 'SSL_CERT_DIR',
265
+ 'NODE_EXTRA_CA_CERTS',
266
+ 'GIT_SSL_CAINFO',
267
+ 'REQUESTS_CA_BUNDLE',
268
+ 'CURL_CA_BUNDLE',
269
+ ]);
270
+
271
+ // Proxy and CA variables are read in either case by some clients.
272
+ function isBrokerVariable(key: string): boolean {
273
+ return BROKER_VARIABLES.has(key.toUpperCase());
274
+ }
275
+
276
+ function collectPlaceholderProblems(env: Readonly<Record<string, string>>): string[] {
277
+ return Object.keys(env)
278
+ .filter((key) => isBrokerVariable(key) || key === BASE_URL_VARIABLE)
279
+ .map((key) => `placeholderEnv must not set ${key}`);
280
+ }
281
+
282
+ // The broker matches a request by the exact host it connects to on the
283
+ // default https port, so the base URL must be https on that port, with a
284
+ // host one of the selected profiles covers.
285
+ function findBaseURLProblem(baseURL: string, hosts: readonly string[]): string | null {
286
+ let url: URL;
287
+
288
+ try {
289
+ url = new URL(baseURL);
290
+ } catch {
291
+ return 'baseURL must be an https URL with no port or user info';
292
+ }
293
+
294
+ if (url.protocol !== 'https:' || url.port !== '' || url.username !== '' || url.password !== '') {
295
+ return 'baseURL must be an https URL with no port or user info';
296
+ }
297
+
298
+ if (!hosts.includes(url.hostname)) {
299
+ return `baseURL host ${url.hostname} is not a host of the selected profiles (${hosts.join(', ')})`;
300
+ }
301
+
302
+ return null;
86
303
  }
87
304
 
88
305
  // Claude Code settings the sessions of this gateway are started with, on top of
@@ -2,15 +2,21 @@ import { DEFAULT_GIT_TRANSPORTS } from './default-git-transports';
2
2
  import { isRecord } from './report';
3
3
 
4
4
  /**
5
- * Where workspace sources come from: the GitHub owner whose repositories
6
- * the spawn picker lists by default, or null to list the gh account's own;
7
- * the ids of the sources the picker offers, in order, or null for the
8
- * default order; and the git transports the daemon fetches over.
5
+ * Where workspace sources come from and where their checkouts land: the
6
+ * GitHub owner whose repositories the spawn picker lists by default, or
7
+ * null to list the gh account's own; the ids of the sources the picker
8
+ * offers, in order, or null for the default order; the git transports the
9
+ * daemon fetches over; the root a checkout lands under on any target
10
+ * without its own, or null for the default; and each target's own root, by
11
+ * target id. A root is kept as written: whether it fits its target, such
12
+ * as `~` on a remote target, is checked where the target is known.
9
13
  */
10
14
  export interface WorkspacesConfig {
11
15
  readonly githubOwner: string | null;
12
16
  readonly sources: readonly string[] | null;
13
17
  readonly gitTransports: readonly string[] | InvalidGitTransports;
18
+ readonly root: string | null;
19
+ readonly targetRoots: ReadonlyMap<string, string>;
14
20
  }
15
21
 
16
22
  /**
@@ -34,17 +40,20 @@ const GITHUB_OWNER_PATTERN = /^[A-Za-z\d][A-Za-z\d-]{0,38}$/u;
34
40
  /**
35
41
  * Reads the `workspaces` section of config.json. An owner that is not a
36
42
  * GitHub login is dropped, so a typo lists the gh account's own
37
- * repositories instead of failing, and a source order that is not a list
38
- * of ids falls back to the default order. A transport list that is not a
43
+ * repositories instead of failing, a source order that is not a list of
44
+ * ids falls back to the default order, and a root that is not a non-empty
45
+ * string is dropped. A transport list that is not a
39
46
  * list of transports atc allows is a config error, and the list is then
40
47
  * invalid, never the default, so the daemon runs no git until it is fixed.
41
48
  * An empty list is valid and allows no transport.
42
49
  */
43
50
  export function collectWorkspacesConfig(raw: unknown): CollectedWorkspacesConfig {
44
- const owner = isRecord(raw) ? raw['githubOwner'] : undefined;
45
- const sources = isRecord(raw) ? raw['sources'] : undefined;
46
- const rawTransports = isRecord(raw) ? raw['gitTransports'] : undefined;
47
- const transports = collectGitTransports(rawTransports);
51
+ const section = isRecord(raw) ? raw : {};
52
+ const owner = section['githubOwner'];
53
+ const sources = section['sources'];
54
+ const root = section['root'];
55
+ const targets = isRecord(section['targets']) ? section['targets'] : {};
56
+ const transports = collectGitTransports(section['gitTransports']);
48
57
 
49
58
  return {
50
59
  workspaces: {
@@ -55,6 +64,12 @@ export function collectWorkspacesConfig(raw: unknown): CollectedWorkspacesConfig
55
64
  ? sources
56
65
  : null,
57
66
  gitTransports: transports.transports,
67
+ root: typeof root === 'string' && root !== '' ? root : null,
68
+ targetRoots: new Map(
69
+ Object.entries(targets).flatMap(([id, dir]) =>
70
+ typeof dir === 'string' && dir !== '' ? [[id, dir] as const] : [],
71
+ ),
72
+ ),
58
73
  },
59
74
  errors: transports.errors,
60
75
  };
@@ -3,6 +3,8 @@ import { dirname, join } from 'node:path';
3
3
  import { z } from 'zod';
4
4
  import { buildOptionalString } from './build-optional-string';
5
5
  import { buildOptionalStringArray } from './build-optional-string-array';
6
+ import { collectAuthProfiles } from './collect-auth-profiles';
7
+ import type { AuthProfile } from './collect-auth-profiles';
6
8
  import { collectDirRoots } from './collect-dir-roots';
7
9
  import { collectGateways } from './collect-gateways';
8
10
  import type { GatewayConfig } from './collect-gateways';
@@ -28,6 +30,14 @@ export interface Config {
28
30
  dirs: DirsConfig;
29
31
  workspaces: WorkspacesConfig;
30
32
  gateways: GatewayConfig[];
33
+
34
+ // The problems that kept a gateway with auth out of the gateways.
35
+ gatewayErrors: readonly string[];
36
+
37
+ // The credential references a gateway's auth selects from, by profile
38
+ // name, and the problems that kept a profile out.
39
+ authProfiles: ReadonlyMap<string, AuthProfile>;
40
+ authProfileErrors: readonly string[];
31
41
  hooks: HooksConfig;
32
42
  leader: LeaderKey;
33
43
 
@@ -70,8 +80,17 @@ const DEFAULTS: Config = {
70
80
  codexBin: 'codex',
71
81
  codexArgs: [],
72
82
  dirs: { roots: [] },
73
- workspaces: { githubOwner: null, sources: null, gitTransports: DEFAULT_GIT_TRANSPORTS },
83
+ workspaces: {
84
+ githubOwner: null,
85
+ sources: null,
86
+ gitTransports: DEFAULT_GIT_TRANSPORTS,
87
+ root: null,
88
+ targetRoots: new Map(),
89
+ },
74
90
  gateways: [],
91
+ gatewayErrors: [],
92
+ authProfiles: new Map(),
93
+ authProfileErrors: [],
75
94
  hooks: {},
76
95
  leader: { code: 0, label: '^Space' },
77
96
  targets: [{ id: 'local', provider: 'local-pty', options: {} }],
@@ -112,6 +131,7 @@ const CONFIG_SCHEMA = z.object({
112
131
  dirs: z.unknown().optional(),
113
132
  workspaces: z.unknown().optional(),
114
133
  gateways: z.unknown().optional(),
134
+ authProfiles: z.unknown().optional(),
115
135
  hooks: z.unknown().optional(),
116
136
  leader: buildOptionalString(),
117
137
  targets: z.unknown().optional(),
@@ -200,21 +220,33 @@ function tryWriteDefaultConfig(file: string): void {
200
220
  /**
201
221
  * The config.json text a first run writes. It leaves out the targets and
202
222
  * principals, so the file holds the one implicit `local` target and no
203
- * principals until the user sets their own, and the errors a parse
204
- * reports, which belong to no file.
223
+ * principals until the user sets their own, no auth profiles, and the
224
+ * errors a parse reports, which belong to no file.
205
225
  */
206
226
  export function renderDefaultConfig(): string {
207
227
  const {
228
+ gatewayErrors: _gatewayErrors,
229
+ authProfiles: _authProfiles,
230
+ authProfileErrors: _authProfileErrors,
208
231
  targets: _targets,
209
232
  defaultTarget: _default,
210
233
  targetErrors: _errors,
211
234
  principals: _principals,
212
235
  principalErrors: _principalErrors,
213
236
  workspaceErrors: _workspaceErrors,
237
+ workspaces,
214
238
  ...written
215
239
  } = DEFAULTS;
216
240
 
217
- return `${JSON.stringify(written, null, 2)}\n`;
241
+ // The target roots are a map in memory and an object of target ids in
242
+ // the file.
243
+ const { targetRoots, ...rest } = workspaces;
244
+
245
+ return `${JSON.stringify(
246
+ { ...written, workspaces: { ...rest, targets: Object.fromEntries(targetRoots) } },
247
+ null,
248
+ 2,
249
+ )}\n`;
218
250
  }
219
251
 
220
252
  /**
@@ -251,7 +283,15 @@ export function parseConfig(raw: unknown, file: string = configFile): Config {
251
283
  const codexArgs = parsed.data.codexArgs ?? DEFAULTS.codexArgs;
252
284
  const dirs = { roots: collectDirRoots(parsed.data.dirs) };
253
285
  const workspaces = collectWorkspacesConfig(parsed.data.workspaces);
254
- const gateways = collectGateways(parsed.data.gateways, claudeBin, claudeArgs);
286
+ const authProfiles = collectAuthProfiles(parsed.data.authProfiles);
287
+
288
+ const gateways = collectGateways(
289
+ parsed.data.gateways,
290
+ claudeBin,
291
+ claudeArgs,
292
+ authProfiles.profiles,
293
+ );
294
+
255
295
  const hooks = collectHooks(parsed.data.hooks);
256
296
  const targets = collectTargets(parsed.data.targets, parsed.data.defaultTarget);
257
297
  const principals = collectPrincipals(parsed.data.principals);
@@ -268,7 +308,10 @@ export function parseConfig(raw: unknown, file: string = configFile): Config {
268
308
  codexArgs,
269
309
  dirs,
270
310
  workspaces: workspaces.workspaces,
271
- gateways,
311
+ gateways: gateways.gateways,
312
+ gatewayErrors: gateways.errors,
313
+ authProfiles: authProfiles.profiles,
314
+ authProfileErrors: authProfiles.errors,
272
315
  hooks,
273
316
  leader,
274
317
  targets: targets.targets,
@@ -0,0 +1,186 @@
1
+ import type { AuthProfile } from './collect-auth-profiles';
2
+
3
+ /**
4
+ * How impd applies one secret to requests for one host.
5
+ */
6
+ interface AuthRule {
7
+ readonly host: string;
8
+ readonly header: string;
9
+ readonly scheme: 'bearer';
10
+ }
11
+
12
+ /**
13
+ * One secret and the complete set of rules a selection needs impd to hold
14
+ * for it, one per host, sorted by host.
15
+ */
16
+ export interface ResolvedAuthSecret {
17
+ readonly secret: string;
18
+ readonly kind: 'custom';
19
+ readonly rules: readonly AuthRule[];
20
+ }
21
+
22
+ /**
23
+ * A selection with its dependencies expanded: every profile it reaches,
24
+ * every host those profiles send a credential to, and the rules grouped by
25
+ * secret, each list sorted so two selections of the same profiles resolve
26
+ * the same.
27
+ */
28
+ interface ResolvedAuthProfiles {
29
+ readonly profiles: readonly string[];
30
+ readonly hosts: readonly string[];
31
+ readonly secrets: readonly ResolvedAuthSecret[];
32
+ }
33
+
34
+ /**
35
+ * Why a selection cannot be bound:
36
+ *
37
+ * - `auth_profile_unknown`: it reaches a profile that is missing or that
38
+ * the config refused.
39
+ * - `auth_dependency_cycle`: the dependencies loop back on themselves.
40
+ * - `auth_collision`: two profiles send different credentials or rules to
41
+ * one host, which impd could not tell apart.
42
+ */
43
+ export interface AuthProfileProblem {
44
+ readonly code: 'auth_profile_unknown' | 'auth_dependency_cycle' | 'auth_collision';
45
+ readonly message: string;
46
+ }
47
+
48
+ export type AuthProfileResolution =
49
+ | { readonly resolved: ResolvedAuthProfiles }
50
+ | { readonly problem: AuthProfileProblem };
51
+
52
+ /**
53
+ * Expands the selected profiles through their dependencies, then checks
54
+ * the whole expanded set: each host must get exactly one rule, so a
55
+ * credential is never picked by order. Two profiles holding the identical
56
+ * secret and rule for a host merge into one rule.
57
+ */
58
+ export function resolveAuthProfiles(
59
+ profiles: ReadonlyMap<string, AuthProfile>,
60
+ selected: readonly string[],
61
+ ): AuthProfileResolution {
62
+ let reached: ReadonlyMap<string, AuthProfile> = new Map();
63
+
64
+ for (const name of selected) {
65
+ const next = collectReached(profiles, name, null, [], reached);
66
+
67
+ if (isAuthProfileProblem(next)) {
68
+ return { problem: next };
69
+ }
70
+
71
+ reached = next;
72
+ }
73
+
74
+ const ordered = [...reached.values()].toSorted((a, b) => (a.name < b.name ? -1 : 1));
75
+
76
+ const byHost = new Map<string, AuthProfile>();
77
+
78
+ for (const profile of ordered) {
79
+ const other = byHost.get(profile.host);
80
+
81
+ if (other === undefined) {
82
+ byHost.set(profile.host, profile);
83
+ } else if (!hasSameRule(other, profile)) {
84
+ return {
85
+ problem: {
86
+ code: 'auth_collision',
87
+ message: `profiles ${other.name} and ${profile.name} both send a credential to ${profile.host}`,
88
+ },
89
+ };
90
+ }
91
+ }
92
+
93
+ return {
94
+ resolved: {
95
+ profiles: ordered.map((profile) => profile.name),
96
+ hosts: [...byHost.keys()].toSorted(),
97
+ secrets: buildSecrets([...byHost.values()]),
98
+ },
99
+ };
100
+ }
101
+
102
+ // Depth-first over the dependencies from one name: returns `reached` plus
103
+ // every profile this name reaches, each added once its own dependencies are
104
+ // done. A profile already reached is not walked again, so a shared
105
+ // dependency costs one visit.
106
+ // `path` holds the names on the way here, so a name already on it closes a
107
+ // cycle.
108
+ function collectReached(
109
+ profiles: ReadonlyMap<string, AuthProfile>,
110
+ name: string,
111
+ from: string | null,
112
+ path: readonly string[],
113
+ reached: ReadonlyMap<string, AuthProfile>,
114
+ ): ReadonlyMap<string, AuthProfile> | AuthProfileProblem {
115
+ if (path.includes(name)) {
116
+ return {
117
+ code: 'auth_dependency_cycle',
118
+ message: `profile dependencies form a cycle: ${[...path.slice(path.indexOf(name)), name].join(' -> ')}`,
119
+ };
120
+ }
121
+
122
+ if (reached.has(name)) {
123
+ return reached;
124
+ }
125
+
126
+ const profile = profiles.get(name);
127
+
128
+ if (profile === undefined) {
129
+ return {
130
+ code: 'auth_profile_unknown',
131
+ message:
132
+ from === null
133
+ ? `profile ${name} is selected, but authProfiles has no usable profile by that name`
134
+ : `profile ${from} depends on ${name}, but authProfiles has no usable profile by that name`,
135
+ };
136
+ }
137
+
138
+ let next = reached;
139
+
140
+ for (const dependency of profile.dependencies) {
141
+ const after = collectReached(profiles, dependency, name, [...path, name], next);
142
+
143
+ if (isAuthProfileProblem(after)) {
144
+ return after;
145
+ }
146
+
147
+ next = after;
148
+ }
149
+
150
+ return new Map([...next, [name, profile]]);
151
+ }
152
+
153
+ function isAuthProfileProblem(
154
+ value: ReadonlyMap<string, AuthProfile> | AuthProfileProblem,
155
+ ): value is AuthProfileProblem {
156
+ return 'code' in value;
157
+ }
158
+
159
+ function hasSameRule(left: AuthProfile, right: AuthProfile): boolean {
160
+ return (
161
+ left.secret === right.secret &&
162
+ left.header === right.header &&
163
+ left.scheme === right.scheme &&
164
+ left.kind === right.kind
165
+ );
166
+ }
167
+
168
+ // One entry per secret, sorted by secret, each with its rules sorted by host.
169
+ function buildSecrets(profiles: readonly AuthProfile[]): ResolvedAuthSecret[] {
170
+ const rules = new Map<string, AuthRule[]>();
171
+
172
+ for (const profile of profiles) {
173
+ const list = rules.get(profile.secret) ?? [];
174
+
175
+ list.push({ host: profile.host, header: profile.header, scheme: profile.scheme });
176
+ rules.set(profile.secret, list);
177
+ }
178
+
179
+ return [...rules.entries()]
180
+ .toSorted(([a], [b]) => (a < b ? -1 : 1))
181
+ .map(([secret, list]) => ({
182
+ secret,
183
+ kind: 'custom',
184
+ rules: list.toSorted((a, b) => (a.host < b.host ? -1 : 1)),
185
+ }));
186
+ }