@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
@@ -30,28 +30,33 @@ const SUPABASE_LOCAL_PORT_REFERENCE_PROJECT = 'my-app';
30
30
  export function generateMinikubeBaseArtifacts(args: {
31
31
  manifest: InfraManifestInput;
32
32
  namespace: string;
33
+ supabaseProjectId: string | null;
33
34
  extraResources: string[];
34
35
  extraEnvEntries: string[];
35
36
  }): GeneratedInfrastructureFile[] {
36
- const { manifest, namespace, extraResources, extraEnvEntries } = args;
37
+ const { manifest, namespace, supabaseProjectId, extraResources, extraEnvEntries } = args;
37
38
 
38
39
  const root = 'infra/minikube';
39
40
  const k8sRoot = `${root}/k8s`;
40
41
  const scriptsRoot = `${root}/scripts`;
41
42
  const appImageRoot = `${root}/app-image`;
42
43
  const defaultAppImage = getDefaultAppImage(namespace);
43
- const supabaseLocalPorts = resolveSupabaseLocalPorts(namespace);
44
+ const supabaseLocalPorts = resolveSupabaseLocalPorts(supabaseProjectId ?? namespace);
44
45
 
45
46
  const authScope = manifest.auth?.scope ?? 'none';
46
47
  const authProvider = manifest.auth?.provider ?? 'none';
47
48
  const authzEngine = manifest.auth?.authorization?.engine ?? 'none';
48
49
  const databaseProvider = manifest.database?.provider ?? 'none';
50
+ const secretStoreProvider = manifest.secretStore?.provider ?? 'none';
49
51
  const storageMetadata = resolveAppInfraStorageMetadata(manifest);
50
52
  const profileModel = resolveSupabaseProfileModel(manifest);
51
53
  const monitoringEnabled = manifest.deployment?.monitoring === true;
52
54
  const domain = manifest.networking?.domain ?? '';
53
55
  const supabaseLocalEnabled =
54
- authProvider === 'supabase' || databaseProvider === 'supabase' || storageMetadata !== null;
56
+ authProvider === 'supabase' ||
57
+ databaseProvider === 'supabase' ||
58
+ storageMetadata !== null ||
59
+ secretStoreProvider === 'supabase-vault';
55
60
 
56
61
  return [
57
62
  {
@@ -64,6 +69,7 @@ export function generateMinikubeBaseArtifacts(args: {
64
69
  authzEngine,
65
70
  extraResources,
66
71
  supabaseLocalPorts,
72
+ supabaseProjectId,
67
73
  supabaseLocalEnabled,
68
74
  profileModel,
69
75
  }),
@@ -90,6 +96,7 @@ export function generateMinikubeBaseArtifacts(args: {
90
96
  authScope,
91
97
  authProvider,
92
98
  databaseProvider,
99
+ secretStoreProvider,
93
100
  storageMetadata,
94
101
  monitoringEnabled,
95
102
  domain,
@@ -135,6 +142,7 @@ export function generateMinikubeBaseArtifacts(args: {
135
142
  path: `${scriptsRoot}/status.sh`,
136
143
  content: getStatusScript({
137
144
  defaultNamespace: namespace,
145
+ supabaseProjectId,
138
146
  supabaseLocalEnabled,
139
147
  profileModel,
140
148
  }),
@@ -143,6 +151,7 @@ export function generateMinikubeBaseArtifacts(args: {
143
151
  {
144
152
  path: `${scriptsRoot}/supabase-local-env.sh`,
145
153
  content: getSupabaseLocalEnvScript({
154
+ supabaseProjectId,
146
155
  supabaseLocalPorts,
147
156
  profileModel,
148
157
  }),
@@ -163,6 +172,7 @@ function getReadmeMarkdown(args: {
163
172
  authzEngine: string;
164
173
  extraResources: string[];
165
174
  supabaseLocalPorts: SupabaseLocalPorts;
175
+ supabaseProjectId: string | null;
166
176
  supabaseLocalEnabled: boolean;
167
177
  profileModel: ResolvedProfileModel;
168
178
  }): string {
@@ -174,6 +184,7 @@ function getReadmeMarkdown(args: {
174
184
  authzEngine,
175
185
  extraResources,
176
186
  supabaseLocalPorts,
187
+ supabaseProjectId,
177
188
  supabaseLocalEnabled,
178
189
  profileModel,
179
190
  } = args;
@@ -240,6 +251,7 @@ ${resourceLines}
240
251
  ## Runtime Conventions
241
252
 
242
253
  - Namespace: \`${namespace}\`
254
+ - Supabase local project identity: \`${supabaseProjectId ?? 'unused'}\`
243
255
  - Monitoring requested: \`${monitoringEnabled ? 'true' : 'false'}\`
244
256
  - Auth provider: \`${authProvider}\`
245
257
  - Authorization engine: \`${authzEngine}\`
@@ -327,6 +339,7 @@ Build flow:
327
339
 
328
340
  - \`./scripts/up.sh\` calls \`./scripts/build-app-image.sh\` when \`APP_BUILD_ENABLED=true\`.
329
341
  - Supabase local project root: \`infra/minikube\`${supabaseLocalEnabled ? '' : ' (unused unless Supabase-backed auth/database/storage is enabled)'}.
342
+ - Supabase local project identity: \`${supabaseProjectId ?? 'unused'}\`; existing local configs with another \`project_id\` must be destroyed and recreated manually.
330
343
  - Immutable migrations live in \`supabase/migrations/\` and are applied with \`supabase migration up --local\`.
331
344
  ${profileModel.enabled ? '- Generated profile desired-state reconciliation lives in `supabase/generated/auth_profiles.sql` and records checksum state in `ankhorage_internal.generated_schema_state`.\n' : ''}- \`./scripts/supabase-local-env.sh\` can populate Supabase keys in \`.env\` from local \`supabase status -o env\`.
332
345
  - \`./scripts/supabase-local-env.sh\` writes \`EXPO_PUBLIC_SUPABASE_URL\` + \`EXPO_PUBLIC_SUPABASE_ANON_KEY\` into \`$APP_SOURCE_DIR/.env.local\` (e.g. \`apps/card/.env.local\`) for local app runs.
@@ -427,6 +440,7 @@ function getAppConfigMap(args: {
427
440
  authScope: string;
428
441
  authProvider: string;
429
442
  databaseProvider: string;
443
+ secretStoreProvider: string;
430
444
  storageMetadata: AppInfraStorageMetadata | null;
431
445
  monitoringEnabled: boolean;
432
446
  domain: string;
@@ -436,6 +450,7 @@ function getAppConfigMap(args: {
436
450
  authScope,
437
451
  authProvider,
438
452
  databaseProvider,
453
+ secretStoreProvider,
439
454
  storageMetadata,
440
455
  monitoringEnabled,
441
456
  domain,
@@ -459,6 +474,7 @@ data:
459
474
  AUTH_SCOPE: "${authScope}"
460
475
  AUTH_PROVIDER: "${authProvider}"
461
476
  DATABASE_PROVIDER: "${databaseProvider}"
477
+ SECRET_STORE_PROVIDER: "${secretStoreProvider}"
462
478
  ${storageLines} NETWORK_DOMAIN: "${domain}"
463
479
  `;
464
480
  }
@@ -1406,10 +1422,11 @@ $$;`;
1406
1422
 
1407
1423
  function getStatusScript(args: {
1408
1424
  defaultNamespace: string;
1425
+ supabaseProjectId: string | null;
1409
1426
  supabaseLocalEnabled: boolean;
1410
1427
  profileModel: ResolvedProfileModel;
1411
1428
  }): string {
1412
- const { defaultNamespace, supabaseLocalEnabled, profileModel } = args;
1429
+ const { defaultNamespace, supabaseProjectId, supabaseLocalEnabled, profileModel } = args;
1413
1430
  const profileStatusSql = profileModel.enabled ? getSupabaseProfileChecksumSql(profileModel) : '';
1414
1431
  const profileVerificationSql = profileModel.enabled
1415
1432
  ? getSupabaseProfileVerificationSql(profileModel)
@@ -1433,8 +1450,28 @@ AUTH_RUNTIME_MODE="\${AUTH_RUNTIME_MODE:-local}"
1433
1450
  SUPABASE_LOCAL_ENABLED="\${SUPABASE_LOCAL_ENABLED:-${supabaseLocalEnabled ? 'true' : 'false'}}"
1434
1451
  SUPABASE_SECRET_SYNC_ENABLED="\${SUPABASE_SECRET_SYNC_ENABLED:-true}"
1435
1452
  SUPABASE_PROJECT_DIR="\${SUPABASE_PROJECT_DIR:-\${ROOT_DIR}}"
1453
+ EXPECTED_SUPABASE_PROJECT_ID="${supabaseProjectId ?? ''}"
1436
1454
  SUPABASE_PROFILE_ENABLED="${profileModel.enabled ? 'true' : 'false'}"
1437
1455
  SUPABASE_PROFILE_RECONCILE_FILE="\${SUPABASE_PROJECT_DIR}/supabase/generated/auth_profiles.sql"
1456
+ export EXPECTED_SUPABASE_PROJECT_ID
1457
+
1458
+ reject_supabase_project_id_override() {
1459
+ if [[ -n "\${SUPABASE_PROJECT_ID:-}" ]]; then
1460
+ echo "SUPABASE_PROJECT_ID must not be set for generated local Infra scripts."
1461
+ echo "Unset SUPABASE_PROJECT_ID and use supabase/config.toml project_id instead."
1462
+ return 1
1463
+ fi
1464
+
1465
+ unset SUPABASE_PROJECT_ID
1466
+ }
1467
+
1468
+ require_expected_supabase_project_identity() {
1469
+ if [[ -z "\${EXPECTED_SUPABASE_PROJECT_ID}" ]]; then
1470
+ echo "Cannot run local Supabase infrastructure: expected Supabase project identity is empty."
1471
+ echo "Regenerate Infra with appManifest.metadata.slug for Supabase-backed local services."
1472
+ return 1
1473
+ fi
1474
+ }
1438
1475
 
1439
1476
  if ! command -v kubectl >/dev/null 2>&1; then
1440
1477
  echo "kubectl is required but not installed."
@@ -1598,6 +1635,61 @@ run_supabase_sql() {
1598
1635
  return 1
1599
1636
  }
1600
1637
 
1638
+ validate_supabase_project_identity() {
1639
+ local config_file="\${SUPABASE_PROJECT_DIR}/supabase/config.toml"
1640
+
1641
+ if [[ -z "\${EXPECTED_SUPABASE_PROJECT_ID}" ]]; then
1642
+ return 0
1643
+ fi
1644
+
1645
+ if [[ ! -f "\${config_file}" ]]; then
1646
+ echo "- local Supabase project identity: config missing at \${config_file}"
1647
+ return 1
1648
+ fi
1649
+
1650
+ if ! command -v python3 >/dev/null 2>&1; then
1651
+ echo "- local Supabase project identity: python3 is required to read \${config_file}"
1652
+ return 1
1653
+ fi
1654
+
1655
+ python3 - "\${config_file}" <<'PY'
1656
+ import os
1657
+ import re
1658
+ import sys
1659
+
1660
+
1661
+ def read_top_level_project_id(config_path):
1662
+ key_re = re.compile(r'^\\s*project_id\\s*=\\s*(.*?)\\s*(?:#.*)?$')
1663
+ section_re = re.compile(r'^\\s*\\[[^\\]]+\\]\\s*$')
1664
+
1665
+ with open(config_path, 'r', encoding='utf-8') as f:
1666
+ for line in f:
1667
+ if section_re.match(line):
1668
+ return None
1669
+ match = key_re.match(line)
1670
+ if not match:
1671
+ continue
1672
+ raw = match.group(1).strip()
1673
+ quoted = re.match(r'^["\\']([^"\\']*)["\\']$', raw)
1674
+ return quoted.group(1) if quoted else raw
1675
+
1676
+ return None
1677
+
1678
+
1679
+ expected = os.environ['EXPECTED_SUPABASE_PROJECT_ID']
1680
+ actual = read_top_level_project_id(sys.argv[1])
1681
+
1682
+ if actual == expected:
1683
+ raise SystemExit(0)
1684
+
1685
+ found = 'missing' if actual is None else f'"{actual}"'
1686
+ print('Supabase project identity mismatch.', file=sys.stderr)
1687
+ print(f'Expected "{expected}", found {found}.', file=sys.stderr)
1688
+ print('Destroy the invalid local stack and run Infra Up again.', file=sys.stderr)
1689
+ raise SystemExit(1)
1690
+ PY
1691
+ }
1692
+
1601
1693
  check_immutable_migrations_applied() {
1602
1694
  local migrations_dir="\${SUPABASE_PROJECT_DIR}/supabase/migrations"
1603
1695
  local applied_versions
@@ -1632,8 +1724,14 @@ if [[ "\${AUTH_RUNTIME_MODE}" == "local" && "\${SUPABASE_LOCAL_ENABLED}" == "tru
1632
1724
  echo "Local Supabase database checks:"
1633
1725
  status_failed=0
1634
1726
 
1635
- if require_supabase_cli_capabilities; then
1636
- if supabase --workdir "\${SUPABASE_PROJECT_DIR}" status >/dev/null 2>&1; then
1727
+ if ! require_expected_supabase_project_identity; then
1728
+ status_failed=1
1729
+ elif ! reject_supabase_project_id_override; then
1730
+ status_failed=1
1731
+ elif require_supabase_cli_capabilities; then
1732
+ if ! validate_supabase_project_identity; then
1733
+ status_failed=1
1734
+ elif supabase --workdir "\${SUPABASE_PROJECT_DIR}" status >/dev/null 2>&1; then
1637
1735
  STATUS_ENV="$(supabase --workdir "\${SUPABASE_PROJECT_DIR}" status -o env)"
1638
1736
  SUPABASE_DB_URL="$(read_env_value "\${STATUS_ENV}" DB_URL)"
1639
1737
  if [[ -z "\${SUPABASE_DB_URL}" ]]; then
@@ -1705,10 +1803,11 @@ fi
1705
1803
  }
1706
1804
 
1707
1805
  function getSupabaseLocalEnvScript(args: {
1806
+ supabaseProjectId: string | null;
1708
1807
  supabaseLocalPorts: SupabaseLocalPorts;
1709
1808
  profileModel: ResolvedProfileModel;
1710
1809
  }): string {
1711
- const { supabaseLocalPorts, profileModel } = args;
1810
+ const { supabaseProjectId, supabaseLocalPorts, profileModel } = args;
1712
1811
  const profileStatusSql = profileModel.enabled ? getSupabaseProfileChecksumSql(profileModel) : '';
1713
1812
  const profileVerificationSql = profileModel.enabled
1714
1813
  ? getSupabaseProfileVerificationSql(profileModel)
@@ -1731,9 +1830,29 @@ fi
1731
1830
 
1732
1831
  APP_SOURCE_DIR="\${APP_SOURCE_DIR:-$(cd "\${ROOT_DIR}/../.." && pwd)}"
1733
1832
  SUPABASE_PROJECT_DIR="\${SUPABASE_PROJECT_DIR:-\${ROOT_DIR}}"
1833
+ EXPECTED_SUPABASE_PROJECT_ID="${supabaseProjectId ?? ''}"
1734
1834
  APP_SUPABASE_ENV_FILE="\${APP_SUPABASE_ENV_FILE:-\${APP_SOURCE_DIR}/.env.local}"
1735
1835
  SUPABASE_PROFILE_ENABLED="${profileModel.enabled ? 'true' : 'false'}"
1736
1836
  SUPABASE_PROFILE_RECONCILE_FILE="\${SUPABASE_PROJECT_DIR}/supabase/generated/auth_profiles.sql"
1837
+ export EXPECTED_SUPABASE_PROJECT_ID
1838
+
1839
+ reject_supabase_project_id_override() {
1840
+ if [[ -n "\${SUPABASE_PROJECT_ID:-}" ]]; then
1841
+ echo "SUPABASE_PROJECT_ID must not be set for generated local Infra scripts."
1842
+ echo "Unset SUPABASE_PROJECT_ID and use supabase/config.toml project_id instead."
1843
+ exit 1
1844
+ fi
1845
+
1846
+ unset SUPABASE_PROJECT_ID
1847
+ }
1848
+
1849
+ require_expected_supabase_project_identity() {
1850
+ if [[ -z "\${EXPECTED_SUPABASE_PROJECT_ID}" ]]; then
1851
+ echo "Cannot run local Supabase infrastructure: expected Supabase project identity is empty."
1852
+ echo "Regenerate Infra with appManifest.metadata.slug for Supabase-backed local services."
1853
+ exit 1
1854
+ fi
1855
+ }
1737
1856
 
1738
1857
  SUPABASE_LOCAL_PORT_BASE="\${SUPABASE_LOCAL_PORT_BASE:-${supabaseLocalPorts.base}}"
1739
1858
  SUPABASE_LOCAL_SHADOW_PORT="\${SUPABASE_LOCAL_SHADOW_PORT:-\${SUPABASE_LOCAL_PORT_BASE}}"
@@ -1779,6 +1898,11 @@ require_supabase_cli_capabilities() {
1779
1898
  exit 1
1780
1899
  fi
1781
1900
 
1901
+ if ! command -v python3 >/dev/null 2>&1; then
1902
+ echo "python3 is required to reconcile supabase/config.toml before local Supabase startup."
1903
+ exit 1
1904
+ fi
1905
+
1782
1906
  local version
1783
1907
  version="$(supabase --version | awk '{print $NF}')"
1784
1908
  if ! version_ge "\${version}" "2.106.0"; then
@@ -1840,6 +1964,8 @@ run_checked_sql_file() {
1840
1964
  psql "\${SUPABASE_DB_URL}" -v ON_ERROR_STOP=1 -q -f "\${sql_file}"
1841
1965
  }
1842
1966
 
1967
+ require_expected_supabase_project_identity
1968
+ reject_supabase_project_id_override
1843
1969
  require_supabase_cli_capabilities
1844
1970
 
1845
1971
  if [[ ! -d "\${SUPABASE_PROJECT_DIR}" ]]; then
@@ -1850,18 +1976,6 @@ fi
1850
1976
  configure_supabase_local_ports() {
1851
1977
  local config_file="\${SUPABASE_PROJECT_DIR}/supabase/config.toml"
1852
1978
 
1853
- if ! command -v python3 >/dev/null 2>&1; then
1854
- echo "python3 is required to patch supabase/config.toml with generated local port defaults."
1855
- echo "Install python3 (recommended) or manually edit \${config_file} ports:"
1856
- echo "- [api] port = \${SUPABASE_LOCAL_API_PORT}"
1857
- echo "- [db] port = \${SUPABASE_LOCAL_DB_PORT}"
1858
- echo "- [db] shadow_port = \${SUPABASE_LOCAL_SHADOW_PORT}"
1859
- echo "- [studio] port = \${SUPABASE_LOCAL_STUDIO_PORT}"
1860
- echo "- [inbucket] port = \${SUPABASE_LOCAL_INBUCKET_PORT}"
1861
- echo "- [analytics] port = \${SUPABASE_LOCAL_ANALYTICS_PORT}"
1862
- exit 1
1863
- fi
1864
-
1865
1979
  python3 - "\${config_file}" <<'PY'
1866
1980
  import os
1867
1981
  import re
@@ -1965,8 +2079,114 @@ if __name__ == "__main__":
1965
2079
  PY
1966
2080
  }
1967
2081
 
2082
+ write_supabase_project_identity_for_new_config() {
2083
+ local config_file="\${SUPABASE_PROJECT_DIR}/supabase/config.toml"
2084
+
2085
+ require_expected_supabase_project_identity
2086
+
2087
+ if ! command -v python3 >/dev/null 2>&1; then
2088
+ echo "python3 is required to write Supabase project_id in \${config_file}."
2089
+ exit 1
2090
+ fi
2091
+
2092
+ python3 - "\${config_file}" <<'PY'
2093
+ import os
2094
+ import re
2095
+ import sys
2096
+
2097
+
2098
+ def write_top_level_project_id(config_path, expected):
2099
+ key_re = re.compile(r'^(\\s*)project_id\\s*=\\s*(.*?)(\\s*(#.*)?)?$')
2100
+ section_re = re.compile(r'^\\s*\\[[^\\]]+\\]\\s*$')
2101
+
2102
+ with open(config_path, 'r', encoding='utf-8') as f:
2103
+ lines = f.readlines()
2104
+
2105
+ section_start = len(lines)
2106
+ for i, line in enumerate(lines):
2107
+ if section_re.match(line):
2108
+ section_start = i
2109
+ break
2110
+
2111
+ for i in range(section_start):
2112
+ match = key_re.match(lines[i])
2113
+ if not match:
2114
+ continue
2115
+ indent = match.group(1) or ''
2116
+ trailing = (match.group(3) or '').rstrip('\\n')
2117
+ lines[i] = f'{indent}project_id = "{expected}"{trailing}\\n'
2118
+ break
2119
+ else:
2120
+ insert_at = section_start
2121
+ lines.insert(insert_at, f'project_id = "{expected}"\\n')
2122
+
2123
+ with open(config_path, 'w', encoding='utf-8') as f:
2124
+ f.writelines(lines)
2125
+
2126
+
2127
+ write_top_level_project_id(sys.argv[1], os.environ['EXPECTED_SUPABASE_PROJECT_ID'])
2128
+ PY
2129
+ }
2130
+
2131
+ validate_supabase_project_identity() {
2132
+ local config_file="\${SUPABASE_PROJECT_DIR}/supabase/config.toml"
2133
+
2134
+ require_expected_supabase_project_identity
2135
+
2136
+ if [[ ! -f "\${config_file}" ]]; then
2137
+ echo "Supabase project identity mismatch."
2138
+ printf 'Expected "%s", found missing.\n' "\${EXPECTED_SUPABASE_PROJECT_ID}"
2139
+ echo "The existing local Supabase project belongs to a different identity."
2140
+ echo "Destroy the invalid local stack and its project-owned resources, then run Infra Up again."
2141
+ exit 1
2142
+ fi
2143
+
2144
+ if ! command -v python3 >/dev/null 2>&1; then
2145
+ echo "python3 is required to read Supabase project_id in \${config_file}."
2146
+ exit 1
2147
+ fi
2148
+
2149
+ python3 - "\${config_file}" <<'PY'
2150
+ import os
2151
+ import re
2152
+ import sys
2153
+
2154
+
2155
+ def read_top_level_project_id(config_path):
2156
+ key_re = re.compile(r'^\\s*project_id\\s*=\\s*(.*?)\\s*(?:#.*)?$')
2157
+ section_re = re.compile(r'^\\s*\\[[^\\]]+\\]\\s*$')
2158
+
2159
+ with open(config_path, 'r', encoding='utf-8') as f:
2160
+ for line in f:
2161
+ if section_re.match(line):
2162
+ return None
2163
+ match = key_re.match(line)
2164
+ if not match:
2165
+ continue
2166
+ raw = match.group(1).strip()
2167
+ quoted = re.match(r'^["\\']([^"\\']*)["\\']$', raw)
2168
+ return quoted.group(1) if quoted else raw
2169
+
2170
+ return None
2171
+
2172
+
2173
+ expected = os.environ['EXPECTED_SUPABASE_PROJECT_ID']
2174
+ actual = read_top_level_project_id(sys.argv[1])
2175
+
2176
+ if actual == expected:
2177
+ raise SystemExit(0)
2178
+
2179
+ found = 'missing' if actual is None else f'"{actual}"'
2180
+ print('Supabase project identity mismatch.', file=sys.stderr)
2181
+ print(f'Expected "{expected}", found {found}.', file=sys.stderr)
2182
+ print('The existing local Supabase project belongs to a different identity.', file=sys.stderr)
2183
+ print('Destroy the invalid local stack and its project-owned resources, then run Infra Up again.', file=sys.stderr)
2184
+ raise SystemExit(1)
2185
+ PY
2186
+ }
2187
+
1968
2188
  assert_supabase_local_ports_available() {
1969
- local project_label="\${ANKH_NAMESPACE:-$(basename "\${APP_SOURCE_DIR}")}"
2189
+ local project_label="\${EXPECTED_SUPABASE_PROJECT_ID}"
1970
2190
 
1971
2191
  ANKH_PROJECT_LABEL="\${project_label}" python3 - <<'PY'
1972
2192
  import os
@@ -2044,8 +2264,10 @@ PY
2044
2264
  if [[ ! -f "\${SUPABASE_PROJECT_DIR}/supabase/config.toml" ]]; then
2045
2265
  echo "No supabase/config.toml found in \${SUPABASE_PROJECT_DIR}. Initializing Supabase project..."
2046
2266
  supabase --workdir "\${SUPABASE_PROJECT_DIR}" init --yes >/dev/null
2267
+ write_supabase_project_identity_for_new_config
2047
2268
  fi
2048
2269
 
2270
+ validate_supabase_project_identity
2049
2271
  configure_supabase_local_ports
2050
2272
 
2051
2273
  STATUS_ENV="$(
@@ -2117,6 +2339,7 @@ start_supabase_local_stack() {
2117
2339
  }
2118
2340
 
2119
2341
  if [[ -z "\${STATUS_ENV}" ]]; then
2342
+ validate_supabase_project_identity
2120
2343
  assert_supabase_local_ports_available
2121
2344
  echo "Supabase local stack not detected. Starting with 'supabase start'..."
2122
2345
  start_supabase_local_stack
@@ -2130,6 +2353,7 @@ if [[ -z "\${SUPABASE_DB_URL}" ]]; then
2130
2353
  fi
2131
2354
 
2132
2355
  echo "Applying pending immutable Supabase migrations..."
2356
+ validate_supabase_project_identity
2133
2357
  run_checked_command \
2134
2358
  "Immutable Supabase migration application" \
2135
2359
  "" \
@@ -8,9 +8,11 @@ import type {
8
8
  import { generateAuthProviderArtifacts } from './auth';
9
9
  import { generateAuthorizationArtifacts } from './authz';
10
10
  import { generateMinikubeBaseArtifacts } from './base';
11
+ import { generateSecretStoreArtifacts } from './secrets';
11
12
  import { generateStorageArtifacts } from './storage';
12
13
 
13
14
  const DEFAULT_NAMESPACE = 'ankh-app';
15
+ const CANONICAL_PROJECT_SLUG_RE = /^[a-z0-9](?:[a-z0-9-]{0,38}[a-z0-9])?$/;
14
16
 
15
17
  export function generateMinikubeInfra(
16
18
  manifest: InfraManifestInput,
@@ -29,6 +31,9 @@ export function generateMinikubeInfra(
29
31
  }
30
32
 
31
33
  const namespace = resolveNamespace(manifest, options);
34
+ const supabaseProjectId = isSupabaseLocalEnabled(manifest)
35
+ ? validateCanonicalProjectSlug(options.appManifest?.metadata.slug)
36
+ : null;
32
37
 
33
38
  const authArtifacts = generateAuthProviderArtifacts({ manifest, namespace });
34
39
  const authzArtifacts = generateAuthorizationArtifacts({
@@ -37,6 +42,7 @@ export function generateMinikubeInfra(
37
42
  appManifest: options.appManifest,
38
43
  });
39
44
  const storageArtifacts = generateStorageArtifacts({ manifest, namespace });
45
+ const secretStoreArtifacts = generateSecretStoreArtifacts({ manifest, namespace });
40
46
  const apiArtifacts = generateApiInfrastructureArtifacts({
41
47
  data: options.appManifest?.data,
42
48
  databaseProvider: manifest.database?.provider,
@@ -46,16 +52,19 @@ export function generateMinikubeInfra(
46
52
  ...authArtifacts.resources,
47
53
  ...authzArtifacts.resources,
48
54
  ...storageArtifacts.resources,
55
+ ...secretStoreArtifacts.resources,
49
56
  ]);
50
57
  const extraEnvEntries = unique([
51
58
  ...authArtifacts.envEntries,
52
59
  ...authzArtifacts.envEntries,
53
60
  ...storageArtifacts.envEntries,
61
+ ...secretStoreArtifacts.envEntries,
54
62
  ]);
55
63
 
56
64
  const baseFiles = generateMinikubeBaseArtifacts({
57
65
  manifest,
58
66
  namespace,
67
+ supabaseProjectId,
59
68
  extraResources,
60
69
  extraEnvEntries,
61
70
  });
@@ -65,6 +74,7 @@ export function generateMinikubeInfra(
65
74
  ...authArtifacts.warnings,
66
75
  ...authzArtifacts.warnings,
67
76
  ...storageArtifacts.warnings,
77
+ ...secretStoreArtifacts.warnings,
68
78
  ...apiArtifacts.warnings,
69
79
  ]);
70
80
 
@@ -74,6 +84,7 @@ export function generateMinikubeInfra(
74
84
  ...authArtifacts.files,
75
85
  ...authzArtifacts.files,
76
86
  ...storageArtifacts.files,
87
+ ...secretStoreArtifacts.files,
77
88
  ...apiArtifacts.files,
78
89
  ],
79
90
  warnings,
@@ -94,6 +105,7 @@ function collectProviders(manifest: InfraManifestInput): string[] {
94
105
  }
95
106
  if (manifest.database?.provider) providers.add(manifest.database.provider);
96
107
  if (manifest.storage?.provider) providers.add(manifest.storage.provider);
108
+ if (manifest.secretStore?.provider) providers.add(manifest.secretStore.provider);
97
109
  if (manifest.state?.provider) providers.add(manifest.state.provider);
98
110
 
99
111
  return [...providers].sort();
@@ -129,6 +141,38 @@ function collectWarnings(manifest: InfraManifestInput): string[] {
129
141
  return warnings;
130
142
  }
131
143
 
144
+ function isSupabaseLocalEnabled(manifest: InfraManifestInput): boolean {
145
+ const authProvider = manifest.auth?.provider;
146
+ const databaseProvider = manifest.database?.provider;
147
+ const storageProvider = manifest.storage?.provider;
148
+ const secretStoreProvider = manifest.secretStore?.provider;
149
+
150
+ return (
151
+ authProvider === 'supabase' ||
152
+ databaseProvider === 'supabase' ||
153
+ storageProvider === 'supabase' ||
154
+ (storageProvider === 'auto' &&
155
+ (authProvider === 'supabase' || databaseProvider === 'supabase')) ||
156
+ secretStoreProvider === 'supabase-vault'
157
+ );
158
+ }
159
+
160
+ function validateCanonicalProjectSlug(slug: string | undefined): string {
161
+ if (!slug) {
162
+ throw new Error(
163
+ 'Cannot generate local Supabase infrastructure: appManifest.metadata.slug is required.',
164
+ );
165
+ }
166
+
167
+ if (slug.trim() !== slug || !CANONICAL_PROJECT_SLUG_RE.test(slug)) {
168
+ throw new Error(
169
+ 'Cannot generate local Supabase infrastructure: appManifest.metadata.slug must be a canonical lowercase slug up to 40 characters using only a-z, 0-9, and hyphens, without leading or trailing hyphens.',
170
+ );
171
+ }
172
+
173
+ return slug;
174
+ }
175
+
132
176
  function resolveNamespace(
133
177
  manifest: InfraManifestInput,
134
178
  options: InfrastructureGenerationOptions,