@marcusrbrown/infra 0.12.2 → 0.13.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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@marcusrbrown/infra",
3
- "version": "0.12.2",
3
+ "version": "0.13.0",
4
4
  "description": "Infrastructure management CLI — deploy automation, health checks, and MCP bridge",
5
5
  "keywords": [
6
6
  "infra",
@@ -28,6 +28,7 @@ Commands:
28
28
 
29
29
  --url [url] Base URL for CLIProxyAPI health checks. Falls back to CLIPROXY_URL or https://cliproxy.fro.bot.
30
30
  --key [key] Management API key. Falls back to CLIPROXY_MANAGEMENT_KEY when omitted.
31
+ --api-key [key] Downstream API key (bearer) for the upstream provider-auth probe. Falls back to CLIPROXY_API_KEY. Skipped when absent.
31
32
  --verbose Enable verbose output for all commands
32
33
 
33
34
 
@@ -3,6 +3,7 @@ import {afterEach, beforeEach, describe, expect, it} from 'bun:test'
3
3
  import {createCapturedCtx, expectCapturedToInclude, MockProcessExit} from '../../__test__/mcp-ctx-fixture'
4
4
  import {
5
5
  checkHttpReachability,
6
+ checkProviderAuth,
6
7
  checkUsageStats,
7
8
  checkVersion,
8
9
  cliproxyStatusAction,
@@ -839,3 +840,318 @@ describe('cliproxyStatusAction — ambient key does not follow agent-supplied UR
839
840
  expect(output).toMatch(/CLIPROXY_MANAGEMENT_KEY|no key|skipping/i)
840
841
  })
841
842
  })
