@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.
- package/CHANGELOG.md +12 -0
- package/README.md +2 -1
- package/dist/adapters/minikube/base/index.d.ts +1 -0
- package/dist/adapters/minikube/base/index.d.ts.map +1 -1
- package/dist/adapters/minikube/base/index.js +239 -22
- package/dist/adapters/minikube/base/index.js.map +1 -1
- package/dist/adapters/minikube/index.d.ts.map +1 -1
- package/dist/adapters/minikube/index.js +34 -0
- package/dist/adapters/minikube/index.js.map +1 -1
- package/dist/adapters/minikube/secrets/index.d.ts +7 -0
- package/dist/adapters/minikube/secrets/index.d.ts.map +1 -0
- package/dist/adapters/minikube/secrets/index.js +13 -0
- package/dist/adapters/minikube/secrets/index.js.map +1 -0
- package/dist/adapters/minikube/secrets/supabase-vault/index.d.ts +8 -0
- package/dist/adapters/minikube/secrets/supabase-vault/index.d.ts.map +1 -0
- package/dist/adapters/minikube/secrets/supabase-vault/index.js +74 -0
- package/dist/adapters/minikube/secrets/supabase-vault/index.js.map +1 -0
- package/dist/index.d.ts +1 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +1 -0
- package/dist/index.js.map +1 -1
- package/dist/secretStore.d.ts +19 -0
- package/dist/secretStore.d.ts.map +1 -0
- package/dist/secretStore.js +22 -0
- package/dist/secretStore.js.map +1 -0
- package/package.json +7 -3
- package/src/adapters/minikube/base/index.test.ts +456 -0
- package/src/adapters/minikube/base/index.ts +244 -20
- package/src/adapters/minikube/index.ts +44 -0
- package/src/adapters/minikube/secrets/index.test.ts +139 -0
- package/src/adapters/minikube/secrets/index.ts +19 -0
- package/src/adapters/minikube/secrets/supabase-vault/index.ts +91 -0
- package/src/index.test.ts +151 -12
- package/src/index.ts +5 -0
- package/src/package.test.ts +8 -1
- package/src/secretStore.test.ts +76 -0
- 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=
|
|
151
|
-
expect(envExample?.content).toContain('SUPABASE_LOCAL_ANALYTICS_PORT=
|
|
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('
|
|
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, {
|
|
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, {
|
|
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, {
|
|
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, {
|
|
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, {
|
|
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(() =>
|
|
649
|
-
|
|
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, {
|
|
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,
|
package/src/package.test.ts
CHANGED
|
@@ -8,7 +8,14 @@ describe('package metadata', () => {
|
|
|
8
8
|
const expectedAnkhMetadata = {
|
|
9
9
|
category: 'infra',
|
|
10
10
|
provider: './dist/cli/index.js',
|
|
11
|
-
capabilities: [
|
|
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
|
+
}
|