@ankhorage/infra 0.3.2 → 0.3.3

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.
@@ -1,5 +1,10 @@
1
1
  import type { GeneratedInfrastructureFile } from '../../../types';
2
2
  import type { InfraManifestInput } from '../../../types';
3
+ import {
4
+ MANAGED_PROFILE_COLUMNS,
5
+ type ResolvedProfileModel,
6
+ resolveSupabaseProfileModel,
7
+ } from '../auth/supabase/profile';
3
8
 
4
9
  interface SupabaseLocalPorts {
5
10
  base: number;
@@ -42,8 +47,11 @@ export function generateMinikubeBaseArtifacts(args: {
42
47
  const authzEngine = manifest.auth?.authorization.engine ?? 'none';
43
48
  const databaseProvider = manifest.database?.provider ?? 'none';
44
49
  const storageMetadata = resolveAppInfraStorageMetadata(manifest);
50
+ const profileModel = resolveSupabaseProfileModel(manifest);
45
51
  const monitoringEnabled = manifest.deployment?.monitoring === true;
46
52
  const domain = manifest.networking?.domain ?? '';
53
+ const supabaseLocalEnabled =
54
+ authProvider === 'supabase' || databaseProvider === 'supabase' || storageMetadata !== null;
47
55
 
48
56
  return [
49
57
  {
@@ -56,6 +64,8 @@ export function generateMinikubeBaseArtifacts(args: {
56
64
  authzEngine,
57
65
  extraResources,
58
66
  supabaseLocalPorts,
67
+ supabaseLocalEnabled,
68
+ profileModel,
59
69
  }),
60
70
  },
61
71
  {
@@ -66,6 +76,7 @@ export function generateMinikubeBaseArtifacts(args: {
66
76
  extraEnvEntries,
67
77
  defaultAppImage,
68
78
  supabaseLocalPorts,
79
+ supabaseLocalEnabled,
69
80
  }),
70
81
  },
71
82
  {
@@ -98,7 +109,11 @@ export function generateMinikubeBaseArtifacts(args: {
98
109
  },
99
110
  {
100
111
  path: `${scriptsRoot}/up.sh`,
101
- content: getUpScript({ defaultNamespace: namespace, defaultAppImage }),
112
+ content: getUpScript({
113
+ defaultNamespace: namespace,
114
+ defaultAppImage,
115
+ supabaseLocalEnabled,
116
+ }),
102
117
  executable: true,
103
118
  },
104
119
  {
@@ -118,12 +133,19 @@ export function generateMinikubeBaseArtifacts(args: {
118
133
  },
119
134
  {
120
135
  path: `${scriptsRoot}/status.sh`,
121
- content: getStatusScript(namespace),
136
+ content: getStatusScript({
137
+ defaultNamespace: namespace,
138
+ supabaseLocalEnabled,
139
+ profileModel,
140
+ }),
122
141
  executable: true,
123
142
  },
124
143
  {
125
144
  path: `${scriptsRoot}/supabase-local-env.sh`,
126
- content: getSupabaseLocalEnvScript(supabaseLocalPorts),
145
+ content: getSupabaseLocalEnvScript({
146
+ supabaseLocalPorts,
147
+ profileModel,
148
+ }),
127
149
  executable: true,
128
150
  },
129
151
  {
@@ -141,6 +163,8 @@ function getReadmeMarkdown(args: {
141
163
  authzEngine: string;
142
164
  extraResources: string[];
143
165
  supabaseLocalPorts: SupabaseLocalPorts;
166
+ supabaseLocalEnabled: boolean;
167
+ profileModel: ResolvedProfileModel;
144
168
  }): string {
145
169
  const {
146
170
  namespace,
@@ -150,6 +174,8 @@ function getReadmeMarkdown(args: {
150
174
  authzEngine,
151
175
  extraResources,
152
176
  supabaseLocalPorts,
177
+ supabaseLocalEnabled,
178
+ profileModel,
153
179
  } = args;
154
180
  const resourceLines = [
155
181
  'namespace.yaml',
@@ -300,9 +326,11 @@ Override with:
300
326
  Build flow:
301
327
 
302
328
  - \`./scripts/up.sh\` calls \`./scripts/build-app-image.sh\` when \`APP_BUILD_ENABLED=true\`.
303
- - \`./scripts/supabase-local-env.sh\` can populate Supabase keys in \`.env\` from local \`supabase status -o env\`.
329
+ - Supabase local project root: \`infra/minikube\`${supabaseLocalEnabled ? '' : ' (unused unless Supabase-backed auth/database/storage is enabled)'}.
330
+ - Immutable migrations live in \`supabase/migrations/\` and are applied with \`supabase migration up --local\`.
331
+ ${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\`.
304
332
  - \`./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.
305
- - With \`AUTH_RUNTIME_MODE=local\` (default), \`up.sh\` auto-runs \`supabase-local-env.sh\` when Supabase keys are missing.
333
+ - With \`AUTH_RUNTIME_MODE=local\` (default), \`up.sh\` runs \`supabase-local-env.sh\` before app/Kubernetes startup when Supabase-backed local services are enabled.
306
334
  - \`supabase-local-env.sh\` checks configured host ports before starting a missing local Supabase stack.
307
335
  - \`build-app-image.sh\` runs \`bunx expo export --platform web\` from app source.
308
336
  - \`app-image/Dockerfile\` builds an nginx image from exported web artifacts.
@@ -330,6 +358,7 @@ function getEnvExample(args: {
330
358
  extraEnvEntries: string[];
331
359
  defaultAppImage: string;
332
360
  supabaseLocalPorts: SupabaseLocalPorts;
361
+ supabaseLocalEnabled: boolean;
333
362
  }): string {
334
363
  const { namespace, domain, extraEnvEntries, defaultAppImage, supabaseLocalPorts } = args;
335
364
 
@@ -341,6 +370,7 @@ function getEnvExample(args: {
341
370
  'APP_BUILD_ENABLED=true',
342
371
  'APP_SOURCE_DIR=',
343
372
  'APP_WEB_EXPORT_DIR=.ankh/web-export',
373
+ '# Canonical Supabase project root. Leave empty to use infra/minikube.',
344
374
  'SUPABASE_PROJECT_DIR=',
345
375
  `SUPABASE_LOCAL_PORT_BASE=${supabaseLocalPorts.base}`,
346
376
  `SUPABASE_LOCAL_SHADOW_PORT=${supabaseLocalPorts.shadow}`,
@@ -577,8 +607,12 @@ spec:
577
607
  `;
578
608
  }
579
609
 
580
- function getUpScript(args: { defaultNamespace: string; defaultAppImage: string }): string {
581
- const { defaultNamespace, defaultAppImage } = args;
610
+ function getUpScript(args: {
611
+ defaultNamespace: string;
612
+ defaultAppImage: string;
613
+ supabaseLocalEnabled: boolean;
614
+ }): string {
615
+ const { defaultNamespace, defaultAppImage, supabaseLocalEnabled } = args;
582
616
 
583
617
  return `#!/usr/bin/env bash
584
618
  set -euo pipefail
@@ -611,6 +645,7 @@ APP_IMAGE_PULL_SECRET_USERNAME="\${APP_IMAGE_PULL_SECRET_USERNAME:-}"
611
645
  APP_IMAGE_PULL_SECRET_PASSWORD="\${APP_IMAGE_PULL_SECRET_PASSWORD:-}"
612
646
  APP_IMAGE_PULL_SECRET_EMAIL="\${APP_IMAGE_PULL_SECRET_EMAIL:-}"
613
647
  AUTH_RUNTIME_MODE="\${AUTH_RUNTIME_MODE:-local}"
648
+ SUPABASE_LOCAL_ENABLED="\${SUPABASE_LOCAL_ENABLED:-${supabaseLocalEnabled ? 'true' : 'false'}}"
614
649
  SUPABASE_SECRET_SYNC_ENABLED="\${SUPABASE_SECRET_SYNC_ENABLED:-true}"
615
650
  SUPABASE_LOCAL_ENV_SCRIPT="\${SCRIPT_DIR}/supabase-local-env.sh"
616
651
  SUPABASE_URL="\${SUPABASE_URL:-}"
@@ -655,13 +690,15 @@ if [[ "\${HOST_STATUS}" != "Running" ]]; then
655
690
  minikube start -p "\${PROFILE}" --driver="\${DRIVER}"
656
691
  fi
657
692
 
658
- if ! has_required_supabase_env && [[ "\${AUTH_RUNTIME_MODE}" == "local" ]]; then
693
+ if [[ "\${AUTH_RUNTIME_MODE}" == "local" && "\${SUPABASE_LOCAL_ENABLED}" == "true" ]]; then
659
694
  if [[ ! -x "\${SUPABASE_LOCAL_ENV_SCRIPT}" ]]; then
660
- echo "Supabase local bootstrap skipped before app build: missing executable \${SUPABASE_LOCAL_ENV_SCRIPT}."
695
+ echo "Supabase local bootstrap failed: missing executable \${SUPABASE_LOCAL_ENV_SCRIPT}."
696
+ exit 1
661
697
  else
662
- echo "Supabase keys missing before app build; running \${SUPABASE_LOCAL_ENV_SCRIPT}."
698
+ echo "Running local Supabase bootstrap, immutable migrations, generated reconciliation, and schema verification."
663
699
  if ! "\${SUPABASE_LOCAL_ENV_SCRIPT}"; then
664
- echo "Supabase local bootstrap failed before app build; continuing."
700
+ echo "Supabase local bootstrap failed."
701
+ exit 1
665
702
  fi
666
703
  if [[ -f "\${ROOT_DIR}/.env" ]]; then
667
704
  set -a
@@ -738,13 +775,15 @@ esac
738
775
  kubectl apply -k "\${K8S_DIR}"
739
776
 
740
777
  if [[ "\${SUPABASE_SECRET_SYNC_ENABLED}" == "true" ]]; then
741
- if ! has_required_supabase_env && [[ "\${AUTH_RUNTIME_MODE}" == "local" ]]; then
778
+ if ! has_required_supabase_env && [[ "\${AUTH_RUNTIME_MODE}" == "local" && "\${SUPABASE_LOCAL_ENABLED}" == "true" ]]; then
742
779
  if [[ ! -x "\${SUPABASE_LOCAL_ENV_SCRIPT}" ]]; then
743
- echo "Supabase local bootstrap skipped: missing executable \${SUPABASE_LOCAL_ENV_SCRIPT}."
780
+ echo "Supabase local bootstrap failed: missing executable \${SUPABASE_LOCAL_ENV_SCRIPT}."
781
+ exit 1
744
782
  else
745
783
  echo "Supabase keys missing; running \${SUPABASE_LOCAL_ENV_SCRIPT}."
746
784
  if ! "\${SUPABASE_LOCAL_ENV_SCRIPT}"; then
747
- echo "Supabase local bootstrap failed; continuing without local credential import."
785
+ echo "Supabase local bootstrap failed."
786
+ exit 1
748
787
  fi
749
788
  if [[ -f "\${ROOT_DIR}/.env" ]]; then
750
789
  set -a
@@ -980,7 +1019,402 @@ echo "Minikube infrastructure removed."
980
1019
  `;
981
1020
  }
982
1021
 
983
- function getStatusScript(defaultNamespace: string): string {
1022
+ function getSupabaseProfileChecksumSql(profileModel: ResolvedProfileModel): string {
1023
+ return `do $$
1024
+ declare
1025
+ actual_hash text;
1026
+ actual_table_name text;
1027
+ begin
1028
+ if to_regclass('ankhorage_internal.generated_schema_state') is null then
1029
+ raise exception 'missing ankhorage_internal.generated_schema_state';
1030
+ end if;
1031
+
1032
+ select content_hash, table_name
1033
+ into actual_hash, actual_table_name
1034
+ from ankhorage_internal.generated_schema_state
1035
+ where artifact_key = 'auth.profile';
1036
+
1037
+ if actual_hash is null then
1038
+ raise exception 'missing generated schema state for auth.profile';
1039
+ end if;
1040
+
1041
+ if actual_table_name <> '${escapeSqlLiteral(profileModel.table)}' then
1042
+ raise exception 'generated schema state table mismatch for auth.profile: expected %, found %', '${escapeSqlLiteral(profileModel.table)}', actual_table_name;
1043
+ end if;
1044
+
1045
+ if actual_hash <> '${escapeSqlLiteral(profileModel.desiredStateHash)}' then
1046
+ raise exception 'stale generated schema state for auth.profile: expected %, found %', '${escapeSqlLiteral(profileModel.desiredStateHash)}', actual_hash;
1047
+ end if;
1048
+ end;
1049
+ $$;`;
1050
+ }
1051
+
1052
+ function getSupabaseProfileDisabledGuardSql(): string {
1053
+ return `do $$
1054
+ begin
1055
+ if to_regclass('ankhorage_internal.generated_schema_state') is not null
1056
+ and exists (
1057
+ select 1
1058
+ from ankhorage_internal.generated_schema_state
1059
+ where artifact_key = 'auth.profile'
1060
+ ) then
1061
+ 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';
1062
+ end if;
1063
+ end;
1064
+ $$;`;
1065
+ }
1066
+
1067
+ function getSupabaseProfileVerificationSql(profileModel: ResolvedProfileModel): string {
1068
+ const configuredColumns = profileModel.columns.map((column) => column.column);
1069
+ const configuredArray = getSqlTextArray(configuredColumns);
1070
+ const managedArray = getSqlTextArray(MANAGED_PROFILE_COLUMNS);
1071
+ const updateGrantArray = getSqlTextArray(configuredColumns);
1072
+ const protectedUpdateColumnsArray = getSqlTextArray(['id', 'created_at', 'updated_at', 'role']);
1073
+ const table = escapeSqlLiteral(profileModel.table);
1074
+ const selectPolicy = escapeSqlLiteral(`${profileModel.table}_select_own`);
1075
+ const updatePolicy = escapeSqlLiteral(`${profileModel.table}_update_own`);
1076
+ const triggerName = escapeSqlLiteral(`on_auth_user_created_${profileModel.table}`);
1077
+ const functionName = escapeSqlLiteral(`handle_new_${profileModel.table}_user`);
1078
+ const triggerCheck =
1079
+ profileModel.createStrategy === 'trigger'
1080
+ ? ` if not exists (
1081
+ select 1
1082
+ from pg_trigger t
1083
+ join pg_class rel on rel.oid = t.tgrelid
1084
+ join pg_namespace rel_ns on rel_ns.oid = rel.relnamespace
1085
+ join pg_proc fn on fn.oid = t.tgfoid
1086
+ join pg_namespace fn_ns on fn_ns.oid = fn.pronamespace
1087
+ where rel_ns.nspname = 'auth'
1088
+ and rel.relname = 'users'
1089
+ and t.tgname = '${triggerName}'
1090
+ and not t.tgisinternal
1091
+ and t.tgenabled = 'O'
1092
+ and (t.tgtype::integer & 1) = 1
1093
+ and (t.tgtype::integer & 4) = 4
1094
+ and (t.tgtype::integer & (2 | 8 | 16 | 32)) = 0
1095
+ and fn_ns.nspname = 'public'
1096
+ and fn.proname = '${functionName}'
1097
+ ) then
1098
+ raise exception 'missing generated new-user profile trigger';
1099
+ end if;`
1100
+ : ` if exists (
1101
+ select 1
1102
+ from pg_trigger t
1103
+ join pg_class rel on rel.oid = t.tgrelid
1104
+ join pg_namespace rel_ns on rel_ns.oid = rel.relnamespace
1105
+ where rel_ns.nspname = 'auth'
1106
+ and rel.relname = 'users'
1107
+ and t.tgname = '${triggerName}'
1108
+ and not t.tgisinternal
1109
+ ) then
1110
+ raise exception 'generated new-user profile trigger exists but createStrategy is not trigger';
1111
+ end if;`;
1112
+
1113
+ return `do $$
1114
+ declare
1115
+ profile_table constant text := '${table}';
1116
+ configured_columns constant text[] := ${configuredArray};
1117
+ managed_columns constant text[] := ${managedArray};
1118
+ expected_column text;
1119
+ begin
1120
+ if to_regclass('auth.users') is null then
1121
+ raise exception 'missing auth.users';
1122
+ end if;
1123
+
1124
+ if to_regclass('public.users') is not null then
1125
+ raise exception 'reserved conflicting identity table public.users exists';
1126
+ end if;
1127
+
1128
+ if to_regclass(format('public.%I', profile_table)) is null then
1129
+ raise exception 'missing configured profile table public.%', profile_table;
1130
+ end if;
1131
+
1132
+ if not exists (
1133
+ select 1
1134
+ from pg_constraint c
1135
+ where c.conrelid = format('public.%I', profile_table)::regclass
1136
+ and c.contype = 'p'
1137
+ and c.conkey = array[
1138
+ (
1139
+ select a.attnum
1140
+ from pg_attribute a
1141
+ where a.attrelid = format('public.%I', profile_table)::regclass
1142
+ and a.attname = 'id'
1143
+ and not a.attisdropped
1144
+ )
1145
+ ]::smallint[]
1146
+ ) then
1147
+ raise exception 'profile table primary key must be exactly id';
1148
+ end if;
1149
+
1150
+ if not exists (
1151
+ select 1
1152
+ from pg_constraint c
1153
+ where c.conrelid = format('public.%I', profile_table)::regclass
1154
+ and c.confrelid = 'auth.users'::regclass
1155
+ and c.contype = 'f'
1156
+ and c.confdeltype = 'c'
1157
+ and c.conkey = array[
1158
+ (
1159
+ select a.attnum
1160
+ from pg_attribute a
1161
+ where a.attrelid = format('public.%I', profile_table)::regclass
1162
+ and a.attname = 'id'
1163
+ and not a.attisdropped
1164
+ )
1165
+ ]::smallint[]
1166
+ and c.confkey = array[
1167
+ (
1168
+ select a.attnum
1169
+ from pg_attribute a
1170
+ where a.attrelid = 'auth.users'::regclass
1171
+ and a.attname = 'id'
1172
+ and not a.attisdropped
1173
+ )
1174
+ ]::smallint[]
1175
+ ) then
1176
+ raise exception 'profile id must reference auth.users(id) with cascade delete';
1177
+ end if;
1178
+
1179
+ if not exists (
1180
+ select 1
1181
+ from information_schema.columns c
1182
+ where c.table_schema = 'public'
1183
+ and c.table_name = profile_table
1184
+ and c.column_name = 'id'
1185
+ and c.udt_name = 'uuid'
1186
+ and c.is_nullable = 'NO'
1187
+ ) then
1188
+ raise exception 'profile id column must be uuid not null';
1189
+ end if;
1190
+
1191
+ if not exists (
1192
+ select 1
1193
+ from information_schema.columns c
1194
+ where c.table_schema = 'public'
1195
+ and c.table_name = profile_table
1196
+ and c.column_name = 'created_at'
1197
+ and c.data_type = 'timestamp with time zone'
1198
+ and c.is_nullable = 'NO'
1199
+ and c.column_default = 'now()'
1200
+ ) then
1201
+ raise exception 'profile created_at column must be timestamptz not null default now()';
1202
+ end if;
1203
+
1204
+ if not exists (
1205
+ select 1
1206
+ from information_schema.columns c
1207
+ where c.table_schema = 'public'
1208
+ and c.table_name = profile_table
1209
+ and c.column_name = 'updated_at'
1210
+ and c.data_type = 'timestamp with time zone'
1211
+ and c.is_nullable = 'NO'
1212
+ and c.column_default = 'now()'
1213
+ ) then
1214
+ raise exception 'profile updated_at column must be timestamptz not null default now()';
1215
+ end if;
1216
+
1217
+ foreach expected_column in array configured_columns loop
1218
+ if not exists (
1219
+ select 1
1220
+ from information_schema.columns c
1221
+ where c.table_schema = 'public'
1222
+ and c.table_name = profile_table
1223
+ and c.column_name = expected_column
1224
+ and c.data_type = 'text'
1225
+ ) then
1226
+ raise exception 'missing configured managed profile column %', expected_column;
1227
+ end if;
1228
+ end loop;
1229
+
1230
+ foreach expected_column in array managed_columns loop
1231
+ if not expected_column = any(configured_columns) and exists (
1232
+ select 1
1233
+ from information_schema.columns c
1234
+ where c.table_schema = 'public'
1235
+ and c.table_name = profile_table
1236
+ and c.column_name = expected_column
1237
+ ) then
1238
+ raise exception 'stale managed profile column % exists', expected_column;
1239
+ end if;
1240
+ end loop;
1241
+
1242
+ if exists (
1243
+ select 1
1244
+ from information_schema.columns
1245
+ where table_schema = 'public'
1246
+ and table_name = profile_table
1247
+ and column_name = 'role'
1248
+ ) then
1249
+ raise exception 'generated role column must not exist';
1250
+ end if;
1251
+
1252
+ if not exists (
1253
+ select 1
1254
+ from pg_class c
1255
+ join pg_namespace n on n.oid = c.relnamespace
1256
+ where n.nspname = 'public'
1257
+ and c.relname = profile_table
1258
+ and c.relrowsecurity
1259
+ ) then
1260
+ raise exception 'profile table RLS is not enabled';
1261
+ end if;
1262
+
1263
+ if not exists (
1264
+ select 1
1265
+ from pg_policy p
1266
+ join pg_class c on c.oid = p.polrelid
1267
+ join pg_namespace n on n.oid = c.relnamespace
1268
+ where n.nspname = 'public'
1269
+ and c.relname = profile_table
1270
+ and p.polname = '${selectPolicy}'
1271
+ and p.polcmd = 'r'
1272
+ and p.polroles = array['authenticated'::regrole]::oid[]
1273
+ and regexp_replace(lower(pg_get_expr(p.polqual, p.polrelid)), '[[:space:]()]', '', 'g') in (
1274
+ 'auth.uid=id',
1275
+ 'auth.uid=' || profile_table || '.id',
1276
+ 'selectauth.uidasuid=id',
1277
+ 'selectauth.uidasuid=' || profile_table || '.id'
1278
+ )
1279
+ and p.polwithcheck is null
1280
+ ) then
1281
+ raise exception 'own-profile SELECT policy is missing or has unsafe definition';
1282
+ end if;
1283
+
1284
+ if not exists (
1285
+ select 1
1286
+ from pg_policy p
1287
+ join pg_class c on c.oid = p.polrelid
1288
+ join pg_namespace n on n.oid = c.relnamespace
1289
+ where n.nspname = 'public'
1290
+ and c.relname = profile_table
1291
+ and p.polname = '${updatePolicy}'
1292
+ and p.polcmd = 'w'
1293
+ and p.polroles = array['authenticated'::regrole]::oid[]
1294
+ and regexp_replace(lower(pg_get_expr(p.polqual, p.polrelid)), '[[:space:]()]', '', 'g') in (
1295
+ 'auth.uid=id',
1296
+ 'auth.uid=' || profile_table || '.id',
1297
+ 'selectauth.uidasuid=id',
1298
+ 'selectauth.uidasuid=' || profile_table || '.id'
1299
+ )
1300
+ and regexp_replace(lower(pg_get_expr(p.polwithcheck, p.polrelid)), '[[:space:]()]', '', 'g') in (
1301
+ 'auth.uid=id',
1302
+ 'auth.uid=' || profile_table || '.id',
1303
+ 'selectauth.uidasuid=id',
1304
+ 'selectauth.uidasuid=' || profile_table || '.id'
1305
+ )
1306
+ ) then
1307
+ raise exception 'own-profile UPDATE policy is missing or has unsafe definition';
1308
+ end if;
1309
+
1310
+ if exists (
1311
+ select 1
1312
+ from pg_policy p
1313
+ join pg_class c on c.oid = p.polrelid
1314
+ join pg_namespace n on n.oid = c.relnamespace
1315
+ where n.nspname = 'public'
1316
+ and c.relname = profile_table
1317
+ and p.polname not in ('${selectPolicy}', '${updatePolicy}')
1318
+ ) then
1319
+ raise exception 'unexpected profile table RLS policy exists';
1320
+ end if;
1321
+
1322
+ if has_any_column_privilege('anon', format('public.%I', profile_table), 'SELECT')
1323
+ or has_any_column_privilege('anon', format('public.%I', profile_table), 'INSERT')
1324
+ or has_any_column_privilege('anon', format('public.%I', profile_table), 'UPDATE')
1325
+ or has_any_column_privilege('anon', format('public.%I', profile_table), 'REFERENCES')
1326
+ or has_table_privilege('anon', format('public.%I', profile_table), 'DELETE') then
1327
+ raise exception 'anon must not have profile table privileges';
1328
+ end if;
1329
+
1330
+ if not has_table_privilege('authenticated', format('public.%I', profile_table), 'SELECT') then
1331
+ raise exception 'authenticated role must have profile table SELECT privilege';
1332
+ end if;
1333
+
1334
+ if has_any_column_privilege('authenticated', format('public.%I', profile_table), 'INSERT')
1335
+ or has_any_column_privilege('authenticated', format('public.%I', profile_table), 'REFERENCES')
1336
+ or has_table_privilege('authenticated', format('public.%I', profile_table), 'DELETE') then
1337
+ raise exception 'authenticated role must not have profile table INSERT, DELETE, or REFERENCES privilege';
1338
+ end if;
1339
+
1340
+ foreach expected_column in array ${updateGrantArray} loop
1341
+ if not has_column_privilege('authenticated', format('public.%I', profile_table), expected_column, 'UPDATE') then
1342
+ raise exception 'authenticated role is missing UPDATE privilege on configured profile column %', expected_column;
1343
+ end if;
1344
+ end loop;
1345
+
1346
+ foreach expected_column in array (
1347
+ select array_agg(c.column_name::text order by c.ordinal_position)
1348
+ from information_schema.columns c
1349
+ where c.table_schema = 'public'
1350
+ and c.table_name = profile_table
1351
+ ) loop
1352
+ if not expected_column = any(configured_columns)
1353
+ and has_column_privilege('authenticated', format('public.%I', profile_table), expected_column, 'UPDATE') then
1354
+ raise exception 'authenticated role has unexpected UPDATE privilege on profile column %', expected_column;
1355
+ end if;
1356
+ end loop;
1357
+
1358
+ foreach expected_column in array ${protectedUpdateColumnsArray} loop
1359
+ if exists (
1360
+ select 1
1361
+ from information_schema.columns c
1362
+ where c.table_schema = 'public'
1363
+ and c.table_name = profile_table
1364
+ and c.column_name = expected_column
1365
+ ) and has_column_privilege('authenticated', format('public.%I', profile_table), expected_column, 'UPDATE') then
1366
+ raise exception 'authenticated role must not have UPDATE privilege on protected profile column %', expected_column;
1367
+ end if;
1368
+ end loop;
1369
+
1370
+ ${triggerCheck}
1371
+
1372
+ if '${profileModel.createStrategy}' = 'trigger' then
1373
+ if not exists (
1374
+ select 1
1375
+ from pg_proc p
1376
+ join pg_namespace n on n.oid = p.pronamespace
1377
+ where n.nspname = 'public'
1378
+ and p.proname = '${functionName}'
1379
+ and p.prosecdef
1380
+ and p.prorettype = 'trigger'::regtype
1381
+ and p.proconfig @> array['search_path=pg_catalog, pg_temp']
1382
+ ) then
1383
+ raise exception 'generated trigger function is missing required SECURITY DEFINER properties';
1384
+ end if;
1385
+
1386
+ if exists (
1387
+ select 1
1388
+ from pg_proc p
1389
+ join pg_namespace n on n.oid = p.pronamespace
1390
+ cross join lateral aclexplode(coalesce(p.proacl, acldefault('f', p.proowner))) acl
1391
+ where n.nspname = 'public'
1392
+ and p.proname = '${functionName}'
1393
+ and acl.privilege_type = 'EXECUTE'
1394
+ and (
1395
+ acl.grantee = 0
1396
+ or acl.grantee = 'anon'::regrole
1397
+ or acl.grantee = 'authenticated'::regrole
1398
+ )
1399
+ ) then
1400
+ raise exception 'generated trigger function execute privilege must be revoked from PUBLIC, anon, and authenticated';
1401
+ end if;
1402
+ end if;
1403
+ end;
1404
+ $$;`;
1405
+ }
1406
+
1407
+ function getStatusScript(args: {
1408
+ defaultNamespace: string;
1409
+ supabaseLocalEnabled: boolean;
1410
+ profileModel: ResolvedProfileModel;
1411
+ }): string {
1412
+ const { defaultNamespace, supabaseLocalEnabled, profileModel } = args;
1413
+ const profileStatusSql = profileModel.enabled ? getSupabaseProfileChecksumSql(profileModel) : '';
1414
+ const profileVerificationSql = profileModel.enabled
1415
+ ? getSupabaseProfileVerificationSql(profileModel)
1416
+ : '';
1417
+
984
1418
  return `#!/usr/bin/env bash
985
1419
  set -euo pipefail
986
1420
 
@@ -996,7 +1430,11 @@ fi
996
1430
 
997
1431
  NAMESPACE="\${ANKH_NAMESPACE:-${defaultNamespace}}"
998
1432
  AUTH_RUNTIME_MODE="\${AUTH_RUNTIME_MODE:-local}"
1433
+ SUPABASE_LOCAL_ENABLED="\${SUPABASE_LOCAL_ENABLED:-${supabaseLocalEnabled ? 'true' : 'false'}}"
999
1434
  SUPABASE_SECRET_SYNC_ENABLED="\${SUPABASE_SECRET_SYNC_ENABLED:-true}"
1435
+ SUPABASE_PROJECT_DIR="\${SUPABASE_PROJECT_DIR:-\${ROOT_DIR}}"
1436
+ SUPABASE_PROFILE_ENABLED="${profileModel.enabled ? 'true' : 'false'}"
1437
+ SUPABASE_PROFILE_RECONCILE_FILE="\${SUPABASE_PROJECT_DIR}/supabase/generated/auth_profiles.sql"
1000
1438
 
1001
1439
  if ! command -v kubectl >/dev/null 2>&1; then
1002
1440
  echo "kubectl is required but not installed."
@@ -1068,10 +1506,214 @@ if [[ "\${SUPABASE_SECRET_SYNC_ENABLED}" == "true" ]]; then
1068
1506
  else
1069
1507
  echo "- secret/supabase-auth-secrets: skipped (SUPABASE_SECRET_SYNC_ENABLED=false)"
1070
1508
  fi
1509
+
1510
+ version_ge() {
1511
+ local actual="$1"
1512
+ local minimum="$2"
1513
+
1514
+ local actual_major actual_minor actual_patch
1515
+ local minimum_major minimum_minor minimum_patch
1516
+ IFS=. read -r actual_major actual_minor actual_patch <<< "\${actual}"
1517
+ IFS=. read -r minimum_major minimum_minor minimum_patch <<< "\${minimum}"
1518
+
1519
+ actual_major="\${actual_major:-0}"
1520
+ actual_minor="\${actual_minor:-0}"
1521
+ actual_patch="\${actual_patch:-0}"
1522
+ minimum_major="\${minimum_major:-0}"
1523
+ minimum_minor="\${minimum_minor:-0}"
1524
+ minimum_patch="\${minimum_patch:-0}"
1525
+
1526
+ (( actual_major > minimum_major )) && return 0
1527
+ (( actual_major < minimum_major )) && return 1
1528
+ (( actual_minor > minimum_minor )) && return 0
1529
+ (( actual_minor < minimum_minor )) && return 1
1530
+ (( actual_patch >= minimum_patch ))
1531
+ }
1532
+
1533
+ require_supabase_cli_capabilities() {
1534
+ if ! command -v supabase >/dev/null 2>&1; then
1535
+ echo "supabase CLI is required for local schema status checks."
1536
+ echo "Install or upgrade: https://supabase.com/docs/guides/local-development/cli/getting-started"
1537
+ return 1
1538
+ fi
1539
+
1540
+ local version
1541
+ version="$(supabase --version | awk '{print $NF}')"
1542
+ if ! version_ge "\${version}" "2.106.0"; then
1543
+ echo "supabase CLI >= 2.106.0 is required; found \${version}."
1544
+ echo "Upgrade: https://supabase.com/docs/guides/local-development/cli/getting-started"
1545
+ return 1
1546
+ fi
1547
+
1548
+ if ! supabase status --help 2>/dev/null | grep -Fq -- "--workdir"; then
1549
+ echo "supabase CLI does not support required global --workdir flag."
1550
+ return 1
1551
+ fi
1552
+
1553
+ if ! supabase migration up --help 2>/dev/null | grep -Fq -- "--local"; then
1554
+ echo "supabase CLI does not support required migration up --local command."
1555
+ return 1
1556
+ fi
1557
+
1558
+ if ! command -v psql >/dev/null 2>&1; then
1559
+ echo "psql is required for local schema status checks."
1560
+ return 1
1561
+ fi
1562
+ }
1563
+
1564
+ read_env_value() {
1565
+ local source="$1"
1566
+ local key="$2"
1567
+ local line
1568
+ line="$(printf '%s\\n' "\${source}" | awk -v key="\${key}" '
1569
+ $0 ~ "^(export[[:space:]]+)?" key "=" {print}
1570
+ ' | tail -n1)"
1571
+
1572
+ if [[ -z "\${line}" ]]; then
1573
+ echo ""
1574
+ return
1575
+ fi
1576
+
1577
+ line="\${line#export }"
1578
+ line="\${line#\${key}=}"
1579
+ line="\${line%\\"}"
1580
+ line="\${line#\\"}"
1581
+ printf '%s' "\${line}"
1582
+ }
1583
+
1584
+ run_supabase_sql() {
1585
+ local sql="$1"
1586
+ local label="$2"
1587
+ local tmp_file
1588
+ tmp_file="$(mktemp)"
1589
+ printf '%s\\n' "\${sql}" > "\${tmp_file}"
1590
+
1591
+ if psql "\${SUPABASE_DB_URL}" -v ON_ERROR_STOP=1 -q -f "\${tmp_file}" >/dev/null; then
1592
+ rm -f "\${tmp_file}"
1593
+ return 0
1594
+ fi
1595
+
1596
+ rm -f "\${tmp_file}"
1597
+ echo "- \${label}: failed"
1598
+ return 1
1599
+ }
1600
+
1601
+ check_immutable_migrations_applied() {
1602
+ local migrations_dir="\${SUPABASE_PROJECT_DIR}/supabase/migrations"
1603
+ local applied_versions
1604
+ applied_versions="$(psql "\${SUPABASE_DB_URL}" -v ON_ERROR_STOP=1 -Atc "select version from supabase_migrations.schema_migrations" 2>/dev/null || true)"
1605
+
1606
+ if [[ -z "\${applied_versions}" ]]; then
1607
+ applied_versions=""
1608
+ fi
1609
+
1610
+ if [[ ! -d "\${migrations_dir}" ]]; then
1611
+ return 0
1612
+ fi
1613
+
1614
+ local file
1615
+ local version
1616
+ local pending=0
1617
+ for file in "\${migrations_dir}"/*.sql; do
1618
+ [[ -e "\${file}" ]] || continue
1619
+ version="$(basename "\${file}")"
1620
+ version="\${version%%_*}"
1621
+ if ! printf '%s\\n' "\${applied_versions}" | grep -Fxq "\${version}"; then
1622
+ echo "- immutable migrations: pending \${file}"
1623
+ pending=1
1624
+ fi
1625
+ done
1626
+
1627
+ return "\${pending}"
1628
+ }
1629
+
1630
+ if [[ "\${AUTH_RUNTIME_MODE}" == "local" && "\${SUPABASE_LOCAL_ENABLED}" == "true" ]]; then
1631
+ echo
1632
+ echo "Local Supabase database checks:"
1633
+ status_failed=0
1634
+
1635
+ if require_supabase_cli_capabilities; then
1636
+ if supabase --workdir "\${SUPABASE_PROJECT_DIR}" status >/dev/null 2>&1; then
1637
+ STATUS_ENV="$(supabase --workdir "\${SUPABASE_PROJECT_DIR}" status -o env)"
1638
+ SUPABASE_DB_URL="$(read_env_value "\${STATUS_ENV}" DB_URL)"
1639
+ if [[ -z "\${SUPABASE_DB_URL}" ]]; then
1640
+ echo "- immutable migrations: status unavailable (DB_URL missing from supabase status)"
1641
+ status_failed=1
1642
+ elif check_immutable_migrations_applied; then
1643
+ echo "- immutable migrations: applied"
1644
+ else
1645
+ echo "- immutable migrations: pending"
1646
+ status_failed=1
1647
+ fi
1648
+
1649
+ if [[ "\${SUPABASE_PROFILE_ENABLED}" == "true" ]]; then
1650
+ if [[ -f "\${SUPABASE_PROFILE_RECONCILE_FILE}" ]]; then
1651
+ PROFILE_STATUS_SQL=$(cat <<'SQL'
1652
+ ${profileStatusSql}
1653
+ SQL
1654
+ )
1655
+ PROFILE_VERIFY_SQL=$(cat <<'SQL'
1656
+ ${profileVerificationSql}
1657
+ SQL
1658
+ )
1659
+
1660
+ if run_supabase_sql "\${PROFILE_STATUS_SQL}" "profile reconciliation"; then
1661
+ echo "- profile reconciliation: applied, checksum matches"
1662
+ else
1663
+ echo "- profile reconciliation: pending or stale"
1664
+ status_failed=1
1665
+ fi
1666
+
1667
+ if run_supabase_sql "\${PROFILE_VERIFY_SQL}" "profile schema"; then
1668
+ echo "- profile schema: verified"
1669
+ else
1670
+ echo "- profile schema: drift detected"
1671
+ status_failed=1
1672
+ fi
1673
+ else
1674
+ echo "- profile reconciliation: generated file missing at \${SUPABASE_PROFILE_RECONCILE_FILE}"
1675
+ echo "- profile schema: not verified"
1676
+ status_failed=1
1677
+ fi
1678
+ else
1679
+ PROFILE_DISABLED_SQL=$(cat <<'SQL'
1680
+ ${getSupabaseProfileDisabledGuardSql()}
1681
+ SQL
1682
+ )
1683
+ if run_supabase_sql "\${PROFILE_DISABLED_SQL}" "profile disabled-state"; then
1684
+ echo "- profile reconciliation: skipped (no profile table configured)"
1685
+ echo "- profile schema: skipped (no profile table configured)"
1686
+ else
1687
+ echo "- profile reconciliation: stale generated state exists for removed profile table"
1688
+ echo "- profile schema: not verified"
1689
+ status_failed=1
1690
+ fi
1691
+ fi
1692
+ else
1693
+ echo "- local Supabase stack: not running or not reachable from \${SUPABASE_PROJECT_DIR}"
1694
+ status_failed=1
1695
+ fi
1696
+ else
1697
+ status_failed=1
1698
+ fi
1699
+
1700
+ if [[ "\${status_failed}" -ne 0 ]]; then
1701
+ exit "\${status_failed}"
1702
+ fi
1703
+ fi
1071
1704
  `;
1072
1705
  }
1073
1706
 
1074
- function getSupabaseLocalEnvScript(supabaseLocalPorts: SupabaseLocalPorts): string {
1707
+ function getSupabaseLocalEnvScript(args: {
1708
+ supabaseLocalPorts: SupabaseLocalPorts;
1709
+ profileModel: ResolvedProfileModel;
1710
+ }): string {
1711
+ const { supabaseLocalPorts, profileModel } = args;
1712
+ const profileStatusSql = profileModel.enabled ? getSupabaseProfileChecksumSql(profileModel) : '';
1713
+ const profileVerificationSql = profileModel.enabled
1714
+ ? getSupabaseProfileVerificationSql(profileModel)
1715
+ : '';
1716
+
1075
1717
  return `#!/usr/bin/env bash
1076
1718
  set -euo pipefail
1077
1719
 
@@ -1088,8 +1730,10 @@ if [[ -f "\${ENV_FILE}" ]]; then
1088
1730
  fi
1089
1731
 
1090
1732
  APP_SOURCE_DIR="\${APP_SOURCE_DIR:-$(cd "\${ROOT_DIR}/../.." && pwd)}"
1091
- SUPABASE_PROJECT_DIR="\${SUPABASE_PROJECT_DIR:-\${APP_SOURCE_DIR}}"
1733
+ SUPABASE_PROJECT_DIR="\${SUPABASE_PROJECT_DIR:-\${ROOT_DIR}}"
1092
1734
  APP_SUPABASE_ENV_FILE="\${APP_SUPABASE_ENV_FILE:-\${APP_SOURCE_DIR}/.env.local}"
1735
+ SUPABASE_PROFILE_ENABLED="${profileModel.enabled ? 'true' : 'false'}"
1736
+ SUPABASE_PROFILE_RECONCILE_FILE="\${SUPABASE_PROJECT_DIR}/supabase/generated/auth_profiles.sql"
1093
1737
 
1094
1738
  SUPABASE_LOCAL_PORT_BASE="\${SUPABASE_LOCAL_PORT_BASE:-${supabaseLocalPorts.base}}"
1095
1739
  SUPABASE_LOCAL_SHADOW_PORT="\${SUPABASE_LOCAL_SHADOW_PORT:-\${SUPABASE_LOCAL_PORT_BASE}}"
@@ -1105,9 +1749,101 @@ export SUPABASE_LOCAL_DB_PORT
1105
1749
  export SUPABASE_LOCAL_STUDIO_PORT
1106
1750
  export SUPABASE_LOCAL_INBUCKET_PORT
1107
1751
  export SUPABASE_LOCAL_ANALYTICS_PORT
1108
- if ! command -v supabase >/dev/null 2>&1; then
1109
- echo "supabase CLI is required but not installed."
1110
- echo "Install: https://supabase.com/docs/guides/local-development/cli/getting-started"
1752
+ version_ge() {
1753
+ local actual="$1"
1754
+ local minimum="$2"
1755
+
1756
+ local actual_major actual_minor actual_patch
1757
+ local minimum_major minimum_minor minimum_patch
1758
+ IFS=. read -r actual_major actual_minor actual_patch <<< "\${actual}"
1759
+ IFS=. read -r minimum_major minimum_minor minimum_patch <<< "\${minimum}"
1760
+
1761
+ actual_major="\${actual_major:-0}"
1762
+ actual_minor="\${actual_minor:-0}"
1763
+ actual_patch="\${actual_patch:-0}"
1764
+ minimum_major="\${minimum_major:-0}"
1765
+ minimum_minor="\${minimum_minor:-0}"
1766
+ minimum_patch="\${minimum_patch:-0}"
1767
+
1768
+ (( actual_major > minimum_major )) && return 0
1769
+ (( actual_major < minimum_major )) && return 1
1770
+ (( actual_minor > minimum_minor )) && return 0
1771
+ (( actual_minor < minimum_minor )) && return 1
1772
+ (( actual_patch >= minimum_patch ))
1773
+ }
1774
+
1775
+ require_supabase_cli_capabilities() {
1776
+ if ! command -v supabase >/dev/null 2>&1; then
1777
+ echo "supabase CLI is required but not installed."
1778
+ echo "Install or upgrade: https://supabase.com/docs/guides/local-development/cli/getting-started"
1779
+ exit 1
1780
+ fi
1781
+
1782
+ local version
1783
+ version="$(supabase --version | awk '{print $NF}')"
1784
+ if ! version_ge "\${version}" "2.106.0"; then
1785
+ echo "supabase CLI >= 2.106.0 is required; found \${version}."
1786
+ echo "Upgrade: https://supabase.com/docs/guides/local-development/cli/getting-started"
1787
+ exit 1
1788
+ fi
1789
+
1790
+ if ! supabase status --help 2>/dev/null | grep -Fq -- "--workdir"; then
1791
+ echo "supabase CLI does not support required global --workdir flag."
1792
+ echo "Upgrade: https://supabase.com/docs/guides/local-development/cli/getting-started"
1793
+ exit 1
1794
+ fi
1795
+
1796
+ if ! supabase migration up --help 2>/dev/null | grep -Fq -- "--local"; then
1797
+ echo "supabase CLI does not support required migration up --local command."
1798
+ echo "Upgrade: https://supabase.com/docs/guides/local-development/cli/getting-started"
1799
+ exit 1
1800
+ fi
1801
+
1802
+ if ! command -v psql >/dev/null 2>&1; then
1803
+ echo "psql is required but not installed."
1804
+ exit 1
1805
+ fi
1806
+ }
1807
+
1808
+ format_command() {
1809
+ printf '%q ' "$@"
1810
+ }
1811
+
1812
+ run_checked_command() {
1813
+ local label="$1"
1814
+ local sql_file="$2"
1815
+ shift 2
1816
+ local status
1817
+ set +e
1818
+ "$@"
1819
+ status=$?
1820
+ set -e
1821
+
1822
+ if [[ "\${status}" -ne 0 ]]; then
1823
+ echo "\${label} failed."
1824
+ if [[ -n "\${sql_file}" ]]; then
1825
+ echo "SQL file: \${sql_file}"
1826
+ fi
1827
+ echo "Supabase project workdir: \${SUPABASE_PROJECT_DIR}"
1828
+ echo "Command: $(format_command "$@")"
1829
+ echo "Exit status: \${status}"
1830
+ exit "\${status}"
1831
+ fi
1832
+ }
1833
+
1834
+ run_checked_sql_file() {
1835
+ local label="$1"
1836
+ local sql_file="$2"
1837
+ run_checked_command \
1838
+ "\${label}" \
1839
+ "\${sql_file}" \
1840
+ psql "\${SUPABASE_DB_URL}" -v ON_ERROR_STOP=1 -q -f "\${sql_file}"
1841
+ }
1842
+
1843
+ require_supabase_cli_capabilities
1844
+
1845
+ if [[ ! -d "\${SUPABASE_PROJECT_DIR}" ]]; then
1846
+ echo "Supabase project directory does not exist: \${SUPABASE_PROJECT_DIR}"
1111
1847
  exit 1
1112
1848
  fi
1113
1849
 
@@ -1307,28 +2043,72 @@ PY
1307
2043
 
1308
2044
  if [[ ! -f "\${SUPABASE_PROJECT_DIR}/supabase/config.toml" ]]; then
1309
2045
  echo "No supabase/config.toml found in \${SUPABASE_PROJECT_DIR}. Initializing Supabase project..."
1310
- (
1311
- cd "\${SUPABASE_PROJECT_DIR}"
1312
- supabase init --yes >/dev/null
1313
- )
2046
+ supabase --workdir "\${SUPABASE_PROJECT_DIR}" init --yes >/dev/null
1314
2047
  fi
1315
2048
 
1316
2049
  configure_supabase_local_ports
1317
2050
 
1318
2051
  STATUS_ENV="$(
1319
- cd "\${SUPABASE_PROJECT_DIR}"
1320
- supabase status -o env 2>/dev/null || true
2052
+ supabase --workdir "\${SUPABASE_PROJECT_DIR}" status -o env 2>/dev/null || true
1321
2053
  )"
1322
2054
 
2055
+ read_env_value() {
2056
+ local key="$1"
2057
+ local line
2058
+ line="$(printf '%s\\n' "\${STATUS_ENV}" | awk -v key="\${key}" '
2059
+ $0 ~ "^(export[[:space:]]+)?" key "=" {print}
2060
+ ' | tail -n1)"
2061
+
2062
+ if [[ -z "\${line}" ]]; then
2063
+ echo ""
2064
+ return
2065
+ fi
2066
+
2067
+ line="\${line#export }"
2068
+ line="\${line#\${key}=}"
2069
+ line="\${line%\\"}"
2070
+ line="\${line#\\"}"
2071
+ printf '%s' "\${line}"
2072
+ }
2073
+
2074
+ check_immutable_migrations_applied() {
2075
+ local migrations_dir="\${SUPABASE_PROJECT_DIR}/supabase/migrations"
2076
+ local applied_versions
2077
+ applied_versions="$(psql "\${SUPABASE_DB_URL}" -v ON_ERROR_STOP=1 -Atc "select version from supabase_migrations.schema_migrations" 2>/dev/null || true)"
2078
+
2079
+ if [[ -z "\${applied_versions}" ]]; then
2080
+ applied_versions=""
2081
+ fi
2082
+
2083
+ if [[ ! -d "\${migrations_dir}" ]]; then
2084
+ return 0
2085
+ fi
2086
+
2087
+ local file
2088
+ local version
2089
+ local pending=0
2090
+ for file in "\${migrations_dir}"/*.sql; do
2091
+ [[ -e "\${file}" ]] || continue
2092
+ version="$(basename "\${file}")"
2093
+ version="\${version%%_*}"
2094
+ if ! printf '%s\\n' "\${applied_versions}" | grep -Fxq "\${version}"; then
2095
+ echo "Pending immutable Supabase migration: \${file}"
2096
+ pending=1
2097
+ fi
2098
+ done
2099
+
2100
+ return "\${pending}"
2101
+ }
2102
+
1323
2103
  start_supabase_local_stack() {
1324
- if supabase start >/dev/null; then
2104
+ if supabase --workdir "\${SUPABASE_PROJECT_DIR}" start >/dev/null; then
1325
2105
  return 0
1326
2106
  fi
1327
2107
 
1328
2108
  echo "Supabase local start failed. Stopping stale local stack and retrying once..."
1329
- supabase stop --no-backup >/dev/null 2>&1 || true
2109
+ supabase --workdir "\${SUPABASE_PROJECT_DIR}" stop --no-backup >/dev/null 2>&1 || true
1330
2110
 
1331
- if supabase start >/dev/null; then
2111
+ if supabase --workdir "\${SUPABASE_PROJECT_DIR}" start >/dev/null; then
1332
2112
  return 0
1333
2113
  fi
1334
2114
 
@@ -1339,35 +2119,73 @@ start_supabase_local_stack() {
1339
2119
  if [[ -z "\${STATUS_ENV}" ]]; then
1340
2120
  assert_supabase_local_ports_available
1341
2121
  echo "Supabase local stack not detected. Starting with 'supabase start'..."
1342
- (
1343
- cd "\${SUPABASE_PROJECT_DIR}"
1344
- start_supabase_local_stack
1345
- )
1346
-
1347
- STATUS_ENV="$(
1348
- cd "\${SUPABASE_PROJECT_DIR}"
1349
- supabase status -o env
1350
- )"
2122
+ start_supabase_local_stack
2123
+ STATUS_ENV="$(supabase --workdir "\${SUPABASE_PROJECT_DIR}" status -o env)"
1351
2124
  fi
1352
2125
 
1353
- read_env_value() {
1354
- local key="$1"
1355
- local line
1356
- line="$(printf '%s\\n' "\${STATUS_ENV}" | awk -v key="\${key}" '
1357
- $0 ~ "^(export[[:space:]]+)?" key "=" {print}
1358
- ' | tail -n1)"
2126
+ SUPABASE_DB_URL="$(read_env_value DB_URL)"
2127
+ if [[ -z "\${SUPABASE_DB_URL}" ]]; then
2128
+ echo "Unable to read DB_URL from 'supabase status -o env'."
2129
+ exit 1
2130
+ fi
1359
2131
 
1360
- if [[ -z "\${line}" ]]; then
1361
- echo ""
1362
- return
2132
+ echo "Applying pending immutable Supabase migrations..."
2133
+ run_checked_command \
2134
+ "Immutable Supabase migration application" \
2135
+ "" \
2136
+ supabase --workdir "\${SUPABASE_PROJECT_DIR}" migration up --local
2137
+
2138
+ if ! check_immutable_migrations_applied; then
2139
+ echo "Immutable Supabase migration application did not apply every local migration."
2140
+ exit 1
2141
+ fi
2142
+
2143
+ if [[ "\${SUPABASE_PROFILE_ENABLED}" == "true" ]]; then
2144
+ if [[ ! -f "\${SUPABASE_PROFILE_RECONCILE_FILE}" ]]; then
2145
+ echo "Generated profile reconciliation file is missing: \${SUPABASE_PROFILE_RECONCILE_FILE}"
2146
+ exit 1
1363
2147
  fi
1364
2148
 
1365
- line="\${line#export }"
1366
- line="\${line#\${key}=}"
1367
- line="\${line%\\"}"
1368
- line="\${line#\\"}"
1369
- printf '%s' "\${line}"
1370
- }
2149
+ echo "Applying generated profile reconciliation..."
2150
+ run_checked_sql_file \
2151
+ "Generated profile reconciliation" \
2152
+ "\${SUPABASE_PROFILE_RECONCILE_FILE}"
2153
+
2154
+ PROFILE_STATUS_SQL=$(cat <<'SQL'
2155
+ ${profileStatusSql}
2156
+ SQL
2157
+ )
2158
+ PROFILE_VERIFY_SQL=$(cat <<'SQL'
2159
+ ${profileVerificationSql}
2160
+ SQL
2161
+ )
2162
+
2163
+ PROFILE_STATUS_FILE="$(mktemp)"
2164
+ PROFILE_VERIFY_FILE="$(mktemp)"
2165
+ printf '%s\\n' "\${PROFILE_STATUS_SQL}" > "\${PROFILE_STATUS_FILE}"
2166
+ printf '%s\\n' "\${PROFILE_VERIFY_SQL}" > "\${PROFILE_VERIFY_FILE}"
2167
+
2168
+ run_checked_sql_file \
2169
+ "Generated profile reconciliation checksum verification" \
2170
+ "\${PROFILE_STATUS_FILE}"
2171
+ run_checked_sql_file \
2172
+ "Generated profile schema verification" \
2173
+ "\${PROFILE_VERIFY_FILE}"
2174
+
2175
+ rm -f "\${PROFILE_STATUS_FILE}" "\${PROFILE_VERIFY_FILE}"
2176
+ echo "Generated profile reconciliation applied and schema verified."
2177
+ else
2178
+ PROFILE_DISABLED_FILE="$(mktemp)"
2179
+ cat > "\${PROFILE_DISABLED_FILE}" <<'SQL'
2180
+ ${getSupabaseProfileDisabledGuardSql()}
2181
+ SQL
2182
+ run_checked_sql_file \
2183
+ "Generated profile disabled-state verification" \
2184
+ "\${PROFILE_DISABLED_FILE}"
2185
+ rm -f "\${PROFILE_DISABLED_FILE}"
2186
+ fi
2187
+
2188
+ STATUS_ENV="$(supabase --workdir "\${SUPABASE_PROJECT_DIR}" status -o env)"
1371
2189
 
1372
2190
  API_URL="$(read_env_value API_URL)"
1373
2191
  ANON_KEY="$(read_env_value ANON_KEY)"
@@ -1476,3 +2294,11 @@ function hashProjectId(value: string): number {
1476
2294
 
1477
2295
  return hash;
1478
2296
  }
2297
+
2298
+ function getSqlTextArray(values: readonly string[]): string {
2299
+ return `array[${values.map((value) => `'${escapeSqlLiteral(value)}'`).join(', ')}]::text[]`;
2300
+ }
2301
+
2302
+ function escapeSqlLiteral(value: string): string {
2303
+ return value.replaceAll("'", "''");
2304
+ }