843
+
844
+ // ─── checkProviderAuth ────────────────────────────────────────────────────────
845
+
846
+ describe('checkProviderAuth', () => {
847
+ afterEach(() => {
848
+ globalThis.fetch = originalFetch
849
+ })
850
+
851
+ it('returns ok when POST /v1/chat/completions returns 200', async () => {
852
+ globalThis.fetch = createFetchImplementation(
853
+ async () =>
854
+ new Response(JSON.stringify({choices: [{message: {content: 'pong'}}]}), {
855
+ status: 200,
856
+ headers: {'content-type': 'application/json'},
857
+ }),
858
+ )
859
+
860
+ const result = await checkProviderAuth('https://cliproxy.example.com', 'test-api-key')
861
+
862
+ expect(result.level).toBe('ok')
863
+ expect(result.title).toBe('Upstream provider auth (anthropic)')
864
+ expect(result.summary).toContain('anthropic route OK')
865
+ expect(result.summary).toContain('claude-sonnet-4-6')
866
+ })
867
+
868
+ it('returns error when POST returns 401', async () => {
869
+ globalThis.fetch = createFetchImplementation(async () => new Response('Unauthorized', {status: 401}))
870
+
871
+ const result = await checkProviderAuth('https://cliproxy.example.com', 'test-api-key')
872
+
873
+ expect(result.level).toBe('error')
874
+ expect(result.summary).toContain('401')
875
+ expect(result.summary).toContain('cliproxy login claude')
876
+ })
877
+
878
+ it('returns error when POST returns 503 with auth_unavailable body', async () => {
879
+ globalThis.fetch = createFetchImplementation(
880
+ async () => new Response('auth_unavailable: no auth available (providers=claude)', {status: 503}),
881
+ )
882
+
883
+ const result = await checkProviderAuth('https://cliproxy.example.com', 'test-api-key')
884
+
885
+ expect(result.level).toBe('error')
886
+ expect(result.summary).toContain('503')
887
+ expect(result.summary).toContain('cliproxy login claude')
888
+ })
889
+
890
+ it('returns error when POST returns 503 with "no auth available" in body', async () => {
891
+ globalThis.fetch = createFetchImplementation(async () => new Response('no auth available', {status: 503}))
892
+
893
+ const result = await checkProviderAuth('https://cliproxy.example.com', 'test-api-key')
894
+
895
+ expect(result.level).toBe('error')
896
+ expect(result.summary).toContain('cliproxy login claude')
897
+ })
898
+
899
+ it('returns error when POST returns 503 with "providers=claude" in body', async () => {
900
+ globalThis.fetch = createFetchImplementation(
901
+ async () => new Response('{"error":"providers=claude unavailable"}', {status: 503}),
902
+ )
903
+
904
+ const result = await checkProviderAuth('https://cliproxy.example.com', 'test-api-key')
905
+
906
+ expect(result.level).toBe('error')
907
+ expect(result.summary).toContain('cliproxy login claude')
908
+ })
909
+
910
+ it('returns warning when POST returns 503 without auth-unavailable markers', async () => {
911
+ globalThis.fetch = createFetchImplementation(async () => new Response('Service Unavailable', {status: 503}))
912
+
913
+ const result = await checkProviderAuth('https://cliproxy.example.com', 'test-api-key')
914
+
915
+ expect(result.level).toBe('warning')
916
+ expect(result.summary).toContain('503')
917
+ expect(result.summary).not.toContain('cliproxy login claude')
918
+ })
919
+
920
+ it('returns warning when POST returns other non-2xx (e.g. 500)', async () => {
921
+ globalThis.fetch = createFetchImplementation(async () => new Response('Internal Server Error', {status: 500}))
922
+
923
+ const result = await checkProviderAuth('https://cliproxy.example.com', 'test-api-key')
924
+
925
+ expect(result.level).toBe('warning')
926
+ expect(result.summary).toContain('500')
927
+ })
928
+
929
+ it('returns warning (not error) when fetch throws (network/timeout)', async () => {
930
+ globalThis.fetch = createFetchImplementation(async () => {
931
+ throw new Error('The operation was aborted due to timeout')
932
+ })
933
+
934
+ const result = await checkProviderAuth('https://cliproxy.example.com', 'test-api-key')
935
+
936
+ expect(result.level).toBe('warning')
937
+ expect(result.summary).toContain('Anthropic probe failed')
938
+ expect(result.summary).toContain('aborted')
939
+ })
940
+
941
+ it('never includes the apiKey in summary or details', async () => {
942
+ const secretKey = 'super-secret-api-key-do-not-leak'
943
+
944
+ globalThis.fetch = createFetchImplementation(async () => new Response('Unauthorized', {status: 401}))
945
+
946
+ const result = await checkProviderAuth('https://cliproxy.example.com', secretKey)
947
+
948
+ expect(result.summary).not.toContain(secretKey)
949
+ const detailsText = (result.details ?? []).join('\n')
950
+ expect(detailsText).not.toContain(secretKey)
951
+ })
952
+
953
+ it('uses the default model claude-sonnet-4-6 when no model override given', async () => {
954
+ let capturedBody = ''
955
+
956
+ globalThis.fetch = createFetchImplementation(async (_url, init) => {
957
+ capturedBody = typeof init?.body === 'string' ? init.body : ''
958
+ return new Response(JSON.stringify({choices: []}), {
959
+ status: 200,
960
+ headers: {'content-type': 'application/json'},
961
+ })
962
+ })
963
+
964
+ await checkProviderAuth('https://cliproxy.example.com', 'test-api-key')
965
+
966
+ expect(capturedBody).toContain('claude-sonnet-4-6')
967
+ })
968
+
969
+ it('uses the model override when options.model is provided', async () => {
970
+ let capturedBody = ''
971
+
972
+ globalThis.fetch = createFetchImplementation(async (_url, init) => {
973
+ capturedBody = typeof init?.body === 'string' ? init.body : ''
974
+ return new Response(JSON.stringify({choices: []}), {
975
+ status: 200,
976
+ headers: {'content-type': 'application/json'},
977
+ })
978
+ })
979
+
980
+ await checkProviderAuth('https://cliproxy.example.com', 'test-api-key', {model: 'claude-opus-4-5'})
981
+
982
+ expect(capturedBody).toContain('claude-opus-4-5')
983
+ expect(capturedBody).not.toContain('claude-sonnet-4-6')
984
+ })
985
+
986
+ it('includes verbose details (URL, model, status) when verbose=true', async () => {
987
+ globalThis.fetch = createFetchImplementation(
988
+ async () =>
989
+ new Response(JSON.stringify({choices: []}), {
990
+ status: 200,
991
+ headers: {'content-type': 'application/json'},
992
+ }),
993
+ )
994
+
995
+ const result = await checkProviderAuth('https://cliproxy.example.com', 'test-api-key', {verbose: true})
996
+
997
+ expect(result.details).toBeDefined()
998
+ const detailsText = (result.details ?? []).join('\n')
999
+ expect(detailsText).toContain('https://cliproxy.example.com')
1000
+ expect(detailsText).toContain('claude-sonnet-4-6')
1001
+ })
1002
+ })
1003
+
1004
+ // ─── cliproxyStatusAction — provider auth wiring ─────────────────────────────
1005
+
1006
+ describe('cliproxyStatusAction — provider auth probe wiring', () => {
1007
+ let originalEnv: Record<string, string | undefined>
1008
+
1009
+ beforeEach(() => {
1010
+ originalEnv = {
1011
+ CLIPROXY_URL: process.env.CLIPROXY_URL,
1012
+ CLIPROXY_MANAGEMENT_KEY: process.env.CLIPROXY_MANAGEMENT_KEY,
1013
+ CLIPROXY_API_KEY: process.env.CLIPROXY_API_KEY,
1014
+ }
1015
+ delete process.env.CLIPROXY_URL
1016
+ delete process.env.CLIPROXY_MANAGEMENT_KEY
1017
+ delete process.env.CLIPROXY_API_KEY
1018
+ })
1019
+
1020
+ afterEach(() => {
1021
+ for (const [k, v] of Object.entries(originalEnv)) {
1022
+ if (v === undefined) {
1023
+ delete process.env[k]
1024
+ } else {
1025
+ process.env[k] = v
1026
+ }
1027
+ }
1028
+ globalThis.fetch = originalFetch
1029
+ })
1030
+
1031
+ it('shows a warning (not error) when no provider key is available', async () => {
1032
+ globalThis.fetch = createFetchImplementation(async url => {
1033
+ if (url.includes('/healthz')) return new Response('ok', {status: 200})
1034
+ throw new Error(`Unexpected fetch: ${url}`)
1035
+ })
1036
+
1037
+ const {ctx, captured} = createCapturedCtx()
1038
+ await cliproxyStatusAction({url: 'https://cliproxy.example.com'}, ctx)
1039
+
1040
+ const output = [...captured.stdout, ...captured.stderr].join('\n')
1041
+ expect(output).toMatch(/CLIPROXY_API_KEY|skipping upstream provider probe/i)
1042
+ // Must be a warning, not an error — no exit(1) for missing key
1043
+ expect(captured.exit).toBeNull()
1044
+ })
1045
+
1046
+ it('runs the provider auth probe when --api-key is provided', async () => {
1047
+ const fetchedUrls: string[] = []
1048
+
1049
+ globalThis.fetch = createFetchImplementation(async url => {
1050
+ fetchedUrls.push(url)
1051
+ if (url.includes('/healthz')) return new Response('ok', {status: 200})
1052
+ if (url.includes('/v1/chat/completions'))
1053
+ return new Response(JSON.stringify({choices: []}), {
1054
+ status: 200,
1055
+ headers: {'content-type': 'application/json'},
1056
+ })
1057
+ throw new Error(`Unexpected fetch: ${url}`)
1058
+ })
1059
+
1060
+ const {ctx, captured} = createCapturedCtx()
1061
+ await cliproxyStatusAction({url: 'https://cliproxy.example.com', apiKey: 'my-api-key'}, ctx)
1062
+
1063
+ expect(fetchedUrls.some(u => u.includes('/v1/chat/completions'))).toBe(true)
1064
+ const output = [...captured.stdout, ...captured.stderr].join('\n')
1065
+ expect(output).toContain('Upstream provider auth')
1066
+ })
1067
+
1068
+ it('does NOT forward ambient CLIPROXY_API_KEY to an explicit --url override', async () => {
1069
+ process.env.CLIPROXY_API_KEY = 'ambient-api-key-secret'
1070
+
1071
+ const capturedAuthHeaders: string[] = []
1072
+
1073
+ globalThis.fetch = createFetchImplementation(async (url, init) => {
1074
+ const hdrs = init?.headers
1075
+ let auth: string | null = null
1076
+ if (hdrs instanceof Headers) {
1077
+ auth = hdrs.get('authorization')
1078
+ } else if (hdrs !== null && hdrs !== undefined && typeof hdrs === 'object') {
1079
+ const hdrsObj = hdrs as Record<string, string>
1080
+ auth = hdrsObj.authorization ?? hdrsObj.Authorization ?? null
1081
+ }
1082
+ if (auth) capturedAuthHeaders.push(auth)
1083
+
1084
+ if (url.includes('/healthz')) return new Response('ok', {status: 200})
1085
+ if (url.includes('/v1/chat/completions')) return new Response(JSON.stringify({choices: []}), {status: 200})
1086
+ return new Response('ok', {status: 200})
1087
+ })
1088
+
1089
+ const {ctx} = createCapturedCtx()
1090
+ // Explicit --url override to a non-trusted host — ambient key must NOT follow
1091
+ await cliproxyStatusAction({url: 'https://evil.example.com'}, ctx)
1092
+
1093
+ // The ambient API key must not appear in any Authorization header sent to evil host
1094
+ expect(capturedAuthHeaders.some(h => h.includes('ambient-api-key-secret'))).toBe(false)
1095
+ })
1096
+
1097
+ it('uses ambient CLIPROXY_API_KEY when URL is the trusted default', async () => {
1098
+ process.env.CLIPROXY_API_KEY = 'ambient-api-key-trusted'
1099
+
1100
+ const capturedAuthHeaders: string[] = []
1101
+
1102
+ globalThis.fetch = createFetchImplementation(async (url, init) => {
1103
+ const hdrs = init?.headers
1104
+ let auth: string | null = null
1105
+ if (hdrs instanceof Headers) {
1106
+ auth = hdrs.get('authorization')
1107
+ } else if (hdrs !== null && hdrs !== undefined && typeof hdrs === 'object') {
1108
+ const hdrsObj = hdrs as Record<string, string>
1109
+ auth = hdrsObj.authorization ?? hdrsObj.Authorization ?? null
1110
+ }
1111
+ if (auth) capturedAuthHeaders.push(auth)
1112
+
1113
+ if (url.includes('/healthz')) return new Response('ok', {status: 200})
1114
+ if (url.includes('/v1/chat/completions'))
1115
+ return new Response(JSON.stringify({choices: []}), {
1116
+ status: 200,
1117
+ headers: {'content-type': 'application/json'},
1118
+ })
1119
+ // Management endpoints — return ok so management checks don't block
1120
+ if (url.includes('/v0/management/config'))
1121
+ return new Response('{}', {status: 200, headers: {'content-type': 'application/json'}})
1122
+ if (url.includes('/v0/management/usage-queue'))
1123
+ return new Response('[]', {status: 200, headers: {'content-type': 'application/json'}})
1124
+ if (url.includes('/v0/management/latest-version'))
1125
+ return new Response(JSON.stringify({'latest-version': 'v1.0.0'}), {
1126
+ status: 200,
1127
+ headers: {'content-type': 'application/json'},
1128
+ })
1129
+ return new Response('ok', {status: 200})
1130
+ })
1131
+
1132
+ const {ctx} = createCapturedCtx()
1133
+ // Default trusted URL — ambient key SHOULD be used
1134
+ await cliproxyStatusAction({url: 'https://cliproxy.fro.bot'}, ctx)
1135
+
1136
+ expect(capturedAuthHeaders.some(h => h.includes('ambient-api-key-trusted'))).toBe(true)
1137
+ })
1138
+
1139
+ it('provider auth error (401) causes exit(1) — dead upstream is a real error', async () => {
1140
+ globalThis.fetch = createFetchImplementation(async url => {
1141
+ if (url.includes('/healthz')) return new Response('ok', {status: 200})
1142
+ if (url.includes('/v1/chat/completions')) return new Response('Unauthorized', {status: 401})
1143
+ throw new Error(`Unexpected fetch: ${url}`)
1144
+ })
1145
+
1146
+ const {ctx, captured} = createCapturedCtx()
1147
+ let threw: unknown
1148
+ try {
1149
+ await cliproxyStatusAction({url: 'https://cliproxy.example.com', apiKey: 'my-api-key'}, ctx)
1150
+ } catch (error) {
1151
+ threw = error
1152
+ }
1153
+
1154
+ expect(threw).toBeInstanceOf(MockProcessExit)
1155
+ expect(captured.exit?.code).toBe(1)
1156
+ })
1157
+ })
@@ -226,6 +226,113 @@ export async function checkVersion(baseUrl: string, key: string): Promise<CheckR
226
226
  }
