@ankhorage/infra 0.3.7 → 0.4.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.
Files changed (37) hide show
  1. package/CHANGELOG.md +12 -0
  2. package/README.md +2 -1
  3. package/dist/adapters/minikube/base/index.d.ts +1 -0
  4. package/dist/adapters/minikube/base/index.d.ts.map +1 -1
  5. package/dist/adapters/minikube/base/index.js +239 -22
  6. package/dist/adapters/minikube/base/index.js.map +1 -1
  7. package/dist/adapters/minikube/index.d.ts.map +1 -1
  8. package/dist/adapters/minikube/index.js +34 -0
  9. package/dist/adapters/minikube/index.js.map +1 -1
  10. package/dist/adapters/minikube/secrets/index.d.ts +7 -0
  11. package/dist/adapters/minikube/secrets/index.d.ts.map +1 -0
  12. package/dist/adapters/minikube/secrets/index.js +13 -0
  13. package/dist/adapters/minikube/secrets/index.js.map +1 -0
  14. package/dist/adapters/minikube/secrets/supabase-vault/index.d.ts +8 -0
  15. package/dist/adapters/minikube/secrets/supabase-vault/index.d.ts.map +1 -0
  16. package/dist/adapters/minikube/secrets/supabase-vault/index.js +74 -0
  17. package/dist/adapters/minikube/secrets/supabase-vault/index.js.map +1 -0
  18. package/dist/index.d.ts +1 -0
  19. package/dist/index.d.ts.map +1 -1
  20. package/dist/index.js +1 -0
  21. package/dist/index.js.map +1 -1
  22. package/dist/secretStore.d.ts +19 -0
  23. package/dist/secretStore.d.ts.map +1 -0
  24. package/dist/secretStore.js +22 -0
  25. package/dist/secretStore.js.map +1 -0
  26. package/package.json +7 -3
  27. package/src/adapters/minikube/base/index.test.ts +456 -0
  28. package/src/adapters/minikube/base/index.ts +244 -20
  29. package/src/adapters/minikube/index.ts +44 -0
  30. package/src/adapters/minikube/secrets/index.test.ts +139 -0
  31. package/src/adapters/minikube/secrets/index.ts +19 -0
  32. package/src/adapters/minikube/secrets/supabase-vault/index.ts +91 -0
  33. package/src/index.test.ts +151 -12
  34. package/src/index.ts +5 -0
  35. package/src/package.test.ts +8 -1
  36. package/src/secretStore.test.ts +76 -0
  37. package/src/secretStore.ts +44 -0
