@ankhorage/infra 0.4.0 → 0.4.2

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.
@@ -30,17 +30,18 @@ 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';
@@ -68,6 +69,7 @@ export function generateMinikubeBaseArtifacts(args: {
68
69
  authzEngine,
69
70
  extraResources,
70
71
  supabaseLocalPorts,
72
+ supabaseProjectId,
71
73
  supabaseLocalEnabled,
72
74
  profileModel,
73
75
  }),
@@ -140,6 +142,7 @@ export function generateMinikubeBaseArtifacts(args: {
140
142
  path: `${scriptsRoot}/status.sh`,
141
143
  content: getStatusScript({
142
144
  defaultNamespace: namespace,
145
+ supabaseProjectId,
143
146
  supabaseLocalEnabled,
144
147
  profileModel,
145
148
  }),
@@ -148,6 +151,7 @@ export function generateMinikubeBaseArtifacts(args: {
148
151
  {
149
152
  path: `${scriptsRoot}/supabase-local-env.sh`,
150
153
  content: getSupabaseLocalEnvScript({
154
+ supabaseProjectId,
151
155
  supabaseLocalPorts,
152
156
  profileModel,
153
157
  }),
@@ -168,6 +172,7 @@ function getReadmeMarkdown(args: {
168
172
  authzEngine: string;
169
173
  extraResources: string[];
170
174
  supabaseLocalPorts: SupabaseLocalPorts;
175
+ supabaseProjectId: string | null;
171
176
  supabaseLocalEnabled: boolean;
172
177
  profileModel: ResolvedProfileModel;
173
178
  }): string {
@@ -179,6 +184,7 @@ function getReadmeMarkdown(args: {
179
184
  authzEngine,
180
185
  extraResources,
181
186
  supabaseLocalPorts,
187
+ supabaseProjectId,
182
188
  supabaseLocalEnabled,
183
189
  profileModel,
184
190
  } = args;
@@ -245,6 +251,7 @@ ${resourceLines}
245
251
  ## Runtime Conventions
246
252
 
247
253
  - Namespace: \`${namespace}\`
254
+ - Supabase local project identity: \`${supabaseProjectId ?? 'unused'}\`
248
255
  - Monitoring requested: \`${monitoringEnabled ? 'true' : 'false'}\`
249
256
  - Auth provider: \`${authProvider}\`
250
257
  - Authorization engine: \`${authzEngine}\`
@@ -332,6 +339,7 @@ Build flow:
332
339
 
333
340
  - \`./scripts/up.sh\` calls \`./scripts/build-app-image.sh\` when \`APP_BUILD_ENABLED=true\`.
334
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.
335
343
  - Immutable migrations live in \`supabase/migrations/\` and are applied with \`supabase migration up --local\`.
336
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\`.
337
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.
@@ -1059,13 +1067,21 @@ $$;`;
1059
1067
 
1060
1068
  function getSupabaseProfileDisabledGuardSql(): string {
1061
1069
  return `do $$
1070
+ declare
1071
+ has_profile_state boolean := false;
1062
1072
  begin
1063
- if to_regclass('ankhorage_internal.generated_schema_state') is not null
1064
- and exists (
1065
- select 1
1066
- from ankhorage_internal.generated_schema_state
1067
- where artifact_key = 'auth.profile'
1068
- ) then
1073
+ if to_regclass('ankhorage_internal.generated_schema_state') is not null then
1074
+ execute
1075
+ 'select exists (
1076
+ select 1
1077
+ from ankhorage_internal.generated_schema_state
1078
+ where artifact_key = $1
1079
+ )'
1080
+ into has_profile_state
1081
+ using 'auth.profile';
1082
+ end if;
1083
+
1084
+ if has_profile_state then
1069
1085
  raise exception 'manifest auth.profile.table was removed but local generated auth.profile state still exists; reset local Supabase or add an explicit cleanup migration';
1070
1086
  end if;
1071
1087
  end;
@@ -1414,10 +1430,11 @@ $$;`;
1414
1430
 
1415
1431
  function getStatusScript(args: {
1416
1432
  defaultNamespace: string;
1433
+ supabaseProjectId: string | null;
1417
1434
  supabaseLocalEnabled: boolean;
1418
1435
  profileModel: ResolvedProfileModel;
1419
1436
  }): string {
1420
- const { defaultNamespace, supabaseLocalEnabled, profileModel } = args;
1437
+ const { defaultNamespace, supabaseProjectId, supabaseLocalEnabled, profileModel } = args;
1421
1438
  const profileStatusSql = profileModel.enabled ? getSupabaseProfileChecksumSql(profileModel) : '';
1422
1439
  const profileVerificationSql = profileModel.enabled
1423
1440
  ? getSupabaseProfileVerificationSql(profileModel)
@@ -1441,8 +1458,28 @@ AUTH_RUNTIME_MODE="\${AUTH_RUNTIME_MODE:-local}"
1441
1458
  SUPABASE_LOCAL_ENABLED="\${SUPABASE_LOCAL_ENABLED:-${supabaseLocalEnabled ? 'true' : 'false'}}"
1442
1459
  SUPABASE_SECRET_SYNC_ENABLED="\${SUPABASE_SECRET_SYNC_ENABLED:-true}"
1443
1460
  SUPABASE_PROJECT_DIR="\${SUPABASE_PROJECT_DIR:-\${ROOT_DIR}}"
1461
+ EXPECTED_SUPABASE_PROJECT_ID="${supabaseProjectId ?? ''}"
1444
1462
  SUPABASE_PROFILE_ENABLED="${profileModel.enabled ? 'true' : 'false'}"
1445
1463
  SUPABASE_PROFILE_RECONCILE_FILE="\${SUPABASE_PROJECT_DIR}/supabase/generated/auth_profiles.sql"
1464
+ export EXPECTED_SUPABASE_PROJECT_ID
1465
+
1466
+ reject_supabase_project_id_override() {
1467
+ if [[ -n "\${SUPABASE_PROJECT_ID:-}" ]]; then
1468
+ echo "SUPABASE_PROJECT_ID must not be set for generated local Infra scripts."
1469
+ echo "Unset SUPABASE_PROJECT_ID and use supabase/config.toml project_id instead."
1470
+ return 1
1471
+ fi
1472
+
1473
+ unset SUPABASE_PROJECT_ID
1474
+ }
1475
+
1476
+ require_expected_supabase_project_identity() {
1477
+ if [[ -z "\${EXPECTED_SUPABASE_PROJECT_ID}" ]]; then
1478
+ echo "Cannot run local Supabase infrastructure: expected Supabase project identity is empty."
1479
+ echo "Regenerate Infra with appManifest.metadata.slug for Supabase-backed local services."
1480
+ return 1
1481
+ fi
1482
+ }
1446
1483
 
1447
1484
  if ! command -v kubectl >/dev/null 2>&1; then
1448
1485
  echo "kubectl is required but not installed."
@@ -1606,6 +1643,61 @@ run_supabase_sql() {
1606
1643
  return 1
1607
1644
  }
1608
1645
 
1646
+ validate_supabase_project_identity() {
1647
+ local config_file="\${SUPABASE_PROJECT_DIR}/supabase/config.toml"
1648
+
1649
+ if [[ -z "\${EXPECTED_SUPABASE_PROJECT_ID}" ]]; then
1650
+ return 0
1651
+ fi
1652
+
1653
+ if [[ ! -f "\${config_file}" ]]; then
1654
+ echo "- local Supabase project identity: config missing at \${config_file}"
1655
+ return 1
1656
+ fi
1657
+
1658
+ if ! command -v python3 >/dev/null 2>&1; then
1659
+ echo "- local Supabase project identity: python3 is required to read \${config_file}"
1660
+ return 1
1661
+ fi
1662
+
1663
+ python3 - "\${config_file}" <<'PY'
1664
+ import os
1665
+ import re
1666
+ import sys
1667
+
1668
+
1669
+ def read_top_level_project_id(config_path):
1670
+ key_re = re.compile(r'^\\s*project_id\\s*=\\s*(.*?)\\s*(?:#.*)?$')
1671
+ section_re = re.compile(r'^\\s*\\[[^\\]]+\\]\\s*$')
1672
+
1673
+ with open(config_path, 'r', encoding='utf-8') as f:
1674
+ for line in f:
1675
+ if section_re.match(line):
1676
+ return None
1677
+ match = key_re.match(line)
1678
+ if not match:
1679
+ continue
1680
+ raw = match.group(1).strip()
1681
+ quoted = re.match(r'^["\\']([^"\\']*)["\\']$', raw)
1682
+ return quoted.group(1) if quoted else raw
1683
+
1684
+ return None
1685
+
1686
+
1687
+ expected = os.environ['EXPECTED_SUPABASE_PROJECT_ID']
1688
+ actual = read_top_level_project_id(sys.argv[1])
1689
+
1690
+ if actual == expected:
1691
+ raise SystemExit(0)
1692
+
1693
+ found = 'missing' if actual is None else f'"{actual}"'
1694
+ print('Supabase project identity mismatch.', file=sys.stderr)
1695
+ print(f'Expected "{expected}", found {found}.', file=sys.stderr)
1696
+ print('Destroy the invalid local stack and run Infra Up again.', file=sys.stderr)
1697
+ raise SystemExit(1)
1698
+ PY
1699
+ }
1700
+
1609
1701
  check_immutable_migrations_applied() {
1610
1702
  local migrations_dir="\${SUPABASE_PROJECT_DIR}/supabase/migrations"
1611
1703
  local applied_versions
@@ -1640,8 +1732,14 @@ if [[ "\${AUTH_RUNTIME_MODE}" == "local" && "\${SUPABASE_LOCAL_ENABLED}" == "tru
1640
1732
  echo "Local Supabase database checks:"
1641
1733
  status_failed=0
1642
1734
 
1643
- if require_supabase_cli_capabilities; then
1644
- if supabase --workdir "\${SUPABASE_PROJECT_DIR}" status >/dev/null 2>&1; then
1735
+ if ! require_expected_supabase_project_identity; then
1736
+ status_failed=1
1737
+ elif ! reject_supabase_project_id_override; then
1738
+ status_failed=1
1739
+ elif require_supabase_cli_capabilities; then
1740
+ if ! validate_supabase_project_identity; then
1741
+ status_failed=1
1742
+ elif supabase --workdir "\${SUPABASE_PROJECT_DIR}" status >/dev/null 2>&1; then
1645
1743
  STATUS_ENV="$(supabase --workdir "\${SUPABASE_PROJECT_DIR}" status -o env)"
1646
1744
  SUPABASE_DB_URL="$(read_env_value "\${STATUS_ENV}" DB_URL)"
1647
1745
  if [[ -z "\${SUPABASE_DB_URL}" ]]; then
@@ -1713,10 +1811,11 @@ fi
1713
1811
  }
1714
1812
 
1715
1813
  function getSupabaseLocalEnvScript(args: {
1814
+ supabaseProjectId: string | null;
1716
1815
  supabaseLocalPorts: SupabaseLocalPorts;
1717
1816
  profileModel: ResolvedProfileModel;
1718
1817
  }): string {
1719
- const { supabaseLocalPorts, profileModel } = args;
1818
+ const { supabaseProjectId, supabaseLocalPorts, profileModel } = args;
1720
1819
  const profileStatusSql = profileModel.enabled ? getSupabaseProfileChecksumSql(profileModel) : '';
1721
1820
  const profileVerificationSql = profileModel.enabled
1722
1821
  ? getSupabaseProfileVerificationSql(profileModel)
@@ -1739,9 +1838,29 @@ fi
1739
1838
 
1740
1839
  APP_SOURCE_DIR="\${APP_SOURCE_DIR:-$(cd "\${ROOT_DIR}/../.." && pwd)}"
1741
1840
  SUPABASE_PROJECT_DIR="\${SUPABASE_PROJECT_DIR:-\${ROOT_DIR}}"
1841
+ EXPECTED_SUPABASE_PROJECT_ID="${supabaseProjectId ?? ''}"
1742
1842
  APP_SUPABASE_ENV_FILE="\${APP_SUPABASE_ENV_FILE:-\${APP_SOURCE_DIR}/.env.local}"
1743
1843
  SUPABASE_PROFILE_ENABLED="${profileModel.enabled ? 'true' : 'false'}"
1744
1844
  SUPABASE_PROFILE_RECONCILE_FILE="\${SUPABASE_PROJECT_DIR}/supabase/generated/auth_profiles.sql"
1845
+ export EXPECTED_SUPABASE_PROJECT_ID
1846
+
1847
+ reject_supabase_project_id_override() {
1848
+ if [[ -n "\${SUPABASE_PROJECT_ID:-}" ]]; then
1849
+ echo "SUPABASE_PROJECT_ID must not be set for generated local Infra scripts."
1850
+ echo "Unset SUPABASE_PROJECT_ID and use supabase/config.toml project_id instead."
1851
+ exit 1
1852
+ fi
1853
+
1854
+ unset SUPABASE_PROJECT_ID
1855
+ }
1856
+
1857
+ require_expected_supabase_project_identity() {
1858
+ if [[ -z "\${EXPECTED_SUPABASE_PROJECT_ID}" ]]; then
1859
+ echo "Cannot run local Supabase infrastructure: expected Supabase project identity is empty."
1860
+ echo "Regenerate Infra with appManifest.metadata.slug for Supabase-backed local services."
1861
+ exit 1
1862
+ fi
1863
+ }
1745
1864
 
1746
1865
  SUPABASE_LOCAL_PORT_BASE="\${SUPABASE_LOCAL_PORT_BASE:-${supabaseLocalPorts.base}}"
1747
1866
  SUPABASE_LOCAL_SHADOW_PORT="\${SUPABASE_LOCAL_SHADOW_PORT:-\${SUPABASE_LOCAL_PORT_BASE}}"
@@ -1787,6 +1906,11 @@ require_supabase_cli_capabilities() {
1787
1906
  exit 1
1788
1907
  fi
1789
1908
 
1909
+ if ! command -v python3 >/dev/null 2>&1; then
1910
+ echo "python3 is required to reconcile supabase/config.toml before local Supabase startup."
1911
+ exit 1
1912
+ fi
1913
+
1790
1914
  local version
1791
1915
  version="$(supabase --version | awk '{print $NF}')"
1792
1916
  if ! version_ge "\${version}" "2.106.0"; then
@@ -1848,6 +1972,8 @@ run_checked_sql_file() {
1848
1972
  psql "\${SUPABASE_DB_URL}" -v ON_ERROR_STOP=1 -q -f "\${sql_file}"
1849
1973
  }
1850
1974
 
1975
+ require_expected_supabase_project_identity
1976
+ reject_supabase_project_id_override
1851
1977
  require_supabase_cli_capabilities
1852
1978
 
1853
1979
  if [[ ! -d "\${SUPABASE_PROJECT_DIR}" ]]; then
@@ -1858,18 +1984,6 @@ fi
1858
1984
  configure_supabase_local_ports() {
1859
1985
  local config_file="\${SUPABASE_PROJECT_DIR}/supabase/config.toml"
1860
1986
 
1861
- if ! command -v python3 >/dev/null 2>&1; then
1862
- echo "python3 is required to patch supabase/config.toml with generated local port defaults."
1863
- echo "Install python3 (recommended) or manually edit \${config_file} ports:"
1864
- echo "- [api] port = \${SUPABASE_LOCAL_API_PORT}"
1865
- echo "- [db] port = \${SUPABASE_LOCAL_DB_PORT}"
1866
- echo "- [db] shadow_port = \${SUPABASE_LOCAL_SHADOW_PORT}"
1867
- echo "- [studio] port = \${SUPABASE_LOCAL_STUDIO_PORT}"
1868
- echo "- [inbucket] port = \${SUPABASE_LOCAL_INBUCKET_PORT}"
1869
- echo "- [analytics] port = \${SUPABASE_LOCAL_ANALYTICS_PORT}"
1870
- exit 1
1871
- fi
1872
-
1873
1987
  python3 - "\${config_file}" <<'PY'
1874
1988
  import os
1875
1989
  import re
@@ -1973,8 +2087,114 @@ if __name__ == "__main__":
1973
2087
  PY
1974
2088
  }
1975
2089
 
2090
+ write_supabase_project_identity_for_new_config() {
2091
+ local config_file="\${SUPABASE_PROJECT_DIR}/supabase/config.toml"
2092
+
2093
+ require_expected_supabase_project_identity
2094
+
2095
+ if ! command -v python3 >/dev/null 2>&1; then
2096
+ echo "python3 is required to write Supabase project_id in \${config_file}."
2097
+ exit 1
2098
+ fi
2099
+
2100
+ python3 - "\${config_file}" <<'PY'
2101
+ import os
2102
+ import re
2103
+ import sys
2104
+
2105
+
2106
+ def write_top_level_project_id(config_path, expected):
2107
+ key_re = re.compile(r'^(\\s*)project_id\\s*=\\s*(.*?)(\\s*(#.*)?)?$')
2108
+ section_re = re.compile(r'^\\s*\\[[^\\]]+\\]\\s*$')
2109
+
2110
+ with open(config_path, 'r', encoding='utf-8') as f:
2111
+ lines = f.readlines()
2112
+
2113
+ section_start = len(lines)
2114
+ for i, line in enumerate(lines):
2115
+ if section_re.match(line):
2116
+ section_start = i
2117
+ break
2118
+
2119
+ for i in range(section_start):
2120
+ match = key_re.match(lines[i])
2121
+ if not match:
2122
+ continue
2123
+ indent = match.group(1) or ''
2124
+ trailing = (match.group(3) or '').rstrip('\\n')
2125
+ lines[i] = f'{indent}project_id = "{expected}"{trailing}\\n'
2126
+ break
2127
+ else:
2128
+ insert_at = section_start
2129
+ lines.insert(insert_at, f'project_id = "{expected}"\\n')
2130
+
2131
+ with open(config_path, 'w', encoding='utf-8') as f:
2132
+ f.writelines(lines)
2133
+
2134
+
2135
+ write_top_level_project_id(sys.argv[1], os.environ['EXPECTED_SUPABASE_PROJECT_ID'])
2136
+ PY
2137
+ }
2138
+
2139
+ validate_supabase_project_identity() {
2140
+ local config_file="\${SUPABASE_PROJECT_DIR}/supabase/config.toml"
2141
+
2142
+ require_expected_supabase_project_identity
2143
+
2144
+ if [[ ! -f "\${config_file}" ]]; then
2145
+ echo "Supabase project identity mismatch."
2146
+ printf 'Expected "%s", found missing.\n' "\${EXPECTED_SUPABASE_PROJECT_ID}"
2147
+ echo "The existing local Supabase project belongs to a different identity."
2148
+ echo "Destroy the invalid local stack and its project-owned resources, then run Infra Up again."
2149
+ exit 1
2150
+ fi
2151
+
2152
+ if ! command -v python3 >/dev/null 2>&1; then
2153
+ echo "python3 is required to read Supabase project_id in \${config_file}."
2154
+ exit 1
2155
+ fi
2156
+
2157
+ python3 - "\${config_file}" <<'PY'
2158
+ import os
2159
+ import re
2160
+ import sys
2161
+
2162
+
2163
+ def read_top_level_project_id(config_path):
2164
+ key_re = re.compile(r'^\\s*project_id\\s*=\\s*(.*?)\\s*(?:#.*)?$')
2165
+ section_re = re.compile(r'^\\s*\\[[^\\]]+\\]\\s*$')
2166
+
2167
+ with open(config_path, 'r', encoding='utf-8') as f:
2168
+ for line in f:
2169
+ if section_re.match(line):
2170
+ return None
2171
+ match = key_re.match(line)
2172
+ if not match:
2173
+ continue
2174
+ raw = match.group(1).strip()
2175
+ quoted = re.match(r'^["\\']([^"\\']*)["\\']$', raw)
2176
+ return quoted.group(1) if quoted else raw
2177
+
2178
+ return None
2179
+
2180
+
2181
+ expected = os.environ['EXPECTED_SUPABASE_PROJECT_ID']
2182
+ actual = read_top_level_project_id(sys.argv[1])
2183
+
2184
+ if actual == expected:
2185
+ raise SystemExit(0)
2186
+
2187
+ found = 'missing' if actual is None else f'"{actual}"'
2188
+ print('Supabase project identity mismatch.', file=sys.stderr)
2189
+ print(f'Expected "{expected}", found {found}.', file=sys.stderr)
2190
+ print('The existing local Supabase project belongs to a different identity.', file=sys.stderr)
2191
+ print('Destroy the invalid local stack and its project-owned resources, then run Infra Up again.', file=sys.stderr)
2192
+ raise SystemExit(1)
2193
+ PY
2194
+ }
2195
+
1976
2196
  assert_supabase_local_ports_available() {
1977
- local project_label="\${ANKH_NAMESPACE:-$(basename "\${APP_SOURCE_DIR}")}"
2197
+ local project_label="\${EXPECTED_SUPABASE_PROJECT_ID}"
1978
2198
 
1979
2199
  ANKH_PROJECT_LABEL="\${project_label}" python3 - <<'PY'
1980
2200
  import os
@@ -2052,8 +2272,10 @@ PY
2052
2272
  if [[ ! -f "\${SUPABASE_PROJECT_DIR}/supabase/config.toml" ]]; then
2053
2273
  echo "No supabase/config.toml found in \${SUPABASE_PROJECT_DIR}. Initializing Supabase project..."
2054
2274
  supabase --workdir "\${SUPABASE_PROJECT_DIR}" init --yes >/dev/null
2275
+ write_supabase_project_identity_for_new_config
2055
2276
  fi
2056
2277
 
2278
+ validate_supabase_project_identity
2057
2279
  configure_supabase_local_ports
2058
2280
 
2059
2281
  STATUS_ENV="$(
@@ -2125,6 +2347,7 @@ start_supabase_local_stack() {
2125
2347
  }
2126
2348
 
2127
2349
  if [[ -z "\${STATUS_ENV}" ]]; then
2350
+ validate_supabase_project_identity
2128
2351
  assert_supabase_local_ports_available
2129
2352
  echo "Supabase local stack not detected. Starting with 'supabase start'..."
2130
2353
  start_supabase_local_stack
@@ -2138,6 +2361,7 @@ if [[ -z "\${SUPABASE_DB_URL}" ]]; then
2138
2361
  fi
2139
2362
 
2140
2363
  echo "Applying pending immutable Supabase migrations..."
2364
+ validate_supabase_project_identity
2141
2365
  run_checked_command \
2142
2366
  "Immutable Supabase migration application" \
2143
2367
  "" \
@@ -12,6 +12,7 @@ import { generateSecretStoreArtifacts } from './secrets';
12
12
  import { generateStorageArtifacts } from './storage';
13
13
 
14
14
  const DEFAULT_NAMESPACE = 'ankh-app';
15
+ const CANONICAL_PROJECT_SLUG_RE = /^[a-z0-9](?:[a-z0-9-]{0,38}[a-z0-9])?$/;
15
16
 
16
17
  export function generateMinikubeInfra(
17
18
  manifest: InfraManifestInput,
@@ -30,6 +31,9 @@ export function generateMinikubeInfra(
30
31
  }
31
32
 
32
33
  const namespace = resolveNamespace(manifest, options);
34
+ const supabaseProjectId = isSupabaseLocalEnabled(manifest)
35
+ ? validateCanonicalProjectSlug(options.appManifest?.metadata.slug)
36
+ : null;
33
37
 
34
38
  const authArtifacts = generateAuthProviderArtifacts({ manifest, namespace });
35
39
  const authzArtifacts = generateAuthorizationArtifacts({
@@ -60,6 +64,7 @@ export function generateMinikubeInfra(
60
64
  const baseFiles = generateMinikubeBaseArtifacts({
61
65
  manifest,
62
66
  namespace,
67
+ supabaseProjectId,
63
68
  extraResources,
64
69
  extraEnvEntries,
65
70
  });
@@ -136,6 +141,38 @@ function collectWarnings(manifest: InfraManifestInput): string[] {
136
141
  return warnings;
137
142
  }
138
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
+
139
176
  function resolveNamespace(
140
177
  manifest: InfraManifestInput,
141
178
  options: InfrastructureGenerationOptions,
@@ -1,3 +1,4 @@
1
+ import type { AppManifest } from '@ankhorage/contracts';
1
2
  import { SUPABASE_VAULT_MIGRATION_SQL } from '@ankhorage/supabase-vault';
2
3
  import { describe, expect, test } from 'bun:test';
3
4
 
@@ -21,7 +22,9 @@ function createManifest(overrides: Partial<InfraManifestInput> = {}): InfraManif
21
22
 
22
23
  describe('minikube secret-store generation', () => {
23
24
  test('generates the released Supabase Vault migration through the existing lifecycle', () => {
24
- const result = generateInfrastructure(createManifest());
25
+ const result = generateInfrastructure(createManifest(), {
26
+ appManifest: createAppManifest('vault-app'),
27
+ });
25
28
  const migration = result.files.find((file) => file.path === SUPABASE_VAULT_MIGRATION_PATH);
26
29
  const envExample = result.files.find((file) => file.path === 'infra/minikube/.env.example');
27
30
  const upScript = result.files.find((file) => file.path === 'infra/minikube/scripts/up.sh');
@@ -55,6 +58,7 @@ describe('minikube secret-store generation', () => {
55
58
  },
56
59
  },
57
60
  }),
61
+ { appManifest: createAppManifest('vault-app') },
58
62
  ),
59
63
  ).toThrow('does not define credentialsRef');
60
64
  });
@@ -81,6 +85,7 @@ describe('minikube secret-store generation', () => {
81
85
  },
82
86
  },
83
87
  }),
88
+ { appManifest: createAppManifest('vault-app') },
84
89
  );
85
90
 
86
91
  const serialized = JSON.stringify(result.files);
@@ -101,3 +106,34 @@ describe('minikube secret-store generation', () => {
101
106
  ).toThrow('Unsupported secret-store provider for minikube adapter');
102
107
  });
103
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
+ }