227
227
  }
228
228
 
229
+ const DEFAULT_PROVIDER_MODEL = 'claude-sonnet-4-6'
230
+
231
+ export interface ProviderAuthOptions {
232
+ model?: string
233
+ verbose?: boolean
234
+ }
235
+
236
+ /**
237
+ * Probe the upstream provider auth by sending a minimal chat completion request.
238
+ * Returns a CheckResult — never throws.
239
+ *
240
+ * - 200 → ok
241
+ * - 401 OR (503 with auth-unavailable body markers) → error with remediation hint
242
+ * - other non-2xx → warning (don't fail status on unrelated upstream issues)
243
+ * - fetch throw (timeout/network) → warning (flaky probe should not fail status)
244
+ *
245
+ * The apiKey is NEVER included in summary or details output.
246
+ */
247
+ export async function checkProviderAuth(
248
+ baseUrl: string,
249
+ apiKey: string,
250
+ options?: ProviderAuthOptions,
251
+ ): Promise<CheckResult> {
252
+ const model = options?.model ?? DEFAULT_PROVIDER_MODEL
253
+ const verbose = options?.verbose === true
254
+ const endpoint = `${baseUrl}/v1/chat/completions`
255
+
256
+ try {
257
+ const response = await fetch(endpoint, {
258
+ method: 'POST',
259
+ headers: {
260
+ authorization: `Bearer ${apiKey}`,
261
+ 'content-type': 'application/json',
262
+ },
263
+ body: JSON.stringify({
264
+ model,
265
+ messages: [{role: 'user', content: 'ping'}],
266
+ max_tokens: 1,
267
+ }),
268
+ signal: AbortSignal.timeout(HTTP_TIMEOUT_MS),
269
+ })
270
+
271
+ const details: string[] = []
272
+ if (verbose) {
273
+ details.push(`URL: ${endpoint}`)
274
+ details.push(`Model: ${model}`)
275
+ details.push(`Status: ${response.status}`)
276
+ }
277
+
278
+ if (response.ok) {
279
+ return {
280
+ title: 'Upstream provider auth (anthropic)',
281
+ level: 'ok',
282
+ summary: `anthropic route OK (${model})`,
283
+ details: details.length > 0 ? details : undefined,
284
+ }
285
+ }
286
+
287
+ // 401 is always an auth failure
288
+ if (response.status === 401) {
289
+ return {
290
+ title: 'Upstream provider auth (anthropic)',
291
+ level: 'error',
292
+ summary: `Anthropic upstream auth unavailable (${response.status}) — run: cliproxy login claude`,
293
+ details: details.length > 0 ? details : undefined,
294
+ }
295
+ }
296
+
297
+ // 503 may be auth_unavailable — check body for known markers
298
+ if (response.status === 503) {
299
+ let bodyText = ''
300
+ try {
301
+ bodyText = await response.text()
302
+ } catch {
303
+ // ignore body read failure
304
+ }
305
+
306
+ const isAuthUnavailable = /auth_unavailable|no auth available|providers=claude/i.test(bodyText)
307
+
308
+ if (isAuthUnavailable) {
309
+ return {
310
+ title: 'Upstream provider auth (anthropic)',
311
+ level: 'error',
312
+ summary: `Anthropic upstream auth unavailable (${response.status}) — run: cliproxy login claude`,
313
+ details: details.length > 0 ? details : undefined,
314
+ }
315
+ }
316
+ }
317
+
318
+ // Any other non-2xx: warning (don't fail status on unrelated upstream issues)
319
+ return {
320
+ title: 'Upstream provider auth (anthropic)',
321
+ level: 'warning',
322
+ summary: `Anthropic probe returned HTTP ${response.status}`,
323
+ details: details.length > 0 ? details : undefined,
324
+ }
325
+ } catch (error) {
326
+ const message = error instanceof Error ? error.message : String(error)
327
+ return {
328
+ title: 'Upstream provider auth (anthropic)',
329
+ level: 'warning',
330
+ summary: `Anthropic probe failed: ${message}`,
331
+ details: verbose ? [`URL: ${endpoint}`, `Model: ${model}`] : undefined,
332
+ }
333
+ }
334
+ }
335
+
229
336
  /**
230
337
  * Probe the management API with a cheap auth check before issuing parallel calls.
231
338
  * Returns null on success, or a CheckResult describing the auth failure.
@@ -377,6 +484,8 @@ export async function getCliproxyStatusSummary(baseUrl: string, key: string, ver
377
484
  export interface StatusOptions {
378
485
  url?: string
379
486
  key?: string
487
+ /** Downstream API key (bearer) for the upstream provider-auth probe. Maps from --api-key CLI flag. */
488
+ apiKey?: string
380
489
  verbose?: boolean
