@ankhorage/infra 7.1.0 → 7.1.1

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/CHANGELOG.md CHANGED
@@ -1,5 +1,11 @@
1
1
  # @ankhorage/infra
2
2
 
3
+ ## 7.1.1
4
+
5
+ ### Patch Changes
6
+
7
+ - 7440682: Expose the project-scoped credential port constructor from `@ankhorage/infra/project` for downstream host composition without exposing credential storage details.
8
+
3
9
  ## 7.1.0
4
10
 
5
11
  ### Minor Changes
@@ -1,3 +1,4 @@
1
+ export { createProjectInfraCredentialPort } from '../features/environment-lifecycle/adapters/outbound/createProjectInfraCredentialPort.js';
1
2
  export { readStoredInfraStateAsync } from '../features/environment-lifecycle/adapters/outbound/readStoredInfraStateAsync.js';
2
3
  export { removeStoredInfraStateAsync } from '../features/environment-lifecycle/adapters/outbound/removeStoredInfraStateAsync.js';
3
4
  export { resolveInfraProjectAsync } from '../features/environment-lifecycle/adapters/outbound/resolveInfraProjectAsync.js';
@@ -1 +1 @@
1
- {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../src/project/index.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,yBAAyB,EAAE,MAAM,kFAAkF,CAAC;AAC7H,OAAO,EAAE,2BAA2B,EAAE,MAAM,oFAAoF,CAAC;AACjI,OAAO,EAAE,wBAAwB,EAAE,MAAM,iFAAiF,CAAC;AAC3H,OAAO,EAAE,iCAAiC,EAAE,MAAM,0FAA0F,CAAC;AAC7I,OAAO,EAAE,0BAA0B,EAAE,MAAM,mFAAmF,CAAC;AAC/H,OAAO,EAAE,2BAA2B,EAAE,MAAM,8EAA8E,CAAC;AAC3H,OAAO,EAAE,kBAAkB,EAAE,MAAM,+DAA+D,CAAC;AACnG,YAAY,EACV,kCAAkC,EAClC,wBAAwB,EACxB,0BAA0B,EAC1B,qBAAqB,EACrB,4BAA4B,EAC5B,oBAAoB,GACrB,MAAM,0BAA0B,CAAC"}
1
+ {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../src/project/index.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,gCAAgC,EAAE,MAAM,yFAAyF,CAAC;AAC3I,OAAO,EAAE,yBAAyB,EAAE,MAAM,kFAAkF,CAAC;AAC7H,OAAO,EAAE,2BAA2B,EAAE,MAAM,oFAAoF,CAAC;AACjI,OAAO,EAAE,wBAAwB,EAAE,MAAM,iFAAiF,CAAC;AAC3H,OAAO,EAAE,iCAAiC,EAAE,MAAM,0FAA0F,CAAC;AAC7I,OAAO,EAAE,0BAA0B,EAAE,MAAM,mFAAmF,CAAC;AAC/H,OAAO,EAAE,2BAA2B,EAAE,MAAM,8EAA8E,CAAC;AAC3H,OAAO,EAAE,kBAAkB,EAAE,MAAM,+DAA+D,CAAC;AACnG,YAAY,EACV,kCAAkC,EAClC,wBAAwB,EACxB,0BAA0B,EAC1B,qBAAqB,EACrB,4BAA4B,EAC5B,oBAAoB,GACrB,MAAM,0BAA0B,CAAC"}
@@ -1,3 +1,4 @@
1
+ export { createProjectInfraCredentialPort } from '../features/environment-lifecycle/adapters/outbound/createProjectInfraCredentialPort.js';
1
2
  export { readStoredInfraStateAsync } from '../features/environment-lifecycle/adapters/outbound/readStoredInfraStateAsync.js';
2
3
  export { removeStoredInfraStateAsync } from '../features/environment-lifecycle/adapters/outbound/removeStoredInfraStateAsync.js';
3
4
  export { resolveInfraProjectAsync } from '../features/environment-lifecycle/adapters/outbound/resolveInfraProjectAsync.js';
@@ -1 +1 @@
1
- {"version":3,"file":"index.js","sourceRoot":"","sources":["../../src/project/index.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,yBAAyB,EAAE,MAAM,kFAAkF,CAAC;AAC7H,OAAO,EAAE,2BAA2B,EAAE,MAAM,oFAAoF,CAAC;AACjI,OAAO,EAAE,wBAAwB,EAAE,MAAM,iFAAiF,CAAC;AAC3H,OAAO,EAAE,iCAAiC,EAAE,MAAM,0FAA0F,CAAC;AAC7I,OAAO,EAAE,0BAA0B,EAAE,MAAM,mFAAmF,CAAC;AAC/H,OAAO,EAAE,2BAA2B,EAAE,MAAM,8EAA8E,CAAC;AAC3H,OAAO,EAAE,kBAAkB,EAAE,MAAM,+DAA+D,CAAC","sourcesContent":["export { readStoredInfraStateAsync } from '../features/environment-lifecycle/adapters/outbound/readStoredInfraStateAsync.js';\nexport { removeStoredInfraStateAsync } from '../features/environment-lifecycle/adapters/outbound/removeStoredInfraStateAsync.js';\nexport { resolveInfraProjectAsync } from '../features/environment-lifecycle/adapters/outbound/resolveInfraProjectAsync.js';\nexport { writeInfraGeneratedArtifactsAsync } from '../features/environment-lifecycle/adapters/outbound/writeInfraGeneratedArtifactsAsync.js';\nexport { writeStoredInfraStateAsync } from '../features/environment-lifecycle/adapters/outbound/writeStoredInfraStateAsync.js';\nexport { createProjectInfraLifecycle } from '../features/environment-lifecycle/composition/createProjectInfraLifecycle.js';\nexport { resolveProjectFile } from '../features/environment-lifecycle/utils/resolveProjectFile.js';\nexport type {\n CreateProjectInfraLifecycleOptions,\n InfraArtifactWriteResult,\n ProjectInfraDestroyRequest,\n ProjectInfraLifecycle,\n ProjectInfraOperationRequest,\n ResolvedInfraProject,\n} from '../types/infraProject.js';\n"]}
1
+ {"version":3,"file":"index.js","sourceRoot":"","sources":["../../src/project/index.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,gCAAgC,EAAE,MAAM,yFAAyF,CAAC;AAC3I,OAAO,EAAE,yBAAyB,EAAE,MAAM,kFAAkF,CAAC;AAC7H,OAAO,EAAE,2BAA2B,EAAE,MAAM,oFAAoF,CAAC;AACjI,OAAO,EAAE,wBAAwB,EAAE,MAAM,iFAAiF,CAAC;AAC3H,OAAO,EAAE,iCAAiC,EAAE,MAAM,0FAA0F,CAAC;AAC7I,OAAO,EAAE,0BAA0B,EAAE,MAAM,mFAAmF,CAAC;AAC/H,OAAO,EAAE,2BAA2B,EAAE,MAAM,8EAA8E,CAAC;AAC3H,OAAO,EAAE,kBAAkB,EAAE,MAAM,+DAA+D,CAAC","sourcesContent":["export { createProjectInfraCredentialPort } from '../features/environment-lifecycle/adapters/outbound/createProjectInfraCredentialPort.js';\nexport { readStoredInfraStateAsync } from '../features/environment-lifecycle/adapters/outbound/readStoredInfraStateAsync.js';\nexport { removeStoredInfraStateAsync } from '../features/environment-lifecycle/adapters/outbound/removeStoredInfraStateAsync.js';\nexport { resolveInfraProjectAsync } from '../features/environment-lifecycle/adapters/outbound/resolveInfraProjectAsync.js';\nexport { writeInfraGeneratedArtifactsAsync } from '../features/environment-lifecycle/adapters/outbound/writeInfraGeneratedArtifactsAsync.js';\nexport { writeStoredInfraStateAsync } from '../features/environment-lifecycle/adapters/outbound/writeStoredInfraStateAsync.js';\nexport { createProjectInfraLifecycle } from '../features/environment-lifecycle/composition/createProjectInfraLifecycle.js';\nexport { resolveProjectFile } from '../features/environment-lifecycle/utils/resolveProjectFile.js';\nexport type {\n CreateProjectInfraLifecycleOptions,\n InfraArtifactWriteResult,\n ProjectInfraDestroyRequest,\n ProjectInfraLifecycle,\n ProjectInfraOperationRequest,\n ResolvedInfraProject,\n} from '../types/infraProject.js';\n"]}
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@ankhorage/infra",
3
3
  "type": "module",
4
- "version": "7.1.0",
4
+ "version": "7.1.1",
5
5
  "description": "Project-aware infrastructure generation library and executable Ankh provider for Ankhorage.",
6
6
  "homepage": "https://github.com/ankhorage/infra#readme",
7
7
  "bugs": {
@@ -121,7 +121,7 @@
121
121
  "@ankhorage/minikube": "^0.4.4",
122
122
  "@ankhorage/paradox": "^0.1.24",
123
123
  "@ankhorage/r2": "^0.2.0",
124
- "@ankhorage/supabase": "^0.5.9",
124
+ "@ankhorage/supabase": "^0.5.10",
125
125
  "@ankhorage/supabase-vault": "^0.3.1",
126
126
  "@types/bun": "^1.4.1",
127
127
  "@types/node": "^25.6.0",
@@ -1,4 +1,6 @@
1
1
  import type {
2
+ InfraControlPlaneCredentialRef,
3
+ InfraCredentialPort,
2
4
  InfraLedger,
3
5
  InfraManifest,
4
6
  InfraResourceIdentity,
@@ -230,9 +232,7 @@ function createDependencies(fixture: ProductionFixture): InfraOrchestrationDepen
230
232
  }
231
233
  },
232
234
  },
233
- credentials: {
234
- resolveAsync: ({ name }) => Promise.resolve(resolveCredential(name)),
235
- },
235
+ credentials: createCredentialPort(),
236
236
  secrets: {
237
237
  resolveAsync: () =>
238
238
  Promise.resolve({
@@ -245,6 +245,32 @@ function createDependencies(fixture: ProductionFixture): InfraOrchestrationDepen
245
245
  };
246
246
  }
247
247
 
248
+ function createCredentialPort(): InfraCredentialPort {
249
+ return {
250
+ findAsync: (reference) => Promise.resolve(findCredential(reference)),
251
+ resolveAsync: ({ name }) => Promise.resolve(resolveCredential(name)),
252
+ persistAsync: (reference) =>
253
+ Promise.resolve({
254
+ ok: false,
255
+ diagnostics: [
256
+ {
257
+ severity: 'error',
258
+ code: 'unexpected-credential-persist',
259
+ message: `Production fixture must not persist credential ${reference.name}.`,
260
+ },
261
+ ],
262
+ }),
263
+ };
264
+ }
265
+
266
+ function findCredential(
267
+ reference: InfraControlPlaneCredentialRef,
268
+ ): InfraResult<Readonly<Record<string, string>> | null> {
269
+ return reference.name === 'SUPABASE_BOOTSTRAP'
270
+ ? resolveCredential(reference.name)
271
+ : success(null);
272
+ }
273
+
248
274
  function resolveCredential(name: string): InfraResult<Readonly<Record<string, string>>> {
249
275
  if (name === 'HCLOUD_TOKEN') return success({ token: 'phase10-hcloud-token' });
250
276
  if (name === 'HETZNER_SSH') {
@@ -1,4 +1,9 @@
1
- import type { InfraManifest, InfraResult } from '@ankhorage/contracts/infra';
1
+ import type {
2
+ InfraControlPlaneCredentialRef,
3
+ InfraCredentialPort,
4
+ InfraManifest,
5
+ InfraResult,
6
+ } from '@ankhorage/contracts/infra';
2
7
  import {
3
8
  createInfraAdapter as createHetznerInfraAdapter,
4
9
  type HetznerCloudApi,
@@ -189,9 +194,7 @@ function createDependencies(fixture: ProductionR2Fixture): InfraOrchestrationDep
189
194
  }
190
195
  },
191
196
  },
192
- credentials: {
193
- resolveAsync: ({ name }) => Promise.resolve(resolveCredential(name)),
194
- },
197
+ credentials: createCredentialPort(),
195
198
  secrets: {
196
199
  resolveAsync: () =>
197
200
  Promise.resolve({
@@ -204,6 +207,32 @@ function createDependencies(fixture: ProductionR2Fixture): InfraOrchestrationDep
204
207
  };
205
208
  }
206
209
 
210
+ function createCredentialPort(): InfraCredentialPort {
211
+ return {
212
+ findAsync: (reference) => Promise.resolve(findCredential(reference)),
213
+ resolveAsync: ({ name }) => Promise.resolve(resolveCredential(name)),
214
+ persistAsync: (reference) =>
215
+ Promise.resolve({
216
+ ok: false,
217
+ diagnostics: [
218
+ {
219
+ severity: 'error',
220
+ code: 'unexpected-credential-persist',
221
+ message: `Production fixture must not persist credential ${reference.name}.`,
222
+ },
223
+ ],
224
+ }),
225
+ };
226
+ }
227
+
228
+ function findCredential(
229
+ reference: InfraControlPlaneCredentialRef,
230
+ ): InfraResult<Readonly<Record<string, string>> | null> {
231
+ return reference.name === 'SUPABASE_BOOTSTRAP'
232
+ ? resolveCredential(reference.name)
233
+ : success(null);
234
+ }
235
+
207
236
  function resolveCredential(name: string): InfraResult<Readonly<Record<string, string>>> {
208
237
  if (name === 'HCLOUD_TOKEN') return success({ token: 'phase10-hcloud-token' });
209
238
  if (name === 'HETZNER_SSH') {
@@ -1,6 +1,8 @@
1
- import { createHmac } from 'node:crypto';
1
+ import { createHash } from 'node:crypto';
2
2
 
3
3
  import type {
4
+ InfraControlPlaneCredentialRef,
5
+ InfraCredentialPort,
4
6
  InfraLedger,
5
7
  InfraManifest,
6
8
  InfraResourceIdentity,
@@ -19,16 +21,7 @@ import type { InfraOrchestrationDependencies } from './types/infraOrchestration.
19
21
  const projectId = 'infra145-compose-providers';
20
22
  const baseUrl = 'http://127.0.0.1:54322';
21
23
  const bucket = 'phase9-acceptance';
22
- const jwtSecret = 'phase9-jwt-secret-that-is-at-least-thirty-two-characters';
23
- const credentials = {
24
- postgresPassword: 'phase9-postgres-password',
25
- jwtSecret,
26
- anonKey: createJwt('anon'),
27
- serviceRoleKey: createJwt('service_role'),
28
- realtimeSecretKeyBase: 'r'.repeat(64),
29
- realtimeDatabaseEncryptionKey: '0123456789abcdef',
30
- pgMetaCryptoKey: 'phase9-meta-crypto-key-that-is-at-least-32-characters',
31
- } as const;
24
+ const bootstrapCredential = { source: 'control-plane', name: 'SUPABASE_BOOTSTRAP' } as const;
32
25
  const manifest = {
33
26
  environments: {
34
27
  local: {
@@ -59,7 +52,8 @@ const manifest = {
59
52
  test.skipIf(process.env.ANKH_INFRA_DOCKER_COMPOSE_PROVIDERS_E2E !== '1')(
60
53
  'runs Supabase and Cerbos through the provider-neutral Docker Compose lifecycle',
61
54
  async () => {
62
- const dependencies = createDependencies();
55
+ const credentialPort = new MemoryCredentialPort();
56
+ const dependencies = createDependencies(credentialPort);
63
57
  let ledger: InfraLedger | undefined;
64
58
 
65
59
  try {
@@ -72,7 +66,10 @@ test.skipIf(process.env.ANKH_INFRA_DOCKER_COMPOSE_PROVIDERS_E2E !== '1')(
72
66
  await upInfraEnvironmentAsync({ projectId, manifest }, dependencies),
73
67
  );
74
68
  ledger = firstLedger;
75
- assertProviderNeutralOutputs(firstOutputs);
69
+ expect(credentialPort.persistCount).toBe(1);
70
+ const firstCredentials = credentialPort.readRequired();
71
+ const firstCredentialFingerprint = credentialFingerprint(firstCredentials);
72
+ assertProviderNeutralOutputs(firstOutputs, firstCredentials);
76
73
 
77
74
  const status = requireSuccess(
78
75
  await statusInfraEnvironmentAsync({ projectId, manifest, previous: ledger }, dependencies),
@@ -98,7 +95,9 @@ test.skipIf(process.env.ANKH_INFRA_DOCKER_COMPOSE_PROVIDERS_E2E !== '1')(
98
95
  await upInfraEnvironmentAsync({ projectId, manifest, previous: ledger }, dependencies),
99
96
  );
100
97
  ledger = resumedLedger;
101
- assertProviderNeutralOutputs(resumedOutputs);
98
+ expect(credentialPort.persistCount).toBe(1);
99
+ expect(credentialFingerprint(credentialPort.readRequired())).toBe(firstCredentialFingerprint);
100
+ assertProviderNeutralOutputs(resumedOutputs, firstCredentials);
102
101
 
103
102
  const health = await fetch(`${baseUrl}/auth/v1/health`);
104
103
  expect(health.ok).toBe(true);
@@ -123,20 +122,10 @@ test.skipIf(process.env.ANKH_INFRA_DOCKER_COMPOSE_PROVIDERS_E2E !== '1')(
123
122
  900_000,
124
123
  );
125
124
 
126
- function createDependencies(): InfraOrchestrationDependencies {
125
+ function createDependencies(credentials: InfraCredentialPort): InfraOrchestrationDependencies {
127
126
  return {
128
127
  adapterResolver: createNodeInfraAdapterPackageResolver(),
129
- credentials: {
130
- resolveAsync: (reference) =>
131
- Promise.resolve(
132
- reference.name === 'SUPABASE_BOOTSTRAP'
133
- ? success(credentials)
134
- : failure(
135
- 'unexpected-control-plane-credential',
136
- `Unexpected control-plane credential ${reference.name}.`,
137
- ),
138
- ),
139
- },
128
+ credentials,
140
129
  secrets: {
141
130
  resolveAsync: (reference) =>
142
131
  Promise.resolve(
@@ -149,6 +138,73 @@ function createDependencies(): InfraOrchestrationDependencies {
149
138
  };
150
139
  }
151
140
 
141
+ class MemoryCredentialPort implements InfraCredentialPort {
142
+ private values: Readonly<Record<string, string>> | null = null;
143
+ persistCount = 0;
144
+
145
+ findAsync(
146
+ reference: InfraControlPlaneCredentialRef,
147
+ ): Promise<InfraResult<Readonly<Record<string, string>> | null>> {
148
+ if (reference.name !== bootstrapCredential.name) return Promise.resolve(success(null));
149
+ return Promise.resolve(success(this.values === null ? null : { ...this.values }));
150
+ }
151
+
152
+ resolveAsync(
153
+ reference: InfraControlPlaneCredentialRef,
154
+ ): Promise<InfraResult<Readonly<Record<string, string>>>> {
155
+ if (reference.name === bootstrapCredential.name && this.values !== null) {
156
+ return Promise.resolve(success({ ...this.values }));
157
+ }
158
+ return Promise.resolve(
159
+ failure(
160
+ 'unexpected-control-plane-credential',
161
+ `Unexpected or missing control-plane credential ${reference.name}.`,
162
+ ),
163
+ );
164
+ }
165
+
166
+ persistAsync(
167
+ reference: InfraControlPlaneCredentialRef,
168
+ values: Readonly<Record<string, string>>,
169
+ ): Promise<InfraResult<null>> {
170
+ if (reference.name !== bootstrapCredential.name) {
171
+ return Promise.resolve(
172
+ failure(
173
+ 'unexpected-control-plane-credential',
174
+ `Unexpected control-plane credential ${reference.name}.`,
175
+ ),
176
+ );
177
+ }
178
+ this.values = { ...values };
179
+ this.persistCount += 1;
180
+ return Promise.resolve(success(null));
181
+ }
182
+
183
+ readRequired(): Readonly<Record<string, string>> {
184
+ if (this.values === null) throw new Error('Expected generated Supabase bootstrap credentials.');
185
+ return { ...this.values };
186
+ }
187
+ }
188
+
189
+ function credentialFingerprint(values: Readonly<Record<string, string>>): string {
190
+ const canonical = Object.entries(values)
191
+ .sort(([left], [right]) => left.localeCompare(right))
192
+ .map(([key, value]) => `${key}\u0000${value}`)
193
+ .join('\u0001');
194
+ return createHash('sha256').update(canonical).digest('hex');
195
+ }
196
+
197
+ function privilegedCredentialValues(values: Readonly<Record<string, string>>): readonly string[] {
198
+ const { anonKey: _anonKey, ...privileged } = values;
199
+ return Object.values(privileged);
200
+ }
201
+
202
+ function readAnonKey(values: Readonly<Record<string, string>>): string {
203
+ const { anonKey } = values;
204
+ if (anonKey === undefined) throw new Error('Expected generated Supabase anonKey.');
205
+ return anonKey;
206
+ }
207
+
152
208
  function createDestroyRequest(
153
209
  previous: InfraLedger,
154
210
  confirmedResources: readonly InfraResourceIdentity[],
@@ -167,14 +223,14 @@ function persistentIdentities(ledger: InfraLedger): readonly InfraResourceIdenti
167
223
  return ledger.resources.filter(({ persistent }) => persistent).map(({ identity }) => identity);
168
224
  }
169
225
 
170
- function assertProviderNeutralOutputs(outputs: InfraLedger['outputs']): void {
226
+ function assertProviderNeutralOutputs(
227
+ outputs: InfraLedger['outputs'],
228
+ credentials: Readonly<Record<string, string>>,
229
+ ): void {
171
230
  const serialized = JSON.stringify(outputs);
172
- expect(serialized).not.toContain(credentials.serviceRoleKey);
173
- expect(serialized).not.toContain(credentials.postgresPassword);
174
- expect(serialized).not.toContain(credentials.jwtSecret);
175
- expect(serialized).not.toContain(credentials.realtimeSecretKeyBase);
176
- expect(serialized).not.toContain(credentials.realtimeDatabaseEncryptionKey);
177
- expect(serialized).not.toContain(credentials.pgMetaCryptoKey);
231
+ for (const secret of privilegedCredentialValues(credentials)) {
232
+ expect(serialized).not.toContain(secret);
233
+ }
178
234
  expect(serialized.toLowerCase()).not.toContain('kubernetes');
179
235
  expect(serialized.toLowerCase()).not.toContain('minikube');
180
236
  expect(
@@ -190,7 +246,7 @@ function assertProviderNeutralOutputs(outputs: InfraLedger['outputs']): void {
190
246
  ({ owner, environmentVariable, value }) =>
191
247
  owner.adapter === 'supabase' &&
192
248
  environmentVariable === 'EXPO_PUBLIC_SUPABASE_ANON_KEY' &&
193
- value === credentials.anonKey,
249
+ value === readAnonKey(credentials),
194
250
  ),
195
251
  ).toBe(true);
196
252
  expect(
@@ -209,23 +265,6 @@ function assertProviderNeutralOutputs(outputs: InfraLedger['outputs']): void {
209
265
  ).toBe(true);
210
266
  }
211
267
 
212
- function createJwt(role: 'anon' | 'service_role'): string {
213
- const encodedHeader = encodeJwtPart({ alg: 'HS256', typ: 'JWT' });
214
- const encodedPayload = encodeJwtPart({
215
- role,
216
- iss: 'supabase',
217
- iat: 1_700_000_000,
218
- exp: 4_102_444_800,
219
- });
220
- const unsigned = `${encodedHeader}.${encodedPayload}`;
221
- const signature = createHmac('sha256', jwtSecret).update(unsigned).digest('base64url');
222
- return `${unsigned}.${signature}`;
223
- }
224
-
225
- function encodeJwtPart(value: Readonly<Record<string, string | number>>): string {
226
- return Buffer.from(JSON.stringify(value)).toString('base64url');
227
- }
228
-
229
268
  function requireSuccess<T>(result: InfraResult<T>): T {
230
269
  if (!result.ok) throw new Error(result.diagnostics.map(({ message }) => message).join(' '));
231
270
  return result.value;
@@ -1,5 +1,9 @@
1
+ import { createHash } from 'node:crypto';
2
+
1
3
  import type {
2
4
  InfraComputeTarget,
5
+ InfraControlPlaneCredentialRef,
6
+ InfraCredentialPort,
3
7
  InfraLedger,
4
8
  InfraManifest,
5
9
  InfraResourceIdentity,
@@ -38,6 +42,7 @@ import type { InfraOrchestrationDependencies } from './types/infraOrchestration.
38
42
  const projectId = 'infra164-local-k3s-supabase';
39
43
  const publicBaseUrl = 'http://127.0.0.1:54321';
40
44
  const bucket = 'infra164-bucket';
45
+ const bootstrapCredential = { source: 'control-plane', name: 'SUPABASE_BOOTSTRAP' } as const;
41
46
  const target: Extract<InfraComputeTarget, { kind: 'local-host' }> = {
42
47
  id: 'local',
43
48
  kind: 'local-host',
@@ -73,6 +78,10 @@ test('runs local k3s and Supabase through the portable compute lifecycle', async
73
78
  await upInfraEnvironmentAsync({ projectId, manifest }, dependencies),
74
79
  );
75
80
  const firstLedger: InfraLedger = firstUp.ledger;
81
+ expect(fixture.credentials.persistCount).toBe(1);
82
+ const firstCredentials = fixture.credentials.readRequired();
83
+ const firstCredentialFingerprint = credentialFingerprint(firstCredentials);
84
+ assertPrivilegedValuesAbsent(firstUp, firstCredentials);
76
85
  expect(firstUp.targets).toEqual([target]);
77
86
  expect([...new Set(fixture.loadedPackages)].sort()).toEqual(
78
87
  [
@@ -88,8 +97,6 @@ test('runs local k3s and Supabase through the portable compute lifecycle', async
88
97
  expect(fixture.kubernetes.serializedResources()).toContain('/etc/postgresql-custom');
89
98
  expect(fixture.kubernetes.serializedResources()).toContain('.ankhorage-image-seeded');
90
99
  expect(fixture.supabase.buckets.has(bucket)).toBe(true);
91
- expect(JSON.stringify(firstUp)).not.toContain('infra164-service-role-key');
92
- expect(JSON.stringify(firstUp)).not.toContain('infra164-postgres-password');
93
100
 
94
101
  const outputs = requireSuccess(
95
102
  getInfraEnvironmentOutputs({
@@ -144,6 +151,11 @@ test('runs local k3s and Supabase through the portable compute lifecycle', async
144
151
  expect(fixture.k3s.state).toBe('ready');
145
152
  expect(resumed.targets).toEqual([target]);
146
153
  expect(resourceIdentities(resumed.resources)).toEqual(identitiesBeforeRestart);
154
+ expect(fixture.credentials.persistCount).toBe(1);
155
+ expect(credentialFingerprint(fixture.credentials.readRequired())).toBe(
156
+ firstCredentialFingerprint,
157
+ );
158
+ assertPrivilegedValuesAbsent(resumed, firstCredentials);
147
159
 
148
160
  const destroyed = requireSuccess(
149
161
  await destroyInfraEnvironmentAsync(
@@ -171,6 +183,7 @@ class LocalK3sFixture {
171
183
  readonly k3s = new FakeK3sControlPlane();
172
184
  readonly kubernetes = this.k3s.api;
173
185
  readonly supabase = new FakeSupabaseControlPlane();
186
+ readonly credentials = new MemoryCredentialPort();
174
187
  readonly loadedPackages: string[] = [];
175
188
  }
176
189
 
@@ -201,9 +214,7 @@ function createDependencies(fixture: LocalK3sFixture): InfraOrchestrationDepende
201
214
  }
202
215
  },
203
216
  },
204
- credentials: {
205
- resolveAsync: ({ name }) => Promise.resolve(resolveCredential(name)),
206
- },
217
+ credentials: fixture.credentials,
207
218
  secrets: {
208
219
  resolveAsync: () =>
209
220
  Promise.resolve({
@@ -216,29 +227,72 @@ function createDependencies(fixture: LocalK3sFixture): InfraOrchestrationDepende
216
227
  };
217
228
  }
218
229
 
219
- function resolveCredential(name: string): InfraResult<Readonly<Record<string, string>>> {
220
- if (name === 'SUPABASE_BOOTSTRAP') {
221
- return success({
222
- postgresPassword: 'infra164-postgres-password',
223
- jwtSecret: 'infra164-jwt-secret-0123456789abcdef',
224
- anonKey: 'infra164-anon-key',
225
- serviceRoleKey: 'infra164-service-role-key',
226
- realtimeSecretKeyBase:
227
- 'infra164-realtime-secret-key-base-0123456789abcdefghijklmnopqrstuvwxyz',
228
- realtimeDatabaseEncryptionKey: '1234567890abcdef',
229
- pgMetaCryptoKey: 'infra164-pg-meta-crypto-key-0123456789abcdef',
230
- });
230
+ class MemoryCredentialPort implements InfraCredentialPort {
231
+ private values: Readonly<Record<string, string>> | null = null;
232
+ persistCount = 0;
233
+
234
+ findAsync(
235
+ reference: InfraControlPlaneCredentialRef,
236
+ ): Promise<InfraResult<Readonly<Record<string, string>> | null>> {
237
+ if (reference.name !== bootstrapCredential.name) return Promise.resolve(success(null));
238
+ return Promise.resolve(success(this.values === null ? null : { ...this.values }));
239
+ }
240
+
241
+ resolveAsync(
242
+ reference: InfraControlPlaneCredentialRef,
243
+ ): Promise<InfraResult<Readonly<Record<string, string>>>> {
244
+ if (reference.name === bootstrapCredential.name && this.values !== null) {
245
+ return Promise.resolve(success({ ...this.values }));
246
+ }
247
+ return Promise.resolve(
248
+ failure(
249
+ 'unexpected-credential',
250
+ `Unexpected or missing credential reference: ${reference.name}`,
251
+ ),
252
+ );
253
+ }
254
+
255
+ persistAsync(
256
+ reference: InfraControlPlaneCredentialRef,
257
+ values: Readonly<Record<string, string>>,
258
+ ): Promise<InfraResult<null>> {
259
+ if (reference.name !== bootstrapCredential.name) {
260
+ return Promise.resolve(
261
+ failure('unexpected-credential', `Unexpected credential reference: ${reference.name}`),
262
+ );
263
+ }
264
+ this.values = { ...values };
265
+ this.persistCount += 1;
266
+ return Promise.resolve(success(null));
267
+ }
268
+
269
+ readRequired(): Readonly<Record<string, string>> {
270
+ if (this.values === null) throw new Error('Expected generated Supabase bootstrap credentials.');
271
+ return { ...this.values };
272
+ }
273
+ }
274
+
275
+ function credentialFingerprint(values: Readonly<Record<string, string>>): string {
276
+ const canonical = Object.entries(values)
277
+ .sort(([left], [right]) => left.localeCompare(right))
278
+ .map(([key, value]) => `${key}\u0000${value}`)
279
+ .join('\u0001');
280
+ return createHash('sha256').update(canonical).digest('hex');
281
+ }
282
+
283
+ function privilegedCredentialValues(values: Readonly<Record<string, string>>): readonly string[] {
284
+ const { anonKey: _anonKey, ...privileged } = values;
285
+ return Object.values(privileged);
286
+ }
287
+
288
+ function assertPrivilegedValuesAbsent(
289
+ value: unknown,
290
+ credentials: Readonly<Record<string, string>>,
291
+ ): void {
292
+ const serialized = JSON.stringify(value);
293
+ for (const secret of privilegedCredentialValues(credentials)) {
294
+ expect(serialized).not.toContain(secret);
231
295
  }
232
- return {
233
- ok: false,
234
- diagnostics: [
235
- {
236
- severity: 'error',
237
- code: 'unexpected-credential',
238
- message: `Unexpected credential reference: ${name}`,
239
- },
240
- ],
241
- };
242
296
  }
243
297
 
244
298
  class FakeLocalHostProbe implements LocalHostProbe {
@@ -446,3 +500,7 @@ function requireSuccess<T>(result: InfraResult<T>): T {
446
500
  function success<T>(value: T): InfraResult<T> {
447
501
  return { ok: true, value, diagnostics: [] };
448
502
  }
503
+
504
+ function failure(code: string, message: string): InfraResult<never> {
505
+ return { ok: false, diagnostics: [{ severity: 'error', code, message }] };
506
+ }
@@ -1,4 +1,7 @@
1
- import { createHmac } from 'node:crypto';
1
+ import { createHash } from 'node:crypto';
2
+ import { promises as fs } from 'node:fs';
3
+ import os from 'node:os';
4
+ import path from 'node:path';
2
5
 
3
6
  import type {
4
7
  InfraLedger,
@@ -8,13 +11,8 @@ import type {
8
11
  } from '@ankhorage/contracts/infra';
9
12
  import { expect, test } from 'bun:test';
10
13
 
11
- import { createNodeInfraAdapterPackageResolver } from './features/environment-lifecycle/adapters/outbound/createNodeInfraAdapterPackageResolver.js';
12
- import { destroyInfraEnvironmentAsync } from './features/environment-lifecycle/application/use-cases/destroyInfraEnvironmentAsync.js';
13
- import { downInfraEnvironmentAsync } from './features/environment-lifecycle/application/use-cases/downInfraEnvironmentAsync.js';
14
- import { planInfraEnvironmentAsync } from './features/environment-lifecycle/application/use-cases/planInfraEnvironmentAsync.js';
15
- import { statusInfraEnvironmentAsync } from './features/environment-lifecycle/application/use-cases/statusInfraEnvironmentAsync.js';
16
- import { upInfraEnvironmentAsync } from './features/environment-lifecycle/application/use-cases/upInfraEnvironmentAsync.js';
17
- import type { InfraOrchestrationDependencies } from './types/infraOrchestration.js';
14
+ import { createProjectInfraCredentialPort } from './features/environment-lifecycle/adapters/outbound/createProjectInfraCredentialPort.js';
15
+ import { createProjectInfraLifecycle } from './project/index.js';
18
16
 
19
17
  const projectId = 'infra145-minikube-supabase';
20
18
  const acceptanceNamespace = `${projectId}-local`;
@@ -23,15 +21,9 @@ const bucket = 'phase8-acceptance';
23
21
  const staleOwnedConfigMap = 'phase8-stale-owned';
24
22
  const unrelatedConfigMap = 'phase8-unrelated';
25
23
  const staleOwnedResourceId = 'stale:phase8-owned';
26
- const jwtSecret = 'phase8-jwt-secret-that-is-at-least-thirty-two-characters';
27
- const credentials = {
28
- postgresPassword: 'phase8-postgres-password',
29
- jwtSecret,
30
- anonKey: createJwt('anon'),
31
- serviceRoleKey: createJwt('service_role'),
32
- realtimeSecretKeyBase: 'r'.repeat(64),
33
- realtimeDatabaseEncryptionKey: '0123456789abcdef',
34
- pgMetaCryptoKey: 'phase8-meta-crypto-key-that-is-at-least-32-characters',
24
+ const bootstrapCredential = {
25
+ source: 'control-plane',
26
+ name: 'SUPABASE_BOOTSTRAP',
35
27
  } as const;
36
28
  const manifest = {
37
29
  environments: {
@@ -56,52 +48,60 @@ const manifest = {
56
48
  } as const satisfies InfraManifest;
57
49
 
58
50
  test.skipIf(process.env.ANKH_INFRA_MINIKUBE_SUPABASE_E2E !== '1')(
59
- 'runs local Minikube and Supabase through the provider-neutral lifecycle',
51
+ 'bootstraps fresh local Minikube and Supabase through the project lifecycle',
60
52
  async () => {
61
- const dependencies = createDependencies();
53
+ const projectPath = await fs.mkdtemp(path.join(os.tmpdir(), 'ankh-infra170-minikube-'));
54
+ const isolatedProjectPath = await fs.mkdtemp(path.join(os.tmpdir(), 'ankh-infra170-isolated-'));
55
+ const request = createOperationRequest(projectPath);
62
56
  let ledger: InfraLedger | undefined;
57
+ let destroyed = false;
63
58
 
64
59
  try {
65
- const initialPlan = requireSuccess(
66
- await planInfraEnvironmentAsync({ projectId, manifest }, dependencies),
67
- );
60
+ const firstLifecycle = createProjectInfraLifecycle();
61
+ const initialPlan = requireSuccess(await firstLifecycle.planAsync(request));
68
62
  expect(initialPlan.actions.some(({ operation }) => operation === 'create')).toBe(true);
69
63
 
70
- const { ledger: firstLedger, outputs: firstOutputs } = requireSuccess(
71
- await upInfraEnvironmentAsync({ projectId, manifest }, dependencies),
72
- );
73
- ledger = firstLedger;
74
- assertSafePublicOutputs(firstOutputs);
64
+ const firstUp = requireSuccess(await firstLifecycle.upAsync(request));
65
+ ({ ledger } = firstUp);
66
+ const firstCredentials = await resolveBootstrapCredentialsAsync(projectPath);
67
+ const firstFingerprint = credentialFingerprint(firstCredentials);
68
+ assertSafePublicOutputs(firstUp.outputs, firstCredentials);
69
+ assertSecretFree(firstUp, firstCredentials);
70
+ await assertStoredStateSecretFreeAsync(projectPath, firstCredentials);
71
+ await assertCredentialFilePermissionsAsync(projectPath);
75
72
  expect(
76
- firstOutputs.some(
73
+ firstUp.outputs.some(
77
74
  ({ owner, name, value }) =>
78
75
  owner.adapter === 'minikube' && name === 'localUrl' && value === baseUrl,
79
76
  ),
80
77
  ).toBe(true);
81
78
  expect(
82
- firstOutputs.some(
79
+ firstUp.outputs.some(
83
80
  ({ environmentVariable, value }) =>
84
81
  environmentVariable === 'EXPO_PUBLIC_SUPABASE_URL' && value === baseUrl,
85
82
  ),
86
83
  ).toBe(true);
87
- expect(firstOutputs.some(({ name, value }) => name === 'bucket' && value === bucket)).toBe(
84
+ expect(firstUp.outputs.some(({ name, value }) => name === 'bucket' && value === bucket)).toBe(
88
85
  true,
89
86
  );
90
87
 
91
- const status = requireSuccess(
92
- await statusInfraEnvironmentAsync({ projectId, manifest, previous: ledger }, dependencies),
93
- );
88
+ const secondLifecycle = createProjectInfraLifecycle();
89
+ const status = requireSuccess(await secondLifecycle.statusAsync(request));
94
90
  expect(status.state).toBe('ready');
95
91
 
96
- const convergedPlan = requireSuccess(
97
- await planInfraEnvironmentAsync({ projectId, manifest, previous: ledger }, dependencies),
98
- );
92
+ const convergedPlan = requireSuccess(await secondLifecycle.planAsync(request));
99
93
  expect(convergedPlan.actions.every(({ operation }) => operation === 'noop')).toBe(true);
94
+ assertSecretFree(convergedPlan, firstCredentials);
100
95
 
101
- await createStaleOwnershipFixturesAsync();
102
- const stalePlan = requireSuccess(
103
- await planInfraEnvironmentAsync({ projectId, manifest, previous: ledger }, dependencies),
96
+ const secondUp = requireSuccess(await secondLifecycle.upAsync(request));
97
+ ({ ledger } = secondUp);
98
+ expect(credentialFingerprint(await resolveBootstrapCredentialsAsync(projectPath))).toBe(
99
+ firstFingerprint,
104
100
  );
101
+ expect(resourceIdentities(secondUp.resources)).toEqual(resourceIdentities(firstUp.resources));
102
+
103
+ await createStaleOwnershipFixturesAsync();
104
+ const stalePlan = requireSuccess(await secondLifecycle.planAsync(request));
105
105
  expect(
106
106
  stalePlan.actions.some(
107
107
  ({ operation, owner }) =>
@@ -109,10 +109,8 @@ test.skipIf(process.env.ANKH_INFRA_MINIKUBE_SUPABASE_E2E !== '1')(
109
109
  ),
110
110
  ).toBe(true);
111
111
 
112
- const { ledger: prunedLedger } = requireSuccess(
113
- await upInfraEnvironmentAsync({ projectId, manifest, previous: ledger }, dependencies),
114
- );
115
- ledger = prunedLedger;
112
+ const pruned = requireSuccess(await secondLifecycle.upAsync(request));
113
+ ({ ledger } = pruned);
116
114
  expect(await configMapExistsAsync(staleOwnedConfigMap)).toBe(false);
117
115
  expect(await configMapExistsAsync(unrelatedConfigMap)).toBe(true);
118
116
  await runKubectlAsync([
@@ -124,35 +122,53 @@ test.skipIf(process.env.ANKH_INFRA_MINIKUBE_SUPABASE_E2E !== '1')(
124
122
  '--ignore-not-found=true',
125
123
  ]);
126
124
 
127
- const { ledger: downLedger } = requireSuccess(
128
- await downInfraEnvironmentAsync({ projectId, manifest, previous: ledger }, dependencies),
125
+ const down = requireSuccess(await secondLifecycle.downAsync(request));
126
+ ({ ledger } = down);
127
+ expect(credentialFingerprint(await resolveBootstrapCredentialsAsync(projectPath))).toBe(
128
+ firstFingerprint,
129
129
  );
130
- ledger = downLedger;
131
130
 
132
- const { ledger: resumedLedger, outputs: resumedOutputs } = requireSuccess(
133
- await upInfraEnvironmentAsync({ projectId, manifest, previous: ledger }, dependencies),
131
+ const resumedLifecycle = createProjectInfraLifecycle();
132
+ const resumed = requireSuccess(await resumedLifecycle.upAsync(request));
133
+ ({ ledger } = resumed);
134
+ expect(credentialFingerprint(await resolveBootstrapCredentialsAsync(projectPath))).toBe(
135
+ firstFingerprint,
134
136
  );
135
- ledger = resumedLedger;
136
- assertSafePublicOutputs(resumedOutputs);
137
+ assertSafePublicOutputs(resumed.outputs, firstCredentials);
138
+ assertSecretFree(resumed, firstCredentials);
139
+ await assertStoredStateSecretFreeAsync(projectPath, firstCredentials);
137
140
 
138
141
  const health = await fetch(`${baseUrl}/auth/v1/health`);
139
142
  expect(health.ok).toBe(true);
140
143
 
141
- const destroyed = requireSuccess(
142
- await destroyInfraEnvironmentAsync(
143
- createDestroyRequest(ledger, persistentIdentities(ledger)),
144
- dependencies,
145
- ),
144
+ const isolatedPort = createProjectInfraCredentialPort({
145
+ projectPath: isolatedProjectPath,
146
+ environment: 'local',
147
+ processEnvironment: {},
148
+ });
149
+ expect(await isolatedPort.persistAsync(bootstrapCredential, firstCredentials)).toMatchObject({
150
+ ok: true,
151
+ });
152
+ const isolatedFingerprint = credentialFingerprint(
153
+ requireSuccess(await isolatedPort.resolveAsync(bootstrapCredential)),
146
154
  );
147
- ledger = destroyed.ledger ?? undefined;
148
- expect(destroyed.ledger).toBeNull();
155
+
156
+ const destroyResult = requireSuccess(
157
+ await resumedLifecycle.destroyAsync(createDestroyRequest(projectPath, ledger)),
158
+ );
159
+ ledger = undefined;
160
+ destroyed = true;
161
+ expect(destroyResult.ledger).toBeNull();
162
+ expect(await findBootstrapCredentialsAsync(projectPath)).toBeNull();
163
+ expect(
164
+ credentialFingerprint(requireSuccess(await isolatedPort.resolveAsync(bootstrapCredential))),
165
+ ).toBe(isolatedFingerprint);
149
166
  } finally {
150
- if (ledger !== undefined) {
151
- await destroyInfraEnvironmentAsync(
152
- createDestroyRequest(ledger, persistentIdentities(ledger)),
153
- dependencies,
154
- );
167
+ if (!destroyed && ledger !== undefined) {
168
+ await createProjectInfraLifecycle().destroyAsync(createDestroyRequest(projectPath, ledger));
155
169
  }
170
+ await fs.rm(projectPath, { recursive: true, force: true });
171
+ await fs.rm(isolatedProjectPath, { recursive: true, force: true });
156
172
  }
157
173
  },
158
174
  900_000,
@@ -227,43 +243,24 @@ async function runKubectlAsync(args: readonly string[]): Promise<string> {
227
243
  return stdout.trim();
228
244
  }
229
245
 
230
- function createDependencies(): InfraOrchestrationDependencies {
246
+ function createOperationRequest(projectPath: string) {
231
247
  return {
232
- adapterResolver: createNodeInfraAdapterPackageResolver(),
233
- credentials: {
234
- resolveAsync: (reference) =>
235
- Promise.resolve(
236
- reference.name === 'SUPABASE_BOOTSTRAP'
237
- ? success(credentials)
238
- : failure(
239
- 'unexpected-control-plane-credential',
240
- `Unexpected control-plane credential ${reference.name}.`,
241
- ),
242
- ),
243
- },
244
- secrets: {
245
- resolveAsync: (reference) =>
246
- Promise.resolve(
247
- failure(
248
- 'unexpected-managed-secret',
249
- `Unexpected managed secret ${reference.ref}/${reference.key}.`,
250
- ),
251
- ),
252
- },
248
+ projectId,
249
+ projectPath,
250
+ manifest,
251
+ environment: 'local' as const,
252
+ executionEnvironment: {},
253
253
  };
254
254
  }
255
255
 
256
- function createDestroyRequest(
257
- previous: InfraLedger,
258
- confirmedResources: readonly InfraResourceIdentity[],
259
- ) {
256
+ function createDestroyRequest(projectPath: string, previous: InfraLedger) {
260
257
  return {
261
- projectId,
262
- manifest,
263
- environment: 'local' as const,
264
- previous,
258
+ ...createOperationRequest(projectPath),
265
259
  confirmation: { projectId, environment: 'local' as const },
266
- persistence: { policy: 'delete' as const, confirmedResources },
260
+ persistence: {
261
+ policy: 'delete' as const,
262
+ confirmedResources: persistentIdentities(previous),
263
+ },
267
264
  };
268
265
  }
269
266
 
@@ -271,48 +268,104 @@ function persistentIdentities(ledger: InfraLedger): readonly InfraResourceIdenti
271
268
  return ledger.resources.filter(({ persistent }) => persistent).map(({ identity }) => identity);
272
269
  }
273
270
 
274
- function assertSafePublicOutputs(outputs: InfraLedger['outputs']): void {
271
+ function resourceIdentities(
272
+ resources: readonly { readonly identity: InfraResourceIdentity }[],
273
+ ): readonly string[] {
274
+ return resources
275
+ .map(({ identity }) =>
276
+ [identity.projectId, identity.environment, identity.adapter, identity.resourceId].join(':'),
277
+ )
278
+ .sort();
279
+ }
280
+
281
+ async function resolveBootstrapCredentialsAsync(
282
+ projectPath: string,
283
+ ): Promise<Readonly<Record<string, string>>> {
284
+ const port = createProjectInfraCredentialPort({
285
+ projectPath,
286
+ environment: 'local',
287
+ processEnvironment: {},
288
+ });
289
+ return requireSuccess(await port.resolveAsync(bootstrapCredential));
290
+ }
291
+
292
+ async function findBootstrapCredentialsAsync(
293
+ projectPath: string,
294
+ ): Promise<Readonly<Record<string, string>> | null> {
295
+ const port = createProjectInfraCredentialPort({
296
+ projectPath,
297
+ environment: 'local',
298
+ processEnvironment: {},
299
+ });
300
+ return requireSuccess(await port.findAsync(bootstrapCredential));
301
+ }
302
+
303
+ function credentialFingerprint(values: Readonly<Record<string, string>>): string {
304
+ const canonical = Object.entries(values)
305
+ .sort(([left], [right]) => left.localeCompare(right))
306
+ .map(([key, value]) => `${key}\u0000${value}`)
307
+ .join('\u0001');
308
+ return createHash('sha256').update(canonical).digest('hex');
309
+ }
310
+
311
+ function privilegedCredentialValues(values: Readonly<Record<string, string>>): readonly string[] {
312
+ const { anonKey: _anonKey, ...privileged } = values;
313
+ return Object.values(privileged);
314
+ }
315
+
316
+ function assertSafePublicOutputs(
317
+ outputs: InfraLedger['outputs'],
318
+ credentials: Readonly<Record<string, string>>,
319
+ ): void {
275
320
  const serialized = JSON.stringify(outputs);
276
- expect(serialized).not.toContain(credentials.serviceRoleKey);
277
- expect(serialized).not.toContain(credentials.postgresPassword);
278
- expect(serialized).not.toContain(credentials.jwtSecret);
279
- expect(serialized).not.toContain(credentials.realtimeSecretKeyBase);
280
- expect(serialized).not.toContain(credentials.realtimeDatabaseEncryptionKey);
281
- expect(serialized).not.toContain(credentials.pgMetaCryptoKey);
321
+ for (const value of privilegedCredentialValues(credentials)) {
322
+ expect(serialized).not.toContain(value);
323
+ }
324
+ const { anonKey } = credentials;
325
+ if (anonKey === undefined) throw new Error('Expected generated Supabase anonKey.');
282
326
  expect(
283
327
  outputs.some(
284
328
  ({ environmentVariable, value }) =>
285
- environmentVariable === 'EXPO_PUBLIC_SUPABASE_ANON_KEY' && value === credentials.anonKey,
329
+ environmentVariable === 'EXPO_PUBLIC_SUPABASE_ANON_KEY' && value === anonKey,
286
330
  ),
287
331
  ).toBe(true);
288
332
  }
289
333
 
290
- function createJwt(role: 'anon' | 'service_role'): string {
291
- const encodedHeader = encodeJwtPart({ alg: 'HS256', typ: 'JWT' });
292
- const encodedPayload = encodeJwtPart({
293
- role,
294
- iss: 'supabase',
295
- iat: 1_700_000_000,
296
- exp: 4_102_444_800,
297
- });
298
- const unsigned = `${encodedHeader}.${encodedPayload}`;
299
- const signature = createHmac('sha256', jwtSecret).update(unsigned).digest('base64url');
300
- return `${unsigned}.${signature}`;
334
+ function assertSecretFree(value: unknown, credentials: Readonly<Record<string, string>>): void {
335
+ const serialized = JSON.stringify(value);
336
+ for (const secret of privilegedCredentialValues(credentials)) {
337
+ expect(serialized).not.toContain(secret);
338
+ }
301
339
  }
302
340
 
303
- function encodeJwtPart(value: Readonly<Record<string, string | number>>): string {
304
- return Buffer.from(JSON.stringify(value)).toString('base64url');
341
+ async function assertStoredStateSecretFreeAsync(
342
+ projectPath: string,
343
+ credentials: Readonly<Record<string, string>>,
344
+ ): Promise<void> {
345
+ const state = await fs.readFile(
346
+ path.join(projectPath, '.ankh', 'infra', 'local', 'state.json'),
347
+ 'utf8',
348
+ );
349
+ for (const secret of privilegedCredentialValues(credentials)) {
350
+ expect(state).not.toContain(secret);
351
+ }
352
+ }
353
+
354
+ async function assertCredentialFilePermissionsAsync(projectPath: string): Promise<void> {
355
+ if (process.platform === 'win32') return;
356
+ const digest = createHash('sha256').update(bootstrapCredential.name).digest('hex');
357
+ const credentialPath = path.join(
358
+ projectPath,
359
+ '.ankh',
360
+ 'infra',
361
+ 'local',
362
+ 'credentials',
363
+ `${digest}.json`,
364
+ );
365
+ expect((await fs.stat(credentialPath)).mode & 0o777).toBe(0o600);
305
366
  }
306
367
 
307
368
  function requireSuccess<T>(result: InfraResult<T>): T {
308
369
  if (!result.ok) throw new Error(result.diagnostics.map(({ message }) => message).join(' '));
309
370
  return result.value;
310
371
  }
311
-
312
- function success<T>(value: T): InfraResult<T> {
313
- return { ok: true, value, diagnostics: [] };
314
- }
315
-
316
- function failure(code: string, message: string): InfraResult<never> {
317
- return { ok: false, diagnostics: [{ severity: 'error', code, message }] };
318
- }
@@ -1,6 +1,8 @@
1
1
  import { createHmac } from 'node:crypto';
2
2
 
3
3
  import type {
4
+ InfraControlPlaneCredentialRef,
5
+ InfraCredentialPort,
4
6
  InfraLedger,
5
7
  InfraManifest,
6
8
  InfraResourceIdentity,
@@ -430,16 +432,7 @@ function redactDiagnosticText(value: string): string {
430
432
  function createDependencies(): InfraOrchestrationDependencies {
431
433
  return {
432
434
  adapterResolver: createNodeInfraAdapterPackageResolver(),
433
- credentials: {
434
- resolveAsync: ({ name }) =>
435
- Promise.resolve(
436
- name === 'SUPABASE_BOOTSTRAP'
437
- ? success(supabaseCredentials())
438
- : name === 'S3_PERSISTENCE'
439
- ? success({ accessKeyId: s3AccessKey, secretAccessKey: s3SecretKey })
440
- : failure('unexpected-credential', `Unexpected credential reference: ${name}`),
441
- ),
442
- },
435
+ credentials: createCredentialPort(),
443
436
  secrets: {
444
437
  resolveAsync: ({ ref, key }) =>
445
438
  Promise.resolve(failure('unexpected-secret', `Unexpected managed secret ${ref}/${key}.`)),
@@ -447,6 +440,40 @@ function createDependencies(): InfraOrchestrationDependencies {
447
440
  };
448
441
  }
449
442
 
443
+ function createCredentialPort(): InfraCredentialPort {
444
+ return {
445
+ findAsync: (reference) => Promise.resolve(findCredential(reference)),
446
+ resolveAsync: (reference) => Promise.resolve(resolveCredential(reference)),
447
+ persistAsync: (reference) =>
448
+ Promise.resolve(
449
+ failure(
450
+ 'unexpected-credential-persist',
451
+ `Recovery acceptance must not persist credential ${reference.name}.`,
452
+ ),
453
+ ),
454
+ };
455
+ }
456
+
457
+ function findCredential(
458
+ reference: InfraControlPlaneCredentialRef,
459
+ ): InfraResult<Readonly<Record<string, string>> | null> {
460
+ if (reference.name === 'SUPABASE_BOOTSTRAP') return success(supabaseCredentials());
461
+ if (reference.name === 'S3_PERSISTENCE') {
462
+ return success({ accessKeyId: s3AccessKey, secretAccessKey: s3SecretKey });
463
+ }
464
+ return success(null);
465
+ }
466
+
467
+ function resolveCredential(
468
+ reference: InfraControlPlaneCredentialRef,
469
+ ): InfraResult<Readonly<Record<string, string>>> {
470
+ if (reference.name === 'SUPABASE_BOOTSTRAP') return success(supabaseCredentials());
471
+ if (reference.name === 'S3_PERSISTENCE') {
472
+ return success({ accessKeyId: s3AccessKey, secretAccessKey: s3SecretKey });
473
+ }
474
+ return failure('unexpected-credential', `Unexpected credential reference: ${reference.name}`);
475
+ }
476
+
450
477
  function createDestroyRequest(
451
478
  previous: InfraLedger,
452
479
  confirmedResources: readonly InfraResourceIdentity[],
@@ -1,6 +1,7 @@
1
1
  import { describe, expect, test } from 'bun:test';
2
2
 
3
3
  import {
4
+ createProjectInfraCredentialPort,
4
5
  createProjectInfraLifecycle,
5
6
  readStoredInfraStateAsync,
6
7
  resolveInfraProjectAsync,
@@ -8,7 +9,8 @@ import {
8
9
  } from './index.js';
9
10
 
10
11
  describe('@ankhorage/infra/project', () => {
11
- test('exports project resolution, safe state and artifact owner APIs', () => {
12
+ test('exports project resolution, credentials, safe state and artifact owner APIs', () => {
13
+ expect(createProjectInfraCredentialPort).toBeFunction();
12
14
  expect(createProjectInfraLifecycle).toBeFunction();
13
15
  expect(resolveInfraProjectAsync).toBeFunction();
14
16
  expect(readStoredInfraStateAsync).toBeFunction();
@@ -1,3 +1,4 @@
1
+ export { createProjectInfraCredentialPort } from '../features/environment-lifecycle/adapters/outbound/createProjectInfraCredentialPort.js';
1
2
  export { readStoredInfraStateAsync } from '../features/environment-lifecycle/adapters/outbound/readStoredInfraStateAsync.js';
2
3
  export { removeStoredInfraStateAsync } from '../features/environment-lifecycle/adapters/outbound/removeStoredInfraStateAsync.js';
3
4
  export { resolveInfraProjectAsync } from '../features/environment-lifecycle/adapters/outbound/resolveInfraProjectAsync.js';