@@ -0,0 +1,139 @@
1
+ import type { AppManifest } from '@ankhorage/contracts';
2
+ import { SUPABASE_VAULT_MIGRATION_SQL } from '@ankhorage/supabase-vault';
3
+ import { describe, expect, test } from 'bun:test';
4
+
5
+ import { generateInfrastructure } from '../../../index';
6
+ import type { InfraManifestInput } from '../../../types';
7
+ import { SUPABASE_VAULT_MIGRATION_PATH } from './supabase-vault';
8
+
9
+ function createManifest(overrides: Partial<InfraManifestInput> = {}): InfraManifestInput {
10
+ return {
11
+ deployment: {
12
+ target: 'minikube',
13
+ monitoring: false,
14
+ },
15
+ secretStore: {
16
+ provider: 'supabase-vault',
17
+ },
18
+ plugins: [],
19
+ ...overrides,
20
+ };
21
+ }
22
+
23
+ describe('minikube secret-store generation', () => {
24
+ test('generates the released Supabase Vault migration through the existing lifecycle', () => {
25
+ const result = generateInfrastructure(createManifest(), {
26
+ appManifest: createAppManifest('vault-app'),
27
+ });
28
+ const migration = result.files.find((file) => file.path === SUPABASE_VAULT_MIGRATION_PATH);
29
+ const envExample = result.files.find((file) => file.path === 'infra/minikube/.env.example');
30
+ const upScript = result.files.find((file) => file.path === 'infra/minikube/scripts/up.sh');
31
+
32
+ expect(migration?.content).toBe(`${SUPABASE_VAULT_MIGRATION_SQL.trim()}\n`);
33
+ expect(envExample?.content).toContain('SECRET_STORE_PROVIDER=supabase-vault');
34
+ expect(envExample?.content).toContain('SUPABASE_LOCAL_ENABLED=true');
35
+ expect(upScript?.content).toContain('SUPABASE_LOCAL_ENABLED="${SUPABASE_LOCAL_ENABLED:-true}"');
36
+ expect(result.meta.providers).toContain('supabase-vault');
37
+ });
38
+
39
+ test('requires credentialsRef for every enabled OAuth provider', () => {
40
+ expect(() =>
41
+ generateInfrastructure(
42
+ createManifest({
43
+ auth: {
44
+ scope: 'global',
45
+ provider: 'supabase',
46
+ signIn: {
47
+ identifiers: ['email'],
48
+ },
49
+ oauth: {
50
+ enabled: true,
51
+ callbackRoute: '/auth/callback',
52
+ providers: [
53
+ {
54
+ id: 'google',
55
+ enabled: true,
56
+ },
57
+ ],
58
+ },
59
+ },
60
+ }),
61
+ { appManifest: createAppManifest('vault-app') },
62
+ ),
63
+ ).toThrow('does not define credentialsRef');
64
+ });
65
+
66
+ test('accepts provider-neutral OAuth secret references without serializing values', () => {
67
+ const result = generateInfrastructure(
68
+ createManifest({
69
+ auth: {
70
+ scope: 'global',
71
+ provider: 'supabase',
72
+ signIn: {
73
+ identifiers: ['email'],
74
+ },
75
+ oauth: {
76
+ enabled: true,
77
+ callbackRoute: '/auth/callback',
78
+ providers: [
79
+ {
80
+ id: 'google',
81
+ enabled: true,
82
+ credentialsRef: 'auth/oauth/google',
83
+ },
84
+ ],
85
+ },
86
+ },
87
+ }),
88
+ { appManifest: createAppManifest('vault-app') },
89
+ );
90
+
91
+ const serialized = JSON.stringify(result.files);
92
+ expect(serialized).toContain('auth/oauth/google');
93
+ expect(serialized).not.toContain('clientSecret');
94
+ expect(serialized).not.toContain('privateKey');
95
+ });
96
+
97
+ test('rejects unknown secret-store providers', () => {
98
+ expect(() =>
99
+ generateInfrastructure(
100
+ createManifest({
101
+ secretStore: {
102
+ provider: 'aws-secrets-manager',
103
+ },
104
+ }),
105
+ ),
106
+ ).toThrow('Unsupported secret-store provider for minikube adapter');
107
+ });
108
+ });
109
+
110
+ function createAppManifest(
111
+ slug: string,
112
+ ): Pick<AppManifest, 'metadata' | 'navigator' | 'screens' | 'settings'> {
113
+ return {
114
+ metadata: {
115
+ name: slug,
116
+ slug,
117
+ version: '1.0.0',
118
+ themeId: 'default',
119
+ },
120
+ navigator: {
121
+ type: 'stack',
122
+ routes: [],
123
+ },
124
+ screens: {},
125
+ settings: {
126
+ localization: {
127
+ defaultLocale: 'en',
128
+ locales: ['en'],
129
+ },
130
+ authFlow: {
131
+ signInRoute: '/sign-in',
132
+ signUpRoute: '/sign-up',
133
+ signOutRoute: '/sign-out',
134
+ unauthorizedRoute: '/sign-in',
135
+ postSignInRoute: '/',
136
+ },
137
+ },
138
+ };
139
+ }
@@ -0,0 +1,19 @@
1
+ import { SUPABASE_VAULT_SECRET_STORE_PROVIDER } from '@ankhorage/supabase-vault';
2
+
3
+ import type { InfraManifestInput } from '../../../types';
4
+ import { emptyMinikubeArtifacts, type MinikubeAdapterArtifacts } from '../contracts';
5
+ import { generateSupabaseVaultSecretStoreArtifacts } from './supabase-vault';
6
+
7
+ export function generateSecretStoreArtifacts(args: {
8
+ readonly manifest: InfraManifestInput;
9
+ readonly namespace: string;
10
+ }): MinikubeAdapterArtifacts {
11
+ const provider = args.manifest.secretStore?.provider;
12
+ if (!provider) return emptyMinikubeArtifacts();
13
+
14
+ if (provider === SUPABASE_VAULT_SECRET_STORE_PROVIDER) {
15
+ return generateSupabaseVaultSecretStoreArtifacts(args);
16
+ }
17
+
18
+ throw new Error(`Unsupported secret-store provider for minikube adapter: "${provider}".`);
19
+ }
@@ -0,0 +1,91 @@
1
+ import {
2
+ SUPABASE_VAULT_MIGRATION_SQL,
3
+ SUPABASE_VAULT_SECRET_STORE_PROVIDER,
4
+ } from '@ankhorage/supabase-vault';
5
+
6
+ import type { InfraManifestInput } from '../../../../types';
7
+ import type { MinikubeAdapterArtifacts } from '../../contracts';
8
+
9
+ export const SUPABASE_VAULT_MIGRATION_PATH =
10
+ 'infra/minikube/supabase/migrations/202607120001_ankhorage_supabase_vault.sql';
11
+
12
+ export function generateSupabaseVaultSecretStoreArtifacts(args: {
13
+ readonly manifest: InfraManifestInput;
14
+ readonly namespace: string;
15
+ }): MinikubeAdapterArtifacts {
16
+ validateOAuthSecretReferences(args.manifest);
17
+
18
+ return {
19
+ files: [
20
+ {
21
+ path: SUPABASE_VAULT_MIGRATION_PATH,
22
+ content: `${SUPABASE_VAULT_MIGRATION_SQL.trim()}\n`,
23
+ },
24
+ {
25
+ path: 'infra/minikube/secrets/supabase-vault.md',
26
+ content: getRuntimeGuide(args.namespace),
27
+ },
28
+ ],
29
+ resources: [],
30
+ envEntries: [
31
+ `SECRET_STORE_PROVIDER=${SUPABASE_VAULT_SECRET_STORE_PROVIDER}`,
32
+ 'SUPABASE_LOCAL_ENABLED=true',
33
+ ],
34
+ warnings: [],
35
+ };
36
+ }
37
+
38
+ function validateOAuthSecretReferences(manifest: InfraManifestInput): void {
39
+ const oauth = manifest.auth?.oauth;
40
+ if (!oauth?.enabled) return;
41
+
42
+ const enabledProviders = oauth.providers.filter((provider) => provider.enabled !== false);
43
+ if (enabledProviders.length === 0) {
44
+ throw new Error('OAuth is enabled but no OAuth provider is enabled.');
45
+ }
46
+
47
+ for (const provider of enabledProviders) {
48
+ if (!provider.credentialsRef?.trim()) {
49
+ throw new Error(
50
+ `OAuth provider "${provider.id}" is enabled but does not define credentialsRef.`,
51
+ );
52
+ }
53
+ }
54
+ }
55
+
56
+ function getRuntimeGuide(namespace: string): string {
57
+ return `# Supabase Vault secret store
58
+
59
+ This project selects the provider-neutral secret-store backend through:
60
+
61
+ \`\`\`text
62
+ infra.secretStore.provider = ${SUPABASE_VAULT_SECRET_STORE_PROVIDER}
63
+ \`\`\`
64
+
65
+ The immutable migration at:
66
+
67
+ \`\`\`text
68
+ ${SUPABASE_VAULT_MIGRATION_PATH}
69
+ \`\`\`
70
+
71
+ is applied by the existing local Supabase migration lifecycle during \`scripts/up.sh\`.
72
+
73
+ ## Security boundary
74
+
75
+ - Secret values are stored only through Supabase Vault.
76
+ - Public manifests contain logical references such as \`auth/oauth/google\`, never values.
77
+ - Project and environment scope are supplied by trusted server code.
78
+ - The browser may receive secret metadata, but must never receive resolved values or Vault IDs.
79
+ - Bootstrap database access must come from trusted process environment or workload identity.
80
+ - Do not expose service-role keys, provider secrets, or resolved payloads through
81
+ \`NEXT_PUBLIC_*\`, \`EXPO_PUBLIC_*\`, generated YAML, tracked JSON, or app bundles.
82
+
83
+ ## Trusted adapter composition
84
+
85
+ Use \`createInfraSecretStoreAdapter\` from \`@ankhorage/infra\` in server-only code and pass
86
+ a trusted Supabase Vault SQL client. The app manifest chooses the provider; privileged connection
87
+ configuration does not belong in the manifest.
88
+
89
+ Generated Kubernetes namespace: \`${namespace}\`.
90
+ `;
91
+ }
package/src/index.test.ts CHANGED
@@ -45,7 +45,7 @@ describe('generateInfrastructure', () => {
45
45
  plugins: [],
46
46
  };
47
47
 
48
- const result = generateInfrastructure(manifest);
48
+ const result = generateInfrastructure(manifest, { appManifest: createAppManifest('alpha') });
49
49
  const paths = result.files.map((f) => f.path);
50
50
 
51
51
  expect(result.meta.target).toBe('minikube');
@@ -147,8 +147,8 @@ describe('generateInfrastructure', () => {
147
147
  expect(envExample?.content).toContain('APP_SOURCE_DIR=');
148
148
  expect(envExample?.content).toContain('APP_WEB_EXPORT_DIR=.ankh/web-export');
149
149
  expect(envExample?.content).toContain('SUPABASE_PROJECT_DIR=');
150
- expect(envExample?.content).toContain('SUPABASE_LOCAL_PORT_BASE=59120');
151
- expect(envExample?.content).toContain('SUPABASE_LOCAL_ANALYTICS_PORT=59125');
150
+ expect(envExample?.content).toContain('SUPABASE_LOCAL_PORT_BASE=55540');
151
+ expect(envExample?.content).toContain('SUPABASE_LOCAL_ANALYTICS_PORT=55545');
152
152
  expect(envExample?.content).toContain('APP_IMAGE=ankh/alpha-local:dev');
153
153
  expect(envExample?.content).toContain('APP_IMAGE_SYNC_STRATEGY=docker-load');
154
154
  expect(envExample?.content).toContain('AUTH_RUNTIME_MODE=local');
@@ -168,7 +168,8 @@ describe('generateInfrastructure', () => {
168
168
 
169
169
  const minikubeReadme = result.files.find((f) => f.path === 'infra/minikube/README.md');
170
170
  expect(minikubeReadme?.content).toContain('SUPABASE_LOCAL_ANALYTICS_PORT');
171
- expect(minikubeReadme?.content).toContain('59125');
171
+ expect(minikubeReadme?.content).toContain('55545');
172
+ expect(minikubeReadme?.content).toContain('Supabase local project identity: `alpha`');
172
173
  expect(minikubeReadme?.content).toContain('$APP_SOURCE_DIR/.env.local');
173
174
  expect(minikubeReadme?.content).toContain('apps/card/.env.local');
174
175
 
@@ -333,7 +334,10 @@ describe('generateInfrastructure', () => {
333
334
  plugins: [],
334
335
  };
335
336
 
336
- const result = generateInfrastructure(manifest, { namespaceHint: 'shop' });
337
+ const result = generateInfrastructure(manifest, {
338
+ namespaceHint: 'shop',
339
+ appManifest: createAppManifest('shop'),
340
+ });
337
341
 
338
342
  expect(result.warnings).toContain(
339
343
  'Storage buckets are configured but not created automatically yet. Ensure buckets exist in Supabase Storage: assets,uploads.',
@@ -380,7 +384,10 @@ describe('generateInfrastructure', () => {
380
384
  plugins: [],
381
385
  };
382
386
 
383
- const result = generateInfrastructure(manifest, { namespaceHint: 'shop' });
387
+ const result = generateInfrastructure(manifest, {
388
+ namespaceHint: 'shop',
389
+ appManifest: createAppManifest('shop'),
390
+ });
384
391
 
385
392
  expect(result.files.map((f) => f.path)).toContain(
386
393
  'infra/minikube/k8s/storage/supabase/app-runtime-storage.env.configmap.yaml',
@@ -425,7 +432,10 @@ describe('generateInfrastructure', () => {
425
432
  plugins: [],
426
433
  };
427
434
 
428
- const result = generateInfrastructure(manifest, { namespaceHint: 'shop' });
435
+ const result = generateInfrastructure(manifest, {
436
+ namespaceHint: 'shop',
437
+ appManifest: createAppManifest('shop'),
438
+ });
429
439
  expect(result.warnings).toContain(
430
440
  'Storage buckets are empty after normalization; no storage artifacts generated.',
431
441
  );
@@ -451,7 +461,10 @@ describe('generateInfrastructure', () => {
451
461
  plugins: [],
452
462
  };
453
463
 
454
- const result = generateInfrastructure(manifest, { namespaceHint: 'shop' });
464
+ const result = generateInfrastructure(manifest, {
465
+ namespaceHint: 'shop',
466
+ appManifest: createAppManifest('shop'),
467
+ });
455
468
  expect(result.warnings).toContain(
456
469
  'Storage buckets are empty after normalization; no storage artifacts generated.',
457
470
  );
@@ -473,7 +486,10 @@ describe('generateInfrastructure', () => {
473
486
  plugins: [],
474
487
  };
475
488
 
476
- const result = generateInfrastructure(manifest, { namespaceHint: 'shop' });
489
+ const result = generateInfrastructure(manifest, {
490
+ namespaceHint: 'shop',
491
+ appManifest: createAppManifest('shop'),
492
+ });
477
493
  expect(result.warnings).toContain(
478
494
  'Storage provider "auto" could not be resolved for minikube; no storage artifacts generated.',
479
495
  );
@@ -645,9 +661,98 @@ describe('generateInfrastructure', () => {
645
661
  plugins: [],
646
662
  };
647
663
 
648
- expect(() => generateInfrastructure(manifest)).toThrow(
649
- 'Unsupported authorization engine for minikube adapter: opa',
664
+ expect(() =>
665
+ generateInfrastructure(manifest, { appManifest: createAppManifest('shop') }),
666
+ ).toThrow('Unsupported authorization engine for minikube adapter: opa');
667
+ });
668
+
669
+ test('requires canonical app slug for local Supabase infrastructure', () => {
670
+ const manifest: InfraManifestInput = {
671
+ deployment: {
672
+ target: 'minikube',
673
+ monitoring: false,
674
+ },
675
+ database: {
676
+ provider: 'supabase',
677
+ tier: 'dev',
678
+ },
679
+ plugins: [],
680
+ };
681
+
682
+ expect(() => generateInfrastructure(manifest, { namespaceHint: 'shop' })).toThrow(
683
+ 'Cannot generate local Supabase infrastructure: appManifest.metadata.slug is required.',
684
+ );
685
+ expect(() =>
686
+ generateInfrastructure(manifest, {
687
+ namespaceHint: 'shop',
688
+ appManifest: createAppManifest('Scanner App'),
689
+ }),
690
+ ).toThrow('appManifest.metadata.slug must be a canonical lowercase slug up to 40 characters');
691
+
692
+ const fortyCharacterSlug = 'a'.repeat(40);
693
+ const fortyCharacterResult = generateInfrastructure(manifest, {
694
+ namespaceHint: 'shop',
695
+ appManifest: createAppManifest(fortyCharacterSlug),
696
+ });
697
+ const fortyCharacterScript = fortyCharacterResult.files.find(
698
+ (f) => f.path === 'infra/minikube/scripts/supabase-local-env.sh',
650
699
  );
700
+ expect(fortyCharacterScript?.content).toContain(
701
+ `EXPECTED_SUPABASE_PROJECT_ID="${fortyCharacterSlug}"`,
702
+ );
703
+
704
+ expect(() =>
705
+ generateInfrastructure(manifest, {
706
+ namespaceHint: 'shop',
707
+ appManifest: createAppManifest('a'.repeat(41)),
708
+ }),
709
+ ).toThrow('appManifest.metadata.slug must be a canonical lowercase slug up to 40 characters');
710
+
711
+ expect(() =>
712
+ generateInfrastructure(manifest, {
713
+ namespaceHint: 'shop',
714
+ appManifest: createAppManifest('very-long-project-name-with-identical-prefix-alpha'),
715
+ }),
716
+ ).toThrow('appManifest.metadata.slug must be a canonical lowercase slug up to 40 characters');
717
+ expect(() =>
718
+ generateInfrastructure(manifest, {
719
+ namespaceHint: 'shop',
720
+ appManifest: createAppManifest('very-long-project-name-with-identical-prefix-beta'),
721
+ }),
722
+ ).toThrow('appManifest.metadata.slug must be a canonical lowercase slug up to 40 characters');
723
+ });
724
+
725
+ test('keeps Supabase project identity independent from domain-derived namespace', () => {
726
+ const manifest: InfraManifestInput = {
727
+ deployment: {
728
+ target: 'minikube',
729
+ monitoring: false,
730
+ },
731
+ database: {
732
+ provider: 'supabase',
733
+ tier: 'dev',
734
+ },
735
+ networking: {
736
+ domain: 'local.example.test',
737
+ cdn: false,
738
+ },
739
+ plugins: [],
740
+ };
741
+
742
+ const result = generateInfrastructure(manifest, {
743
+ namespaceHint: 'ignored',
744
+ appManifest: createAppManifest('scanner'),
745
+ });
746
+ const namespaceFile = result.files.find((f) => f.path === 'infra/minikube/k8s/namespace.yaml');
747
+ const envExample = result.files.find((f) => f.path === 'infra/minikube/.env.example');
748
+ const script = result.files.find(
749
+ (f) => f.path === 'infra/minikube/scripts/supabase-local-env.sh',
750
+ );
751
+
752
+ expect(namespaceFile?.content).toContain('name: local-example-test');
753
+ expect(envExample?.content).toContain('SUPABASE_LOCAL_PORT_BASE=64020');
754
+ expect(envExample?.content).not.toContain('SUPABASE_LOCAL_PORT_BASE=61550');
755
+ expect(script?.content).toContain('EXPECTED_SUPABASE_PROJECT_ID="scanner"');
651
756
  });
652
757
 
653
758
  test('uses namespace hint when domain is not provided', () => {
@@ -671,7 +776,10 @@ describe('generateInfrastructure', () => {
671
776
  plugins: [],
672
777
  };
673
778
 
674
- const result = generateInfrastructure(manifest, { namespaceHint: 'shop' });
779
+ const result = generateInfrastructure(manifest, {
780
+ namespaceHint: 'shop',
781
+ appManifest: createAppManifest('shop'),
782
+ });
675
783
  const namespaceFile = result.files.find((f) => f.path === 'infra/minikube/k8s/namespace.yaml');
676
784
  const upScript = result.files.find((f) => f.path === 'infra/minikube/scripts/up.sh');
677
785
  const statusScript = result.files.find((f) => f.path === 'infra/minikube/scripts/status.sh');
@@ -688,3 +796,34 @@ describe('generateInfrastructure', () => {
688
796
  expect(statusScript?.content).toContain('- secret/supabase-auth-secrets: present');
689
797
  });
690
798
  });
799
+
800
+ function createAppManifest(
801
+ slug: string,
802
+ ): Pick<AppManifest, 'metadata' | 'navigator' | 'screens' | 'settings'> {
803
+ return {
804
+ metadata: {
805
+ name: slug,
806
+ slug,
807
+ version: '1.0.0',
808
+ themeId: 'default',
809
+ },
810
+ navigator: {
811
+ type: 'stack',
812
+ routes: [],
813
+ },
814
+ screens: {},
815
+ settings: {
816
+ localization: {
817
+ defaultLocale: 'en',
818
+ locales: ['en'],
819
+ },
820
+ authFlow: {
821
+ signInRoute: '/sign-in',
822
+ signUpRoute: '/sign-up',
823
+ signOutRoute: '/sign-out',
824
+ unauthorizedRoute: '/sign-in',
825
+ postSignInRoute: '/',
826
+ },
827
+ },
828
+ };
829
+ }
package/src/index.ts CHANGED
@@ -8,6 +8,11 @@ import type {
8
8
 
9
9
  export type { ApiInfrastructureArtifacts } from './apiArtifacts';
10
10
  export { generateApiInfrastructureArtifacts } from './apiArtifacts';
11
+ export {
12
+ createInfraSecretStoreAdapter,
13
+ type CreateInfraSecretStoreAdapterInput,
14
+ type InfraSecretStoreProviders,
15
+ } from './secretStore';
11
16
  export type {
12
17
  GeneratedFile,
13
18
  GeneratedInfrastructureFile,
@@ -8,7 +8,14 @@ describe('package metadata', () => {
8
8
  const expectedAnkhMetadata = {
9
9
  category: 'infra',
10
10
  provider: './dist/cli/index.js',
11
- capabilities: ['infra.validate', 'infra.generate', 'infra.status', 'infra.up', 'infra.down'],
11
+ capabilities: [
12
+ 'infra.validate',
13
+ 'infra.generate',
14
+ 'infra.status',
15
+ 'infra.up',
16
+ 'infra.down',
17
+ 'infra.secret-store',
18
+ ],
12
19
  } as const satisfies AnkhPackageMetadata;
13
20
 
14
21
  expect(packageJson.name).toBe('@ankhorage/infra');
@@ -0,0 +1,76 @@
1
+ import type {
2
+ SupabaseVaultQueryResult,
3
+ SupabaseVaultSqlClient,
4
+ SupabaseVaultSqlExecutor,
5
+ } from '@ankhorage/supabase-vault';
6
+ import { describe, expect, test } from 'bun:test';
7
+
8
+ import { createInfraSecretStoreAdapter } from './secretStore';
9
+
10
+ class FakeSupabaseVaultClient implements SupabaseVaultSqlClient {
11
+ query<TRow extends Record<string, unknown>>(): Promise<SupabaseVaultQueryResult<TRow>> {
12
+ return Promise.resolve({ rows: [] });
13
+ }
14
+
15
+ transaction<TResult>(
16
+ operation: (executor: SupabaseVaultSqlExecutor) => Promise<TResult>,
17
+ ): Promise<TResult> {
18
+ return operation(this);
19
+ }
20
+ }
21
+
22
+ describe('createInfraSecretStoreAdapter', () => {
23
+ test('returns null when the manifest does not select a secret store', () => {
24
+ expect(
25
+ createInfraSecretStoreAdapter({
26
+ manifest: {},
27
+ providers: {},
28
+ }),
29
+ ).toBeNull();
30
+ });
31
+
32
+ test('creates the released Supabase Vault adapter from trusted options', () => {
33
+ const adapter = createInfraSecretStoreAdapter({
34
+ manifest: {
35
+ secretStore: {
36
+ provider: 'supabase-vault',
37
+ },
38
+ },
39
+ providers: {
40
+ supabaseVault: {
41
+ client: new FakeSupabaseVaultClient(),
42
+ },
43
+ },
44
+ });
45
+
46
+ expect(adapter).not.toBeNull();
47
+ expect(typeof adapter?.list).toBe('function');
48
+ expect(typeof adapter?.resolve).toBe('function');
49
+ });
50
+
51
+ test('requires trusted provider bootstrap options', () => {
52
+ expect(() =>
53
+ createInfraSecretStoreAdapter({
54
+ manifest: {
55
+ secretStore: {
56
+ provider: 'supabase-vault',
57
+ },
58
+ },
59
+ providers: {},
60
+ }),
61
+ ).toThrow('requires trusted Supabase Vault adapter options');
62
+ });
63
+
64
+ test('rejects unknown providers instead of silently falling back', () => {
65
+ expect(() =>
66
+ createInfraSecretStoreAdapter({
67
+ manifest: {
68
+ secretStore: {
69
+ provider: 'aws-secrets-manager',
70
+ },
71
+ },
72
+ providers: {},
73
+ }),
74
+ ).toThrow('Unsupported secret-store provider');
75
+ });
76
+ });
@@ -0,0 +1,44 @@
1
+ import type { SecretStoreAdapter } from '@ankhorage/contracts/secrets';
2
+ import {
3
+ createSupabaseVaultAdapter,
4
+ SUPABASE_VAULT_SECRET_STORE_PROVIDER,
5
+ type SupabaseVaultAdapterOptions,
6
+ } from '@ankhorage/supabase-vault';
7
+
8
+ import type { InfraManifestInput } from './types';
9
+
10
+ export interface InfraSecretStoreProviders {
11
+ readonly supabaseVault?: SupabaseVaultAdapterOptions;
12
+ }
13
+
14
+ export interface CreateInfraSecretStoreAdapterInput {
15
+ readonly manifest: Pick<InfraManifestInput, 'secretStore'>;
16
+ readonly providers: InfraSecretStoreProviders;
17
+ }
18
+
19
+ /**
20
+ * Creates the configured server-only secret-store adapter.
21
+ *
22
+ * Bootstrap credentials and trusted clients belong in `providers`; they must never be read from
23
+ * the public app manifest. A missing `infra.secretStore` returns `null` because apps without
24
+ * secret-backed features do not require a secret store.
25
+ */
26
+ export function createInfraSecretStoreAdapter(
27
+ input: CreateInfraSecretStoreAdapterInput,
28
+ ): SecretStoreAdapter | null {
29
+ const provider = input.manifest.secretStore?.provider;
30
+ if (!provider) return null;
31
+
32
+ if (provider === SUPABASE_VAULT_SECRET_STORE_PROVIDER) {
33
+ const options = input.providers.supabaseVault;
34
+ if (!options) {
35
+ throw new Error(
36
+ 'Secret-store provider "supabase-vault" requires trusted Supabase Vault adapter options.',
37
+ );
38
+ }
39
+
40
+ return createSupabaseVaultAdapter(options);
41
+ }
42
+
43
+ throw new Error(`Unsupported secret-store provider: "${provider}".`);
44
+ }