381
490
  }
382
491
 
@@ -396,6 +505,10 @@ export async function cliproxyStatusAction(options: StatusOptions, ctx: ActionCt
396
505
  // An ambient env key is only forwarded when the resolved baseUrl is trusted.
397
506
  const managementKey = options.key ?? (urlIsExplicitlyOverridden ? undefined : process.env.CLIPROXY_MANAGEMENT_KEY)
398
507
 
508
+ // Provider (downstream) API key for the upstream provider-auth probe.
509
+ // Same trusted-URL guard: ambient CLIPROXY_API_KEY only follows to the trusted URL.
510
+ const providerKey = options.apiKey ?? (urlIsExplicitlyOverridden ? undefined : process.env.CLIPROXY_API_KEY)
511
+
399
512
  ctx.console.log('CLIProxyAPI status')
400
513
  ctx.console.log('')
401
514
 
@@ -419,6 +532,17 @@ export async function cliproxyStatusAction(options: StatusOptions, ctx: ActionCt
419
532
  results.push(mgmt.usage, mgmt.version)
420
533
  }
421
534
 
535
+ // Upstream provider-auth probe — only when a downstream API key is available.
536
+ if (providerKey) {
537
+ results.push(await checkProviderAuth(baseUrl, providerKey, {verbose}))
538
+ } else {
539
+ results.push({
540
+ title: 'Upstream provider auth',
541
+ level: 'warning',
542
+ summary: 'CLIPROXY_API_KEY not set — skipping upstream provider probe.',
543
+ })
544
+ }
545
+
422
546
  for (const result of results) {
423
547
  printCheckResult(result, ctx)
424
548
  ctx.console.log('')
@@ -460,6 +584,14 @@ export function registerCliproxyStatus(cli: ReturnType<typeof goke>): void {
460
584
  '--key [key]',
461
585
  z.string().describe('Management API key. Falls back to CLIPROXY_MANAGEMENT_KEY when omitted.'),
462
586
  )
587
+ .option(
588
+ '--api-key [key]',
589
+ z
590
+ .string()
591
+ .describe(
592
+ 'Downstream API key (bearer) for the upstream provider-auth probe. Falls back to CLIPROXY_API_KEY. Skipped when absent.',
593
+ ),
594
+ )
463
595
  .option('--verbose', 'Enable verbose output for all commands')
464
596
  .action(cliproxyStatusAction)
465
597
  }
@@ -68,6 +68,8 @@ export function getGatewayDeployEnv(): Record<string, string> {
68
68
  GATEWAY_OPERATOR_OAUTH_ALLOWED_RETURN_PATHS: process.env.GATEWAY_OPERATOR_OAUTH_ALLOWED_RETURN_PATHS ?? '',
69
69
  GATEWAY_OPERATOR_OAUTH_STATE_TTL_MS: process.env.GATEWAY_OPERATOR_OAUTH_STATE_TTL_MS ?? '',
70
70
  GATEWAY_OPERATOR_OAUTH_MAX_OUTSTANDING_ATTEMPTS: process.env.GATEWAY_OPERATOR_OAUTH_MAX_OUTSTANDING_ATTEMPTS ?? '',
71
+ GATEWAY_VPC_IP: process.env.GATEWAY_VPC_IP ?? '',
72
+ DASHBOARD_VPC_IP: process.env.DASHBOARD_VPC_IP ?? '',
71
73
  GATEWAY_IMAGE_DIGEST: process.env.GATEWAY_IMAGE_DIGEST ?? '',
72
74
  WORKSPACE_IMAGE_DIGEST: process.env.WORKSPACE_IMAGE_DIGEST ?? '',
73
75
  }
@@ -440,6 +440,35 @@ describe('per-app invariants', () => {
440
440
  // SHA verification is wired in and was not accidentally stripped.
441
441
  expect(buildTs).toMatch(/const\s+EXPECTED_SHA256\s*=/)
442
442
  })
443
+
444
+ it('deploy-gateway.yaml does NOT install doctl (firewall is provisioning-only, not deploy-time)', async () => {
445
+ const text = await Bun.file(resolve(REPO_ROOT, '.github/workflows/deploy-gateway.yaml')).text()
446
+ // The DO Cloud Firewall is created in provisioning (provision-droplet.ts), not in the deploy hot path.
447
+ // The deploy job must NOT install doctl — it no longer needs it.
448
+ expect(text).not.toMatch(/uses:\s+digitalocean\/action-doctl@[a-f0-9]{40}/)
449
+ expect(text).not.toContain('digitalocean/action-doctl')
450
+ })
451
+
452
+ it('deploy-gateway.yaml does NOT forward DIGITALOCEAN_ACCESS_TOKEN to the deploy step', async () => {
453
+ const text = await Bun.file(resolve(REPO_ROOT, '.github/workflows/deploy-gateway.yaml')).text()
454
+ // DIGITALOCEAN_ACCESS_TOKEN is only needed for provisioning, not for the deploy hot path.
455
+ // Verify it is not forwarded to the deploy step env block.
456
+ const deployStepIndex = text.indexOf('name: Deploy gateway')
457
+ expect(deployStepIndex).toBeGreaterThan(-1)
458
+ const afterDeployStep = text.slice(deployStepIndex)
459
+ expect(afterDeployStep).not.toContain('DIGITALOCEAN_ACCESS_TOKEN')
460
+ })
461
+
462
+ it('deploy-gateway.yaml still forwards GATEWAY_VPC_IP and DASHBOARD_VPC_IP to the deploy step', async () => {
463
+ const text = await Bun.file(resolve(REPO_ROOT, '.github/workflows/deploy-gateway.yaml')).text()
464
+ // GATEWAY_VPC_IP and DASHBOARD_VPC_IP are still needed by deploy.ts for the compose VPC-IP
465
+ // publish and the DOCKER-USER iptables rule.
466
+ const deployStepIndex = text.indexOf('name: Deploy gateway')
467
+ expect(deployStepIndex).toBeGreaterThan(-1)
468
+ const afterDeployStep = text.slice(deployStepIndex)
469
+ expect(afterDeployStep).toContain('GATEWAY_VPC_IP')
470
+ expect(afterDeployStep).toContain('DASHBOARD_VPC_IP')
471
+ })
443
472
  })
444
473
 
445
474
  describe('findCrossOrgSecretsInherit', () => {
@@ -645,10 +674,9 @@ describe('dorny/paths-filter quantifier guard', () => {
645
674
  })
646
675
  })
647
676
 
648
- // ─── Unit 5: operator auth/config secrets and tuning vars ────────────────────
677
+ // ─── operator auth/config secrets and tuning vars ────────────────────────────
649
678
  //
650
- // RED tests for Unit 5 of 2026-06-18-002-feat-gateway-operator-auth-config-plan.md
651
- // These assert the four new auth/config secrets and three optional tuning vars are
679
+ // These assert the four operator auth/config secrets and three optional tuning vars are
652
680
  // wired through deploy-gateway.yaml (workflow_call.secrets + inputs + deploy step env),
653
681
  // deploy.yaml (fan-out secrets + with inputs), and the CLI passthrough.
654
682
 
@@ -843,6 +871,24 @@ describe('CLI getGatewayDeployEnv: operator auth/config passthrough', () => {
843
871
  Object.assign(process.env, origEnv)
844
872
  }
845
873
  })
874
+
875
+ it('getGatewayDeployEnv includes GATEWAY_VPC_IP and DASHBOARD_VPC_IP (but NOT DIGITALOCEAN_ACCESS_TOKEN)', async () => {
876
+ const {getGatewayDeployEnv} = await import('./commands/gateway/deploy')
877
+ const origEnv = {...process.env}
878
+ process.env.PATH = '/usr/bin'
879
+ process.env.HOME = '/home/test'
880
+ process.env.SSH_AUTH_SOCK = '/tmp/ssh.sock'
881
+ try {
882
+ const env = getGatewayDeployEnv()
883
+ // VPC IPs are still needed by deploy.ts for compose publish + DOCKER-USER rule
884
+ expect(env).toHaveProperty('GATEWAY_VPC_IP')
885
+ expect(env).toHaveProperty('DASHBOARD_VPC_IP')
886
+ // DIGITALOCEAN_ACCESS_TOKEN is provisioning-only — must NOT be in the deploy env
887
+ expect(env).not.toHaveProperty('DIGITALOCEAN_ACCESS_TOKEN')
888
+ } finally {
889
+ Object.assign(process.env, origEnv)
890
+ }
891
+ })
846
892
  })
847
893
 
848
894
  // ─── deploy.yaml: aggregate router passes operator secrets to deploy-gateway ──
@@ -988,3 +1034,147 @@ describe('deploy-gateway.yaml: optional operator secret declarations (issue 1)',
988
1034
  expect(validateStep?.env ?? {}).not.toHaveProperty('GATEWAY_OPERATOR_PUBLIC_ORIGIN')
989
1035
  })
990
1036
  })
1037
+
1038
+ // ─── VPC bridge secrets: CI-vs-local parity ──────────────────────────────────
1039
+ //
1040
+ // GATEWAY_VPC_IP and DASHBOARD_VPC_IP are optional (all-or-none via getOperatorVpcState)
1041
+ // but must be forwarded through CI so the operator VPC bridge is not silently disabled
1042
+ // when the secrets are set. DIGITALOCEAN_ACCESS_TOKEN is provisioning-only and must NOT
1043
+ // be forwarded to the deploy step.
1044
+ // These tests assert the end-to-end wiring without adding them to the required
1045
+ // validation preflight (they are opt-in, like the operator listener vars).
1046
+
1047
+ const VPC_GATEWAY_SECRETS = ['GATEWAY_VPC_IP', 'DASHBOARD_VPC_IP'] as const
1048
+ const VPC_DASHBOARD_SECRETS = ['GATEWAY_VPC_IP'] as const
1049
+
1050
+ describe('deploy-gateway.yaml: VPC bridge secrets wired end-to-end', () => {
1051
+ const DEPLOY_GATEWAY_WORKFLOW = resolve(REPO_ROOT, '.github/workflows/deploy-gateway.yaml')
1052
+
1053
+ for (const secret of VPC_GATEWAY_SECRETS) {
1054
+ it(`workflow_call.secrets declares ${secret} as optional (required: false)`, async () => {
1055
+ const text = await Bun.file(DEPLOY_GATEWAY_WORKFLOW).text()
1056
+ const parsed = parseYaml(text) as {on?: {workflow_call?: {secrets?: Record<string, {required?: boolean}>}}}
1057
+ const secrets = parsed?.on?.workflow_call?.secrets ?? {}
1058
+ expect(secrets).toHaveProperty(secret)
1059
+ expect(secrets[secret]?.required).toBe(false)
1060
+ })
1061
+
1062
+ it(`Deploy gateway step env forwards ${secret} from secrets context`, async () => {
1063
+ const text = await Bun.file(DEPLOY_GATEWAY_WORKFLOW).text()
1064
+ const parsed = parseYaml(text) as {
1065
+ jobs?: {'deploy-gateway'?: {steps?: {name?: string; env?: Record<string, string>}[]}}
1066
+ }
1067
+ const steps = parsed?.jobs?.['deploy-gateway']?.steps ?? []
1068
+ const deployStep = steps.find(s => s.name === 'Deploy gateway')
1069
+ expect(deployStep).toBeDefined()
1070
+ expect(deployStep?.env).toHaveProperty(secret)
1071
+ expect(deployStep?.env?.[secret]).toMatch(/\$\{\{\s*secrets\./)
1072
+ })
1073
+ }
1074
+
1075
+ it('VPC bridge secrets are NOT in the required-secret validation step env', async () => {
1076
+ const text = await Bun.file(DEPLOY_GATEWAY_WORKFLOW).text()
1077
+ const parsed = parseYaml(text) as {
1078
+ jobs?: {'deploy-gateway'?: {steps?: {name?: string; env?: Record<string, string>; run?: string}[]}}
1079
+ }
1080
+ const steps = parsed?.jobs?.['deploy-gateway']?.steps ?? []
1081
+ const validateStep = steps.find(s => s.name === 'Validate required secrets')
1082
+ expect(validateStep).toBeDefined()
1083
+ for (const secret of VPC_GATEWAY_SECRETS) {
1084
+ expect(validateStep?.env ?? {}).not.toHaveProperty(secret)
1085
+ }
1086
+ })
1087
+ })
1088
+
1089
+ describe('deploy-dashboard.yaml: VPC bridge secret wired end-to-end', () => {
1090
+ const DEPLOY_DASHBOARD_WORKFLOW = resolve(REPO_ROOT, '.github/workflows/deploy-dashboard.yaml')
1091
+
1092
+ for (const secret of VPC_DASHBOARD_SECRETS) {
1093
+ it(`workflow_call.secrets declares ${secret} as optional (required: false)`, async () => {
1094
+ const text = await Bun.file(DEPLOY_DASHBOARD_WORKFLOW).text()
1095
+ const parsed = parseYaml(text) as {on?: {workflow_call?: {secrets?: Record<string, {required?: boolean}>}}}
1096
+ const secrets = parsed?.on?.workflow_call?.secrets ?? {}
1097
+ expect(secrets).toHaveProperty(secret)
1098
+ expect(secrets[secret]?.required).toBe(false)
1099
+ })
1100
+
1101
+ it(`Deploy step env forwards ${secret} from secrets context`, async () => {
1102
+ const text = await Bun.file(DEPLOY_DASHBOARD_WORKFLOW).text()
1103
+ const parsed = parseYaml(text) as {
1104
+ jobs?: {'deploy-dashboard'?: {steps?: {name?: string; env?: Record<string, string>}[]}}
1105
+ }
1106
+ const steps = parsed?.jobs?.['deploy-dashboard']?.steps ?? []
1107
+ const deployStep = steps.find(s => s.name === 'Deploy')
1108
+ expect(deployStep).toBeDefined()
1109
+ expect(deployStep?.env).toHaveProperty(secret)
1110
+ expect(deployStep?.env?.[secret]).toMatch(/\$\{\{\s*secrets\./)
1111
+ })
1112
+ }
1113
+
1114
+ it('GATEWAY_VPC_IP is NOT in the required-secret validation step env', async () => {
1115
+ const text = await Bun.file(DEPLOY_DASHBOARD_WORKFLOW).text()
1116
+ const parsed = parseYaml(text) as {
1117
+ jobs?: {'deploy-dashboard'?: {steps?: {name?: string; env?: Record<string, string>; run?: string}[]}}
1118
+ }
1119
+ const steps = parsed?.jobs?.['deploy-dashboard']?.steps ?? []
1120
+ const validateStep = steps.find(s => s.name === 'Validate required secrets')
1121
+ expect(validateStep).toBeDefined()
1122
+ expect(validateStep?.env ?? {}).not.toHaveProperty('GATEWAY_VPC_IP')
1123
+ })
1124
+ })
1125
+
1126
+ describe('deploy.yaml: aggregate router forwards VPC bridge secrets', () => {
1127
+ const DEPLOY_WORKFLOW = resolve(REPO_ROOT, '.github/workflows/deploy.yaml')
1128
+
1129
+ for (const secret of VPC_GATEWAY_SECRETS) {
1130
+ it(`deploy-gateway job secrets block passes ${secret}`, async () => {
1131
+ const text = await Bun.file(DEPLOY_WORKFLOW).text()
1132
+ const parsed = parseYaml(text) as {
1133
+ jobs?: {'deploy-gateway'?: {secrets?: Record<string, string>}}
1134
+ }
1135
+ const secrets = parsed?.jobs?.['deploy-gateway']?.secrets ?? {}
1136
+ expect(secrets).toHaveProperty(secret)
1137
+ expect(secrets[secret]).toContain(secret)
1138
+ })
1139
+ }
1140
+
1141
+ for (const secret of VPC_DASHBOARD_SECRETS) {
1142
+ it(`deploy-dashboard job secrets block passes ${secret}`, async () => {
1143
+ const text = await Bun.file(DEPLOY_WORKFLOW).text()
1144
+ const parsed = parseYaml(text) as {
1145
+ jobs?: {'deploy-dashboard'?: {secrets?: Record<string, string>}}
1146
+ }
1147
+ const secrets = parsed?.jobs?.['deploy-dashboard']?.secrets ?? {}
1148
+ expect(secrets).toHaveProperty(secret)
1149
+ expect(secrets[secret]).toContain(secret)
1150
+ })
1151
+ }
1152
+ })
1153
+
1154
+ // ─── deploy.yaml: deploy-dashboard is decoupled from deploy-gateway ──────────
1155
+ //
1156
+ // The dashboard is a standalone deployment. A gateway failure or slowness must
1157
+ // not block dashboard deploys. deploy-dashboard must NOT declare needs: deploy-gateway,
1158
+ // and its if: must NOT reference needs.deploy-gateway — locking in the decoupling
1159
+ // so it cannot silently regress.
1160
+
1161
+ describe('deploy.yaml: deploy-dashboard is decoupled from deploy-gateway', () => {
1162
+ const DEPLOY_WORKFLOW = resolve(REPO_ROOT, '.github/workflows/deploy.yaml')
1163
+
1164
+ it('deploy-dashboard needs: does NOT include deploy-gateway (standalone job)', async () => {
1165
+ const text = await Bun.file(DEPLOY_WORKFLOW).text()
1166
+ const parsed = parseYaml(text) as {
1167
+ jobs?: {'deploy-dashboard'?: {needs?: string | string[]}}
1168
+ }
1169
+ const needs = parsed?.jobs?.['deploy-dashboard']?.needs ?? []
1170
+ const needsArr = Array.isArray(needs) ? needs : [needs]
1171
+ expect(needsArr).not.toContain('deploy-gateway')
1172
+ })
1173
+
1174
+ it('deploy-dashboard if: does NOT reference needs.deploy-gateway (no gateway coupling)', async () => {
1175
+ const text = await Bun.file(DEPLOY_WORKFLOW).text()
1176
+ // The if: expression must not reference needs.deploy-gateway in any form —
1177
+ // gateway state must not gate the dashboard deploy.
1178
+ expect(text).not.toMatch(/needs\.deploy-gateway/)
1179
+ })
1180
+ })