@ankhorage/infra 0.3.1 → 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.
Files changed (32) hide show
  1. package/CHANGELOG.md +12 -0
  2. package/README.md +2 -2
  3. package/dist/adapters/minikube/auth/supabase/index.d.ts.map +1 -1
  4. package/dist/adapters/minikube/auth/supabase/index.js +12 -161
  5. package/dist/adapters/minikube/auth/supabase/index.js.map +1 -1
  6. package/dist/adapters/minikube/auth/supabase/profile.d.ts +21 -0
  7. package/dist/adapters/minikube/auth/supabase/profile.d.ts.map +1 -0
  8. package/dist/adapters/minikube/auth/supabase/profile.js +452 -0
  9. package/dist/adapters/minikube/auth/supabase/profile.js.map +1 -0
  10. package/dist/adapters/minikube/base/index.d.ts.map +1 -1
  11. package/dist/adapters/minikube/base/index.js +849 -53
  12. package/dist/adapters/minikube/base/index.js.map +1 -1
  13. package/dist/{cli.d.ts → cli/bin.d.ts} +3 -3
  14. package/dist/cli/bin.d.ts.map +1 -0
  15. package/dist/{cli.js → cli/bin.js} +3 -3
  16. package/dist/cli/bin.js.map +1 -0
  17. package/dist/readme-usage.js +1 -1
  18. package/dist/readme-usage.js.map +1 -1
  19. package/package.json +2 -2
  20. package/src/adapters/minikube/auth/supabase/index.test.ts +152 -18
  21. package/src/adapters/minikube/auth/supabase/index.ts +16 -203
  22. package/src/adapters/minikube/auth/supabase/profile.integration.test.ts +860 -0
  23. package/src/adapters/minikube/auth/supabase/profile.ts +526 -0
  24. package/src/adapters/minikube/base/index.test.ts +100 -6
  25. package/src/adapters/minikube/base/index.ts +879 -53
  26. package/src/{cli.ts → cli/bin.ts} +3 -3
  27. package/src/{cli.test.ts → cli/cli.test.ts} +3 -3
  28. package/src/index.test.ts +13 -2
  29. package/src/package.test.ts +1 -1
  30. package/src/readme-usage.ts +1 -1
  31. package/dist/cli.d.ts.map +0 -1
  32. package/dist/cli.js.map +0 -1
@@ -1,3 +1,4 @@
1
+ import { MANAGED_PROFILE_COLUMNS, resolveSupabaseProfileModel, } from '../auth/supabase/profile';
1
2
  const SUPABASE_LOCAL_PORT_BASE = 55320;
2
3
  const SUPABASE_LOCAL_PORT_BUCKET_SIZE = 10;
3
4
  const SUPABASE_LOCAL_PORT_BUCKET_COUNT = 1000;
@@ -15,8 +16,10 @@ export function generateMinikubeBaseArtifacts(args) {
15
16
  const authzEngine = manifest.auth?.authorization.engine ?? 'none';
16
17
  const databaseProvider = manifest.database?.provider ?? 'none';
17
18
  const storageMetadata = resolveAppInfraStorageMetadata(manifest);
19
+ const profileModel = resolveSupabaseProfileModel(manifest);
18
20
  const monitoringEnabled = manifest.deployment?.monitoring === true;
19
21
  const domain = manifest.networking?.domain ?? '';
22
+ const supabaseLocalEnabled = authProvider === 'supabase' || databaseProvider === 'supabase' || storageMetadata !== null;
20
23
  return [
21
24
  {
22
25
  path: `${root}/README.md`,
@@ -28,6 +31,8 @@ export function generateMinikubeBaseArtifacts(args) {
28
31
  authzEngine,
29
32
  extraResources,
30
33
  supabaseLocalPorts,
34
+ supabaseLocalEnabled,
35
+ profileModel,
31
36
  }),
32
37
  },
33
38
  {
@@ -38,6 +43,7 @@ export function generateMinikubeBaseArtifacts(args) {
38
43
  extraEnvEntries,
39
44
  defaultAppImage,
40
45
  supabaseLocalPorts,
46
+ supabaseLocalEnabled,
41
47
  }),
42
48
  },
43
49
  {
@@ -70,7 +76,11 @@ export function generateMinikubeBaseArtifacts(args) {
70
76
  },
71
77
  {
72
78
  path: `${scriptsRoot}/up.sh`,
73
- content: getUpScript({ defaultNamespace: namespace, defaultAppImage }),
79
+ content: getUpScript({
80
+ defaultNamespace: namespace,
81
+ defaultAppImage,
82
+ supabaseLocalEnabled,
83
+ }),
74
84
  executable: true,
75
85
  },
76
86
  {
@@ -90,12 +100,19 @@ export function generateMinikubeBaseArtifacts(args) {
90
100
  },
91
101
  {
92
102
  path: `${scriptsRoot}/status.sh`,
93
- content: getStatusScript(namespace),
103
+ content: getStatusScript({
104
+ defaultNamespace: namespace,
105
+ supabaseLocalEnabled,
106
+ profileModel,
107
+ }),
94
108
  executable: true,
95
109
  },
96
110
  {
97
111
  path: `${scriptsRoot}/supabase-local-env.sh`,
98
- content: getSupabaseLocalEnvScript(supabaseLocalPorts),
112
+ content: getSupabaseLocalEnvScript({
113
+ supabaseLocalPorts,
114
+ profileModel,
115
+ }),
99
116
  executable: true,
100
117
  },
101
118
  {
@@ -105,7 +122,7 @@ export function generateMinikubeBaseArtifacts(args) {
105
122
  ];
106
123
  }
107
124
  function getReadmeMarkdown(args) {
108
- const { namespace, defaultAppImage, monitoringEnabled, authProvider, authzEngine, extraResources, supabaseLocalPorts, } = args;
125
+ const { namespace, defaultAppImage, monitoringEnabled, authProvider, authzEngine, extraResources, supabaseLocalPorts, supabaseLocalEnabled, profileModel, } = args;
109
126
  const resourceLines = [
110
127
  'namespace.yaml',
111
128
  'app.configmap.yaml',
@@ -254,9 +271,11 @@ Override with:
254
271
  Build flow:
255
272
 
256
273
  - \`./scripts/up.sh\` calls \`./scripts/build-app-image.sh\` when \`APP_BUILD_ENABLED=true\`.
257
- - \`./scripts/supabase-local-env.sh\` can populate Supabase keys in \`.env\` from local \`supabase status -o env\`.
274
+ - Supabase local project root: \`infra/minikube\`${supabaseLocalEnabled ? '' : ' (unused unless Supabase-backed auth/database/storage is enabled)'}.
275
+ - Immutable migrations live in \`supabase/migrations/\` and are applied with \`supabase migration up --local\`.
276
+ ${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\`.
258
277
  - \`./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.
259
- - With \`AUTH_RUNTIME_MODE=local\` (default), \`up.sh\` auto-runs \`supabase-local-env.sh\` when Supabase keys are missing.
278
+ - With \`AUTH_RUNTIME_MODE=local\` (default), \`up.sh\` runs \`supabase-local-env.sh\` before app/Kubernetes startup when Supabase-backed local services are enabled.
260
279
  - \`supabase-local-env.sh\` checks configured host ports before starting a missing local Supabase stack.
261
280
  - \`build-app-image.sh\` runs \`bunx expo export --platform web\` from app source.
262
281
  - \`app-image/Dockerfile\` builds an nginx image from exported web artifacts.
@@ -287,6 +306,7 @@ function getEnvExample(args) {
287
306
  'APP_BUILD_ENABLED=true',
288
307
  'APP_SOURCE_DIR=',
289
308
  'APP_WEB_EXPORT_DIR=.ankh/web-export',
309
+ '# Canonical Supabase project root. Leave empty to use infra/minikube.',
290
310
  'SUPABASE_PROJECT_DIR=',
291
311
  `SUPABASE_LOCAL_PORT_BASE=${supabaseLocalPorts.base}`,
292
312
  `SUPABASE_LOCAL_SHADOW_PORT=${supabaseLocalPorts.shadow}`,
@@ -489,7 +509,7 @@ spec:
489
509
  `;
490
510
  }
491
511
  function getUpScript(args) {
492
- const { defaultNamespace, defaultAppImage } = args;
512
+ const { defaultNamespace, defaultAppImage, supabaseLocalEnabled } = args;
493
513
  return `#!/usr/bin/env bash
494
514
  set -euo pipefail
495
515
 
@@ -521,6 +541,7 @@ APP_IMAGE_PULL_SECRET_USERNAME="\${APP_IMAGE_PULL_SECRET_USERNAME:-}"
521
541
  APP_IMAGE_PULL_SECRET_PASSWORD="\${APP_IMAGE_PULL_SECRET_PASSWORD:-}"
522
542
  APP_IMAGE_PULL_SECRET_EMAIL="\${APP_IMAGE_PULL_SECRET_EMAIL:-}"
523
543
  AUTH_RUNTIME_MODE="\${AUTH_RUNTIME_MODE:-local}"
544
+ SUPABASE_LOCAL_ENABLED="\${SUPABASE_LOCAL_ENABLED:-${supabaseLocalEnabled ? 'true' : 'false'}}"
524
545
  SUPABASE_SECRET_SYNC_ENABLED="\${SUPABASE_SECRET_SYNC_ENABLED:-true}"
525
546
  SUPABASE_LOCAL_ENV_SCRIPT="\${SCRIPT_DIR}/supabase-local-env.sh"
526
547
  SUPABASE_URL="\${SUPABASE_URL:-}"
@@ -565,13 +586,15 @@ if [[ "\${HOST_STATUS}" != "Running" ]]; then
565
586
  minikube start -p "\${PROFILE}" --driver="\${DRIVER}"
566
587
  fi
567
588
 
568
- if ! has_required_supabase_env && [[ "\${AUTH_RUNTIME_MODE}" == "local" ]]; then
589
+ if [[ "\${AUTH_RUNTIME_MODE}" == "local" && "\${SUPABASE_LOCAL_ENABLED}" == "true" ]]; then
569
590
  if [[ ! -x "\${SUPABASE_LOCAL_ENV_SCRIPT}" ]]; then
570
- echo "Supabase local bootstrap skipped before app build: missing executable \${SUPABASE_LOCAL_ENV_SCRIPT}."
591
+ echo "Supabase local bootstrap failed: missing executable \${SUPABASE_LOCAL_ENV_SCRIPT}."
592
+ exit 1
571
593
  else
572
- echo "Supabase keys missing before app build; running \${SUPABASE_LOCAL_ENV_SCRIPT}."
594
+ echo "Running local Supabase bootstrap, immutable migrations, generated reconciliation, and schema verification."
573
595
  if ! "\${SUPABASE_LOCAL_ENV_SCRIPT}"; then
574
- echo "Supabase local bootstrap failed before app build; continuing."
596
+ echo "Supabase local bootstrap failed."
597
+ exit 1
575
598
  fi
576
599
  if [[ -f "\${ROOT_DIR}/.env" ]]; then
577
600
  set -a
@@ -648,13 +671,15 @@ esac
648
671
  kubectl apply -k "\${K8S_DIR}"
649
672
 
650
673
  if [[ "\${SUPABASE_SECRET_SYNC_ENABLED}" == "true" ]]; then
651
- if ! has_required_supabase_env && [[ "\${AUTH_RUNTIME_MODE}" == "local" ]]; then
674
+ if ! has_required_supabase_env && [[ "\${AUTH_RUNTIME_MODE}" == "local" && "\${SUPABASE_LOCAL_ENABLED}" == "true" ]]; then
652
675
  if [[ ! -x "\${SUPABASE_LOCAL_ENV_SCRIPT}" ]]; then
653
- echo "Supabase local bootstrap skipped: missing executable \${SUPABASE_LOCAL_ENV_SCRIPT}."
676
+ echo "Supabase local bootstrap failed: missing executable \${SUPABASE_LOCAL_ENV_SCRIPT}."
677
+ exit 1
654
678
  else
655
679
  echo "Supabase keys missing; running \${SUPABASE_LOCAL_ENV_SCRIPT}."
656
680
  if ! "\${SUPABASE_LOCAL_ENV_SCRIPT}"; then
657
- echo "Supabase local bootstrap failed; continuing without local credential import."
681
+ echo "Supabase local bootstrap failed."
682
+ exit 1
658
683
  fi
659
684
  if [[ -f "\${ROOT_DIR}/.env" ]]; then
660
685
  set -a
@@ -881,7 +906,392 @@ kubectl delete -k "\${K8S_DIR}" --ignore-not-found
881
906
  echo "Minikube infrastructure removed."
882
907
  `;
883
908
  }
884
- function getStatusScript(defaultNamespace) {
909
+ function getSupabaseProfileChecksumSql(profileModel) {
910
+ return `do $$
911
+ declare
912
+ actual_hash text;
913
+ actual_table_name text;
914
+ begin
915
+ if to_regclass('ankhorage_internal.generated_schema_state') is null then
916
+ raise exception 'missing ankhorage_internal.generated_schema_state';
917
+ end if;
918
+
919
+ select content_hash, table_name
920
+ into actual_hash, actual_table_name
921
+ from ankhorage_internal.generated_schema_state
922
+ where artifact_key = 'auth.profile';
923
+
924
+ if actual_hash is null then
925
+ raise exception 'missing generated schema state for auth.profile';
926
+ end if;
927
+
928
+ if actual_table_name <> '${escapeSqlLiteral(profileModel.table)}' then
929
+ raise exception 'generated schema state table mismatch for auth.profile: expected %, found %', '${escapeSqlLiteral(profileModel.table)}', actual_table_name;
930
+ end if;
931
+
932
+ if actual_hash <> '${escapeSqlLiteral(profileModel.desiredStateHash)}' then
933
+ raise exception 'stale generated schema state for auth.profile: expected %, found %', '${escapeSqlLiteral(profileModel.desiredStateHash)}', actual_hash;
934
+ end if;
935
+ end;
936
+ $$;`;
937
+ }
938
+ function getSupabaseProfileDisabledGuardSql() {
939
+ return `do $$
940
+ begin
941
+ if to_regclass('ankhorage_internal.generated_schema_state') is not null
942
+ and exists (
943
+ select 1
944
+ from ankhorage_internal.generated_schema_state
945
+ where artifact_key = 'auth.profile'
946
+ ) then
947
+ 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';
948
+ end if;
949
+ end;
950
+ $$;`;
951
+ }
952
+ function getSupabaseProfileVerificationSql(profileModel) {
953
+ const configuredColumns = profileModel.columns.map((column) => column.column);
954
+ const configuredArray = getSqlTextArray(configuredColumns);
955
+ const managedArray = getSqlTextArray(MANAGED_PROFILE_COLUMNS);
956
+ const updateGrantArray = getSqlTextArray(configuredColumns);
957
+ const protectedUpdateColumnsArray = getSqlTextArray(['id', 'created_at', 'updated_at', 'role']);
958
+ const table = escapeSqlLiteral(profileModel.table);
959
+ const selectPolicy = escapeSqlLiteral(`${profileModel.table}_select_own`);
960
+ const updatePolicy = escapeSqlLiteral(`${profileModel.table}_update_own`);
961
+ const triggerName = escapeSqlLiteral(`on_auth_user_created_${profileModel.table}`);
962
+ const functionName = escapeSqlLiteral(`handle_new_${profileModel.table}_user`);
963
+ const triggerCheck = profileModel.createStrategy === 'trigger'
964
+ ? ` if not exists (
965
+ select 1
966
+ from pg_trigger t
967
+ join pg_class rel on rel.oid = t.tgrelid
968
+ join pg_namespace rel_ns on rel_ns.oid = rel.relnamespace
969
+ join pg_proc fn on fn.oid = t.tgfoid
970
+ join pg_namespace fn_ns on fn_ns.oid = fn.pronamespace
971
+ where rel_ns.nspname = 'auth'
972
+ and rel.relname = 'users'
973
+ and t.tgname = '${triggerName}'
974
+ and not t.tgisinternal
975
+ and t.tgenabled = 'O'
976
+ and (t.tgtype::integer & 1) = 1
977
+ and (t.tgtype::integer & 4) = 4
978
+ and (t.tgtype::integer & (2 | 8 | 16 | 32)) = 0
979
+ and fn_ns.nspname = 'public'
980
+ and fn.proname = '${functionName}'
981
+ ) then
982
+ raise exception 'missing generated new-user profile trigger';
983
+ end if;`
984
+ : ` if exists (
985
+ select 1
986
+ from pg_trigger t
987
+ join pg_class rel on rel.oid = t.tgrelid
988
+ join pg_namespace rel_ns on rel_ns.oid = rel.relnamespace
989
+ where rel_ns.nspname = 'auth'
990
+ and rel.relname = 'users'
991
+ and t.tgname = '${triggerName}'
992
+ and not t.tgisinternal
993
+ ) then
994
+ raise exception 'generated new-user profile trigger exists but createStrategy is not trigger';
995
+ end if;`;
996
+ return `do $$
997
+ declare
998
+ profile_table constant text := '${table}';
999
+ configured_columns constant text[] := ${configuredArray};
1000
+ managed_columns constant text[] := ${managedArray};
1001
+ expected_column text;
1002
+ begin
1003
+ if to_regclass('auth.users') is null then
1004
+ raise exception 'missing auth.users';
1005
+ end if;
1006
+
1007
+ if to_regclass('public.users') is not null then
1008
+ raise exception 'reserved conflicting identity table public.users exists';
1009
+ end if;
1010
+
1011
+ if to_regclass(format('public.%I', profile_table)) is null then
1012
+ raise exception 'missing configured profile table public.%', profile_table;
1013
+ end if;
1014
+
1015
+ if not exists (
1016
+ select 1
1017
+ from pg_constraint c
1018
+ where c.conrelid = format('public.%I', profile_table)::regclass
1019
+ and c.contype = 'p'
1020
+ and c.conkey = array[
1021
+ (
1022
+ select a.attnum
1023
+ from pg_attribute a
1024
+ where a.attrelid = format('public.%I', profile_table)::regclass
1025
+ and a.attname = 'id'
1026
+ and not a.attisdropped
1027
+ )
1028
+ ]::smallint[]
1029
+ ) then
1030
+ raise exception 'profile table primary key must be exactly id';
1031
+ end if;
1032
+
1033
+ if not exists (
1034
+ select 1
1035
+ from pg_constraint c
1036
+ where c.conrelid = format('public.%I', profile_table)::regclass
1037
+ and c.confrelid = 'auth.users'::regclass
1038
+ and c.contype = 'f'
1039
+ and c.confdeltype = 'c'
1040
+ and c.conkey = array[
1041
+ (
1042
+ select a.attnum
1043
+ from pg_attribute a
1044
+ where a.attrelid = format('public.%I', profile_table)::regclass
1045
+ and a.attname = 'id'
1046
+ and not a.attisdropped
1047
+ )
1048
+ ]::smallint[]
1049
+ and c.confkey = array[
1050
+ (
1051
+ select a.attnum
1052
+ from pg_attribute a
1053
+ where a.attrelid = 'auth.users'::regclass
1054
+ and a.attname = 'id'
1055
+ and not a.attisdropped
1056
+ )
1057
+ ]::smallint[]
1058
+ ) then
1059
+ raise exception 'profile id must reference auth.users(id) with cascade delete';
1060
+ end if;
1061
+
1062
+ if not exists (
1063
+ select 1
1064
+ from information_schema.columns c
1065
+ where c.table_schema = 'public'
1066
+ and c.table_name = profile_table
1067
+ and c.column_name = 'id'
1068
+ and c.udt_name = 'uuid'
1069
+ and c.is_nullable = 'NO'
1070
+ ) then
1071
+ raise exception 'profile id column must be uuid not null';
1072
+ end if;
1073
+
1074
+ if not exists (
1075
+ select 1
1076
+ from information_schema.columns c
1077
+ where c.table_schema = 'public'
1078
+ and c.table_name = profile_table
1079
+ and c.column_name = 'created_at'
1080
+ and c.data_type = 'timestamp with time zone'
1081
+ and c.is_nullable = 'NO'
1082
+ and c.column_default = 'now()'
1083
+ ) then
1084
+ raise exception 'profile created_at column must be timestamptz not null default now()';
1085
+ end if;
1086
+
1087
+ if not exists (
1088
+ select 1
1089
+ from information_schema.columns c
1090
+ where c.table_schema = 'public'
1091
+ and c.table_name = profile_table
1092
+ and c.column_name = 'updated_at'
1093
+ and c.data_type = 'timestamp with time zone'
1094
+ and c.is_nullable = 'NO'
1095
+ and c.column_default = 'now()'
1096
+ ) then
1097
+ raise exception 'profile updated_at column must be timestamptz not null default now()';
1098
+ end if;
1099
+
1100
+ foreach expected_column in array configured_columns loop
1101
+ if not exists (
1102
+ select 1
1103
+ from information_schema.columns c
1104
+ where c.table_schema = 'public'
1105
+ and c.table_name = profile_table
1106
+ and c.column_name = expected_column
1107
+ and c.data_type = 'text'
1108
+ ) then
1109
+ raise exception 'missing configured managed profile column %', expected_column;
1110
+ end if;
1111
+ end loop;
1112
+
1113
+ foreach expected_column in array managed_columns loop
1114
+ if not expected_column = any(configured_columns) and exists (
1115
+ select 1
1116
+ from information_schema.columns c
1117
+ where c.table_schema = 'public'
1118
+ and c.table_name = profile_table
1119
+ and c.column_name = expected_column
1120
+ ) then
1121
+ raise exception 'stale managed profile column % exists', expected_column;
1122
+ end if;
1123
+ end loop;
1124
+
1125
+ if exists (
1126
+ select 1
1127
+ from information_schema.columns
1128
+ where table_schema = 'public'
1129
+ and table_name = profile_table
1130
+ and column_name = 'role'
1131
+ ) then
1132
+ raise exception 'generated role column must not exist';
1133
+ end if;
1134
+
1135
+ if not exists (
1136
+ select 1
1137
+ from pg_class c
1138
+ join pg_namespace n on n.oid = c.relnamespace
1139
+ where n.nspname = 'public'
1140
+ and c.relname = profile_table
1141
+ and c.relrowsecurity
1142
+ ) then
1143
+ raise exception 'profile table RLS is not enabled';
1144
+ end if;
1145
+
1146
+ if not exists (
1147
+ select 1
1148
+ from pg_policy p
1149
+ join pg_class c on c.oid = p.polrelid
1150
+ join pg_namespace n on n.oid = c.relnamespace
1151
+ where n.nspname = 'public'
1152
+ and c.relname = profile_table
1153
+ and p.polname = '${selectPolicy}'
1154
+ and p.polcmd = 'r'
1155
+ and p.polroles = array['authenticated'::regrole]::oid[]
1156
+ and regexp_replace(lower(pg_get_expr(p.polqual, p.polrelid)), '[[:space:]()]', '', 'g') in (
1157
+ 'auth.uid=id',
1158
+ 'auth.uid=' || profile_table || '.id',
1159
+ 'selectauth.uidasuid=id',
1160
+ 'selectauth.uidasuid=' || profile_table || '.id'
1161
+ )
1162
+ and p.polwithcheck is null
1163
+ ) then
1164
+ raise exception 'own-profile SELECT policy is missing or has unsafe definition';
1165
+ end if;
1166
+
1167
+ if not exists (
1168
+ select 1
1169
+ from pg_policy p
1170
+ join pg_class c on c.oid = p.polrelid
1171
+ join pg_namespace n on n.oid = c.relnamespace
1172
+ where n.nspname = 'public'
1173
+ and c.relname = profile_table
1174
+ and p.polname = '${updatePolicy}'
1175
+ and p.polcmd = 'w'
1176
+ and p.polroles = array['authenticated'::regrole]::oid[]
1177
+ and regexp_replace(lower(pg_get_expr(p.polqual, p.polrelid)), '[[:space:]()]', '', 'g') in (
1178
+ 'auth.uid=id',
1179
+ 'auth.uid=' || profile_table || '.id',
1180
+ 'selectauth.uidasuid=id',
1181
+ 'selectauth.uidasuid=' || profile_table || '.id'
1182
+ )
1183
+ and regexp_replace(lower(pg_get_expr(p.polwithcheck, p.polrelid)), '[[:space:]()]', '', 'g') in (
1184
+ 'auth.uid=id',
1185
+ 'auth.uid=' || profile_table || '.id',
1186
+ 'selectauth.uidasuid=id',
1187
+ 'selectauth.uidasuid=' || profile_table || '.id'
1188
+ )
1189
+ ) then
1190
+ raise exception 'own-profile UPDATE policy is missing or has unsafe definition';
1191
+ end if;
1192
+
1193
+ if exists (
1194
+ select 1
1195
+ from pg_policy p
1196
+ join pg_class c on c.oid = p.polrelid
1197
+ join pg_namespace n on n.oid = c.relnamespace
1198
+ where n.nspname = 'public'
1199
+ and c.relname = profile_table
1200
+ and p.polname not in ('${selectPolicy}', '${updatePolicy}')
1201
+ ) then
1202
+ raise exception 'unexpected profile table RLS policy exists';
1203
+ end if;
1204
+
1205
+ if has_any_column_privilege('anon', format('public.%I', profile_table), 'SELECT')
1206
+ or has_any_column_privilege('anon', format('public.%I', profile_table), 'INSERT')
1207
+ or has_any_column_privilege('anon', format('public.%I', profile_table), 'UPDATE')
1208
+ or has_any_column_privilege('anon', format('public.%I', profile_table), 'REFERENCES')
1209
+ or has_table_privilege('anon', format('public.%I', profile_table), 'DELETE') then
1210
+ raise exception 'anon must not have profile table privileges';
1211
+ end if;
1212
+
1213
+ if not has_table_privilege('authenticated', format('public.%I', profile_table), 'SELECT') then
1214
+ raise exception 'authenticated role must have profile table SELECT privilege';
1215
+ end if;
1216
+
1217
+ if has_any_column_privilege('authenticated', format('public.%I', profile_table), 'INSERT')
1218
+ or has_any_column_privilege('authenticated', format('public.%I', profile_table), 'REFERENCES')
1219
+ or has_table_privilege('authenticated', format('public.%I', profile_table), 'DELETE') then
1220
+ raise exception 'authenticated role must not have profile table INSERT, DELETE, or REFERENCES privilege';
1221
+ end if;
1222
+
1223
+ foreach expected_column in array ${updateGrantArray} loop
1224
+ if not has_column_privilege('authenticated', format('public.%I', profile_table), expected_column, 'UPDATE') then
1225
+ raise exception 'authenticated role is missing UPDATE privilege on configured profile column %', expected_column;
1226
+ end if;
1227
+ end loop;
1228
+
1229
+ foreach expected_column in array (
1230
+ select array_agg(c.column_name::text order by c.ordinal_position)
1231
+ from information_schema.columns c
1232
+ where c.table_schema = 'public'
1233
+ and c.table_name = profile_table
1234
+ ) loop
1235
+ if not expected_column = any(configured_columns)
1236
+ and has_column_privilege('authenticated', format('public.%I', profile_table), expected_column, 'UPDATE') then
1237
+ raise exception 'authenticated role has unexpected UPDATE privilege on profile column %', expected_column;
1238
+ end if;
1239
+ end loop;
1240
+
1241
+ foreach expected_column in array ${protectedUpdateColumnsArray} loop
1242
+ if exists (
1243
+ select 1
1244
+ from information_schema.columns c
1245
+ where c.table_schema = 'public'
1246
+ and c.table_name = profile_table
1247
+ and c.column_name = expected_column
1248
+ ) and has_column_privilege('authenticated', format('public.%I', profile_table), expected_column, 'UPDATE') then
1249
+ raise exception 'authenticated role must not have UPDATE privilege on protected profile column %', expected_column;
1250
+ end if;
1251
+ end loop;
1252
+
1253
+ ${triggerCheck}
1254
+
1255
+ if '${profileModel.createStrategy}' = 'trigger' then
1256
+ if not exists (
1257
+ select 1
1258
+ from pg_proc p
1259
+ join pg_namespace n on n.oid = p.pronamespace
1260
+ where n.nspname = 'public'
1261
+ and p.proname = '${functionName}'
1262
+ and p.prosecdef
1263
+ and p.prorettype = 'trigger'::regtype
1264
+ and p.proconfig @> array['search_path=pg_catalog, pg_temp']
1265
+ ) then
1266
+ raise exception 'generated trigger function is missing required SECURITY DEFINER properties';
1267
+ end if;
1268
+
1269
+ if exists (
1270
+ select 1
1271
+ from pg_proc p
1272
+ join pg_namespace n on n.oid = p.pronamespace
1273
+ cross join lateral aclexplode(coalesce(p.proacl, acldefault('f', p.proowner))) acl
1274
+ where n.nspname = 'public'
1275
+ and p.proname = '${functionName}'
1276
+ and acl.privilege_type = 'EXECUTE'
1277
+ and (
1278
+ acl.grantee = 0
1279
+ or acl.grantee = 'anon'::regrole
1280
+ or acl.grantee = 'authenticated'::regrole
1281
+ )
1282
+ ) then
1283
+ raise exception 'generated trigger function execute privilege must be revoked from PUBLIC, anon, and authenticated';
1284
+ end if;
1285
+ end if;
1286
+ end;
1287
+ $$;`;
1288
+ }
1289
+ function getStatusScript(args) {
1290
+ const { defaultNamespace, supabaseLocalEnabled, profileModel } = args;
1291
+ const profileStatusSql = profileModel.enabled ? getSupabaseProfileChecksumSql(profileModel) : '';
1292
+ const profileVerificationSql = profileModel.enabled
1293
+ ? getSupabaseProfileVerificationSql(profileModel)
1294
+ : '';
885
1295
  return `#!/usr/bin/env bash
886
1296
  set -euo pipefail
887
1297
 
@@ -897,7 +1307,11 @@ fi
897
1307
 
898
1308
  NAMESPACE="\${ANKH_NAMESPACE:-${defaultNamespace}}"
899
1309
  AUTH_RUNTIME_MODE="\${AUTH_RUNTIME_MODE:-local}"
1310
+ SUPABASE_LOCAL_ENABLED="\${SUPABASE_LOCAL_ENABLED:-${supabaseLocalEnabled ? 'true' : 'false'}}"
900
1311
  SUPABASE_SECRET_SYNC_ENABLED="\${SUPABASE_SECRET_SYNC_ENABLED:-true}"
1312
+ SUPABASE_PROJECT_DIR="\${SUPABASE_PROJECT_DIR:-\${ROOT_DIR}}"
1313
+ SUPABASE_PROFILE_ENABLED="${profileModel.enabled ? 'true' : 'false'}"
1314
+ SUPABASE_PROFILE_RECONCILE_FILE="\${SUPABASE_PROJECT_DIR}/supabase/generated/auth_profiles.sql"
901
1315
 
902
1316
  if ! command -v kubectl >/dev/null 2>&1; then
903
1317
  echo "kubectl is required but not installed."
@@ -969,9 +1383,209 @@ if [[ "\${SUPABASE_SECRET_SYNC_ENABLED}" == "true" ]]; then
969
1383
  else
970
1384
  echo "- secret/supabase-auth-secrets: skipped (SUPABASE_SECRET_SYNC_ENABLED=false)"
971
1385
  fi
1386
+
1387
+ version_ge() {
1388
+ local actual="$1"
1389
+ local minimum="$2"
1390
+
1391
+ local actual_major actual_minor actual_patch
1392
+ local minimum_major minimum_minor minimum_patch
1393
+ IFS=. read -r actual_major actual_minor actual_patch <<< "\${actual}"
1394
+ IFS=. read -r minimum_major minimum_minor minimum_patch <<< "\${minimum}"
1395
+
1396
+ actual_major="\${actual_major:-0}"
1397
+ actual_minor="\${actual_minor:-0}"
1398
+ actual_patch="\${actual_patch:-0}"
1399
+ minimum_major="\${minimum_major:-0}"
1400
+ minimum_minor="\${minimum_minor:-0}"
1401
+ minimum_patch="\${minimum_patch:-0}"
1402
+
1403
+ (( actual_major > minimum_major )) && return 0
1404
+ (( actual_major < minimum_major )) && return 1
1405
+ (( actual_minor > minimum_minor )) && return 0
1406
+ (( actual_minor < minimum_minor )) && return 1
1407
+ (( actual_patch >= minimum_patch ))
1408
+ }
1409
+
1410
+ require_supabase_cli_capabilities() {
1411
+ if ! command -v supabase >/dev/null 2>&1; then
1412
+ echo "supabase CLI is required for local schema status checks."
1413
+ echo "Install or upgrade: https://supabase.com/docs/guides/local-development/cli/getting-started"
1414
+ return 1
1415
+ fi
1416
+
1417
+ local version
1418
+ version="$(supabase --version | awk '{print $NF}')"
1419
+ if ! version_ge "\${version}" "2.106.0"; then
1420
+ echo "supabase CLI >= 2.106.0 is required; found \${version}."
1421
+ echo "Upgrade: https://supabase.com/docs/guides/local-development/cli/getting-started"
1422
+ return 1
1423
+ fi
1424
+
1425
+ if ! supabase status --help 2>/dev/null | grep -Fq -- "--workdir"; then
1426
+ echo "supabase CLI does not support required global --workdir flag."
1427
+ return 1
1428
+ fi
1429
+
1430
+ if ! supabase migration up --help 2>/dev/null | grep -Fq -- "--local"; then
1431
+ echo "supabase CLI does not support required migration up --local command."
1432
+ return 1
1433
+ fi
1434
+
1435
+ if ! command -v psql >/dev/null 2>&1; then
1436
+ echo "psql is required for local schema status checks."
1437
+ return 1
1438
+ fi
1439
+ }
1440
+
1441
+ read_env_value() {
1442
+ local source="$1"
1443
+ local key="$2"
1444
+ local line
1445
+ line="$(printf '%s\\n' "\${source}" | awk -v key="\${key}" '
1446
+ $0 ~ "^(export[[:space:]]+)?" key "=" {print}
1447
+ ' | tail -n1)"
1448
+
1449
+ if [[ -z "\${line}" ]]; then
1450
+ echo ""
1451
+ return
1452
+ fi
1453
+
1454
+ line="\${line#export }"
1455
+ line="\${line#\${key}=}"
1456
+ line="\${line%\\"}"
1457
+ line="\${line#\\"}"
1458
+ printf '%s' "\${line}"
1459
+ }
1460
+
1461
+ run_supabase_sql() {
1462
+ local sql="$1"
1463
+ local label="$2"
1464
+ local tmp_file
1465
+ tmp_file="$(mktemp)"
1466
+ printf '%s\\n' "\${sql}" > "\${tmp_file}"
1467
+
1468
+ if psql "\${SUPABASE_DB_URL}" -v ON_ERROR_STOP=1 -q -f "\${tmp_file}" >/dev/null; then
1469
+ rm -f "\${tmp_file}"
1470
+ return 0
1471
+ fi
1472
+
1473
+ rm -f "\${tmp_file}"
1474
+ echo "- \${label}: failed"
1475
+ return 1
1476
+ }
1477
+
1478
+ check_immutable_migrations_applied() {
1479
+ local migrations_dir="\${SUPABASE_PROJECT_DIR}/supabase/migrations"
1480
+ local applied_versions
1481
+ applied_versions="$(psql "\${SUPABASE_DB_URL}" -v ON_ERROR_STOP=1 -Atc "select version from supabase_migrations.schema_migrations" 2>/dev/null || true)"
1482
+
1483
+ if [[ -z "\${applied_versions}" ]]; then
1484
+ applied_versions=""
1485
+ fi
1486
+
1487
+ if [[ ! -d "\${migrations_dir}" ]]; then
1488
+ return 0
1489
+ fi
1490
+
1491
+ local file
1492
+ local version
1493
+ local pending=0
1494
+ for file in "\${migrations_dir}"/*.sql; do
1495
+ [[ -e "\${file}" ]] || continue
1496
+ version="$(basename "\${file}")"
1497
+ version="\${version%%_*}"
1498
+ if ! printf '%s\\n' "\${applied_versions}" | grep -Fxq "\${version}"; then
1499
+ echo "- immutable migrations: pending \${file}"
1500
+ pending=1
1501
+ fi
1502
+ done
1503
+
1504
+ return "\${pending}"
1505
+ }
1506
+
1507
+ if [[ "\${AUTH_RUNTIME_MODE}" == "local" && "\${SUPABASE_LOCAL_ENABLED}" == "true" ]]; then
1508
+ echo
1509
+ echo "Local Supabase database checks:"
1510
+ status_failed=0
1511
+
1512
+ if require_supabase_cli_capabilities; then
1513
+ if supabase --workdir "\${SUPABASE_PROJECT_DIR}" status >/dev/null 2>&1; then
1514
+ STATUS_ENV="$(supabase --workdir "\${SUPABASE_PROJECT_DIR}" status -o env)"
1515
+ SUPABASE_DB_URL="$(read_env_value "\${STATUS_ENV}" DB_URL)"
1516
+ if [[ -z "\${SUPABASE_DB_URL}" ]]; then
1517
+ echo "- immutable migrations: status unavailable (DB_URL missing from supabase status)"
1518
+ status_failed=1
1519
+ elif check_immutable_migrations_applied; then
1520
+ echo "- immutable migrations: applied"
1521
+ else
1522
+ echo "- immutable migrations: pending"
1523
+ status_failed=1
1524
+ fi
1525
+
1526
+ if [[ "\${SUPABASE_PROFILE_ENABLED}" == "true" ]]; then
1527
+ if [[ -f "\${SUPABASE_PROFILE_RECONCILE_FILE}" ]]; then
1528
+ PROFILE_STATUS_SQL=$(cat <<'SQL'
1529
+ ${profileStatusSql}
1530
+ SQL
1531
+ )
1532
+ PROFILE_VERIFY_SQL=$(cat <<'SQL'
1533
+ ${profileVerificationSql}
1534
+ SQL
1535
+ )
1536
+
1537
+ if run_supabase_sql "\${PROFILE_STATUS_SQL}" "profile reconciliation"; then
1538
+ echo "- profile reconciliation: applied, checksum matches"
1539
+ else
1540
+ echo "- profile reconciliation: pending or stale"
1541
+ status_failed=1
1542
+ fi
1543
+
1544
+ if run_supabase_sql "\${PROFILE_VERIFY_SQL}" "profile schema"; then
1545
+ echo "- profile schema: verified"
1546
+ else
1547
+ echo "- profile schema: drift detected"
1548
+ status_failed=1
1549
+ fi
1550
+ else
1551
+ echo "- profile reconciliation: generated file missing at \${SUPABASE_PROFILE_RECONCILE_FILE}"
1552
+ echo "- profile schema: not verified"
1553
+ status_failed=1
1554
+ fi
1555
+ else
1556
+ PROFILE_DISABLED_SQL=$(cat <<'SQL'
1557
+ ${getSupabaseProfileDisabledGuardSql()}
1558
+ SQL
1559
+ )
1560
+ if run_supabase_sql "\${PROFILE_DISABLED_SQL}" "profile disabled-state"; then
1561
+ echo "- profile reconciliation: skipped (no profile table configured)"
1562
+ echo "- profile schema: skipped (no profile table configured)"
1563
+ else
1564
+ echo "- profile reconciliation: stale generated state exists for removed profile table"
1565
+ echo "- profile schema: not verified"
1566
+ status_failed=1
1567
+ fi
1568
+ fi
1569
+ else
1570
+ echo "- local Supabase stack: not running or not reachable from \${SUPABASE_PROJECT_DIR}"
1571
+ status_failed=1
1572
+ fi
1573
+ else
1574
+ status_failed=1
1575
+ fi
1576
+
1577
+ if [[ "\${status_failed}" -ne 0 ]]; then
1578
+ exit "\${status_failed}"
1579
+ fi
1580
+ fi
972
1581
  `;
973
1582
  }
974
- function getSupabaseLocalEnvScript(supabaseLocalPorts) {
1583
+ function getSupabaseLocalEnvScript(args) {
1584
+ const { supabaseLocalPorts, profileModel } = args;
1585
+ const profileStatusSql = profileModel.enabled ? getSupabaseProfileChecksumSql(profileModel) : '';
1586
+ const profileVerificationSql = profileModel.enabled
1587
+ ? getSupabaseProfileVerificationSql(profileModel)
1588
+ : '';
975
1589
  return `#!/usr/bin/env bash
976
1590
  set -euo pipefail
977
1591
 
@@ -988,8 +1602,10 @@ if [[ -f "\${ENV_FILE}" ]]; then
988
1602
  fi
989
1603
 
990
1604
  APP_SOURCE_DIR="\${APP_SOURCE_DIR:-$(cd "\${ROOT_DIR}/../.." && pwd)}"
991
- SUPABASE_PROJECT_DIR="\${SUPABASE_PROJECT_DIR:-\${APP_SOURCE_DIR}}"
1605
+ SUPABASE_PROJECT_DIR="\${SUPABASE_PROJECT_DIR:-\${ROOT_DIR}}"
992
1606
  APP_SUPABASE_ENV_FILE="\${APP_SUPABASE_ENV_FILE:-\${APP_SOURCE_DIR}/.env.local}"
1607
+ SUPABASE_PROFILE_ENABLED="${profileModel.enabled ? 'true' : 'false'}"
1608
+ SUPABASE_PROFILE_RECONCILE_FILE="\${SUPABASE_PROJECT_DIR}/supabase/generated/auth_profiles.sql"
993
1609
 
994
1610
  SUPABASE_LOCAL_PORT_BASE="\${SUPABASE_LOCAL_PORT_BASE:-${supabaseLocalPorts.base}}"
995
1611
  SUPABASE_LOCAL_SHADOW_PORT="\${SUPABASE_LOCAL_SHADOW_PORT:-\${SUPABASE_LOCAL_PORT_BASE}}"
@@ -1005,9 +1621,101 @@ export SUPABASE_LOCAL_DB_PORT
1005
1621
  export SUPABASE_LOCAL_STUDIO_PORT
1006
1622
  export SUPABASE_LOCAL_INBUCKET_PORT
1007
1623
  export SUPABASE_LOCAL_ANALYTICS_PORT
1008
- if ! command -v supabase >/dev/null 2>&1; then
1009
- echo "supabase CLI is required but not installed."
1010
- echo "Install: https://supabase.com/docs/guides/local-development/cli/getting-started"
1624
+ version_ge() {
1625
+ local actual="$1"
1626
+ local minimum="$2"
1627
+
1628
+ local actual_major actual_minor actual_patch
1629
+ local minimum_major minimum_minor minimum_patch
1630
+ IFS=. read -r actual_major actual_minor actual_patch <<< "\${actual}"
1631
+ IFS=. read -r minimum_major minimum_minor minimum_patch <<< "\${minimum}"
1632
+
1633
+ actual_major="\${actual_major:-0}"
1634
+ actual_minor="\${actual_minor:-0}"
1635
+ actual_patch="\${actual_patch:-0}"
1636
+ minimum_major="\${minimum_major:-0}"
1637
+ minimum_minor="\${minimum_minor:-0}"
1638
+ minimum_patch="\${minimum_patch:-0}"
1639
+
1640
+ (( actual_major > minimum_major )) && return 0
1641
+ (( actual_major < minimum_major )) && return 1
1642
+ (( actual_minor > minimum_minor )) && return 0
1643
+ (( actual_minor < minimum_minor )) && return 1
1644
+ (( actual_patch >= minimum_patch ))
1645
+ }
1646
+
1647
+ require_supabase_cli_capabilities() {
1648
+ if ! command -v supabase >/dev/null 2>&1; then
1649
+ echo "supabase CLI is required but not installed."
1650
+ echo "Install or upgrade: https://supabase.com/docs/guides/local-development/cli/getting-started"
1651
+ exit 1
1652
+ fi
1653
+
1654
+ local version
1655
+ version="$(supabase --version | awk '{print $NF}')"
1656
+ if ! version_ge "\${version}" "2.106.0"; then
1657
+ echo "supabase CLI >= 2.106.0 is required; found \${version}."
1658
+ echo "Upgrade: https://supabase.com/docs/guides/local-development/cli/getting-started"
1659
+ exit 1
1660
+ fi
1661
+
1662
+ if ! supabase status --help 2>/dev/null | grep -Fq -- "--workdir"; then
1663
+ echo "supabase CLI does not support required global --workdir flag."
1664
+ echo "Upgrade: https://supabase.com/docs/guides/local-development/cli/getting-started"
1665
+ exit 1
1666
+ fi
1667
+
1668
+ if ! supabase migration up --help 2>/dev/null | grep -Fq -- "--local"; then
1669
+ echo "supabase CLI does not support required migration up --local command."
1670
+ echo "Upgrade: https://supabase.com/docs/guides/local-development/cli/getting-started"
1671
+ exit 1
1672
+ fi
1673
+
1674
+ if ! command -v psql >/dev/null 2>&1; then
1675
+ echo "psql is required but not installed."
1676
+ exit 1
1677
+ fi
1678
+ }
1679
+
1680
+ format_command() {
1681
+ printf '%q ' "$@"
1682
+ }
1683
+
1684
+ run_checked_command() {
1685
+ local label="$1"
1686
+ local sql_file="$2"
1687
+ shift 2
1688
+ local status
1689
+ set +e
1690
+ "$@"
1691
+ status=$?
1692
+ set -e
1693
+
1694
+ if [[ "\${status}" -ne 0 ]]; then
1695
+ echo "\${label} failed."
1696
+ if [[ -n "\${sql_file}" ]]; then
1697
+ echo "SQL file: \${sql_file}"
1698
+ fi
1699
+ echo "Supabase project workdir: \${SUPABASE_PROJECT_DIR}"
1700
+ echo "Command: $(format_command "$@")"
1701
+ echo "Exit status: \${status}"
1702
+ exit "\${status}"
1703
+ fi
1704
+ }
1705
+
1706
+ run_checked_sql_file() {
1707
+ local label="$1"
1708
+ local sql_file="$2"
1709
+ run_checked_command \
1710
+ "\${label}" \
1711
+ "\${sql_file}" \
1712
+ psql "\${SUPABASE_DB_URL}" -v ON_ERROR_STOP=1 -q -f "\${sql_file}"
1713
+ }
1714
+
1715
+ require_supabase_cli_capabilities
1716
+
1717
+ if [[ ! -d "\${SUPABASE_PROJECT_DIR}" ]]; then
1718
+ echo "Supabase project directory does not exist: \${SUPABASE_PROJECT_DIR}"
1011
1719
  exit 1
1012
1720
  fi
1013
1721
 
@@ -1207,28 +1915,72 @@ PY
1207
1915
 
1208
1916
  if [[ ! -f "\${SUPABASE_PROJECT_DIR}/supabase/config.toml" ]]; then
1209
1917
  echo "No supabase/config.toml found in \${SUPABASE_PROJECT_DIR}. Initializing Supabase project..."
1210
- (
1211
- cd "\${SUPABASE_PROJECT_DIR}"
1212
- supabase init --yes >/dev/null
1213
- )
1918
+ supabase --workdir "\${SUPABASE_PROJECT_DIR}" init --yes >/dev/null
1214
1919
  fi
1215
1920
 
1216
1921
  configure_supabase_local_ports
1217
1922
 
1218
1923
  STATUS_ENV="$(
1219
- cd "\${SUPABASE_PROJECT_DIR}"
1220
- supabase status -o env 2>/dev/null || true
1924
+ supabase --workdir "\${SUPABASE_PROJECT_DIR}" status -o env 2>/dev/null || true
1221
1925
  )"
1222
1926
 
1927
+ read_env_value() {
1928
+ local key="$1"
1929
+ local line
1930
+ line="$(printf '%s\\n' "\${STATUS_ENV}" | awk -v key="\${key}" '
1931
+ $0 ~ "^(export[[:space:]]+)?" key "=" {print}
1932
+ ' | tail -n1)"
1933
+
1934
+ if [[ -z "\${line}" ]]; then
1935
+ echo ""
1936
+ return
1937
+ fi
1938
+
1939
+ line="\${line#export }"
1940
+ line="\${line#\${key}=}"
1941
+ line="\${line%\\"}"
1942
+ line="\${line#\\"}"
1943
+ printf '%s' "\${line}"
1944
+ }
1945
+
1946
+ check_immutable_migrations_applied() {
1947
+ local migrations_dir="\${SUPABASE_PROJECT_DIR}/supabase/migrations"
1948
+ local applied_versions
1949
+ applied_versions="$(psql "\${SUPABASE_DB_URL}" -v ON_ERROR_STOP=1 -Atc "select version from supabase_migrations.schema_migrations" 2>/dev/null || true)"
1950
+
1951
+ if [[ -z "\${applied_versions}" ]]; then
1952
+ applied_versions=""
1953
+ fi
1954
+
1955
+ if [[ ! -d "\${migrations_dir}" ]]; then
1956
+ return 0
1957
+ fi
1958
+
1959
+ local file
1960
+ local version
1961
+ local pending=0
1962
+ for file in "\${migrations_dir}"/*.sql; do
1963
+ [[ -e "\${file}" ]] || continue
1964
+ version="$(basename "\${file}")"
1965
+ version="\${version%%_*}"
1966
+ if ! printf '%s\\n' "\${applied_versions}" | grep -Fxq "\${version}"; then
1967
+ echo "Pending immutable Supabase migration: \${file}"
1968
+ pending=1
1969
+ fi
1970
+ done
1971
+
1972
+ return "\${pending}"
1973
+ }
1974
+
1223
1975
  start_supabase_local_stack() {
1224
- if supabase start >/dev/null; then
1976
+ if supabase --workdir "\${SUPABASE_PROJECT_DIR}" start >/dev/null; then
1225
1977
  return 0
1226
1978
  fi
1227
1979
 
1228
1980
  echo "Supabase local start failed. Stopping stale local stack and retrying once..."
1229
- supabase stop --no-backup >/dev/null 2>&1 || true
1981
+ supabase --workdir "\${SUPABASE_PROJECT_DIR}" stop --no-backup >/dev/null 2>&1 || true
1230
1982
 
1231
- if supabase start >/dev/null; then
1983
+ if supabase --workdir "\${SUPABASE_PROJECT_DIR}" start >/dev/null; then
1232
1984
  return 0
1233
1985
  fi
1234
1986
 
@@ -1239,35 +1991,73 @@ start_supabase_local_stack() {
1239
1991
  if [[ -z "\${STATUS_ENV}" ]]; then
1240
1992
  assert_supabase_local_ports_available
1241
1993
  echo "Supabase local stack not detected. Starting with 'supabase start'..."
1242
- (
1243
- cd "\${SUPABASE_PROJECT_DIR}"
1244
- start_supabase_local_stack
1245
- )
1246
-
1247
- STATUS_ENV="$(
1248
- cd "\${SUPABASE_PROJECT_DIR}"
1249
- supabase status -o env
1250
- )"
1994
+ start_supabase_local_stack
1995
+ STATUS_ENV="$(supabase --workdir "\${SUPABASE_PROJECT_DIR}" status -o env)"
1251
1996
  fi
1252
1997
 
1253
- read_env_value() {
1254
- local key="$1"
1255
- local line
1256
- line="$(printf '%s\\n' "\${STATUS_ENV}" | awk -v key="\${key}" '
1257
- $0 ~ "^(export[[:space:]]+)?" key "=" {print}
1258
- ' | tail -n1)"
1998
+ SUPABASE_DB_URL="$(read_env_value DB_URL)"
1999
+ if [[ -z "\${SUPABASE_DB_URL}" ]]; then
2000
+ echo "Unable to read DB_URL from 'supabase status -o env'."
2001
+ exit 1
2002
+ fi
1259
2003
 
1260
- if [[ -z "\${line}" ]]; then
1261
- echo ""
1262
- return
2004
+ echo "Applying pending immutable Supabase migrations..."
2005
+ run_checked_command \
2006
+ "Immutable Supabase migration application" \
2007
+ "" \
2008
+ supabase --workdir "\${SUPABASE_PROJECT_DIR}" migration up --local
2009
+
2010
+ if ! check_immutable_migrations_applied; then
2011
+ echo "Immutable Supabase migration application did not apply every local migration."
2012
+ exit 1
2013
+ fi
2014
+
2015
+ if [[ "\${SUPABASE_PROFILE_ENABLED}" == "true" ]]; then
2016
+ if [[ ! -f "\${SUPABASE_PROFILE_RECONCILE_FILE}" ]]; then
2017
+ echo "Generated profile reconciliation file is missing: \${SUPABASE_PROFILE_RECONCILE_FILE}"
2018
+ exit 1
1263
2019
  fi
1264
2020
 
1265
- line="\${line#export }"
1266
- line="\${line#\${key}=}"
1267
- line="\${line%\\"}"
1268
- line="\${line#\\"}"
1269
- printf '%s' "\${line}"
1270
- }
2021
+ echo "Applying generated profile reconciliation..."
2022
+ run_checked_sql_file \
2023
+ "Generated profile reconciliation" \
2024
+ "\${SUPABASE_PROFILE_RECONCILE_FILE}"
2025
+
2026
+ PROFILE_STATUS_SQL=$(cat <<'SQL'
2027
+ ${profileStatusSql}
2028
+ SQL
2029
+ )
2030
+ PROFILE_VERIFY_SQL=$(cat <<'SQL'
2031
+ ${profileVerificationSql}
2032
+ SQL
2033
+ )
2034
+
2035
+ PROFILE_STATUS_FILE="$(mktemp)"
2036
+ PROFILE_VERIFY_FILE="$(mktemp)"
2037
+ printf '%s\\n' "\${PROFILE_STATUS_SQL}" > "\${PROFILE_STATUS_FILE}"
2038
+ printf '%s\\n' "\${PROFILE_VERIFY_SQL}" > "\${PROFILE_VERIFY_FILE}"
2039
+
2040
+ run_checked_sql_file \
2041
+ "Generated profile reconciliation checksum verification" \
2042
+ "\${PROFILE_STATUS_FILE}"
2043
+ run_checked_sql_file \
2044
+ "Generated profile schema verification" \
2045
+ "\${PROFILE_VERIFY_FILE}"
2046
+
2047
+ rm -f "\${PROFILE_STATUS_FILE}" "\${PROFILE_VERIFY_FILE}"
2048
+ echo "Generated profile reconciliation applied and schema verified."
2049
+ else
2050
+ PROFILE_DISABLED_FILE="$(mktemp)"
2051
+ cat > "\${PROFILE_DISABLED_FILE}" <<'SQL'
2052
+ ${getSupabaseProfileDisabledGuardSql()}
2053
+ SQL
2054
+ run_checked_sql_file \
2055
+ "Generated profile disabled-state verification" \
2056
+ "\${PROFILE_DISABLED_FILE}"
2057
+ rm -f "\${PROFILE_DISABLED_FILE}"
2058
+ fi
2059
+
2060
+ STATUS_ENV="$(supabase --workdir "\${SUPABASE_PROJECT_DIR}" status -o env)"
1271
2061
 
1272
2062
  API_URL="$(read_env_value API_URL)"
1273
2063
  ANON_KEY="$(read_env_value ANON_KEY)"
@@ -1368,4 +2158,10 @@ function hashProjectId(value) {
1368
2158
  }
1369
2159
  return hash;
1370
2160
  }
2161
+ function getSqlTextArray(values) {
2162
+ return `array[${values.map((value) => `'${escapeSqlLiteral(value)}'`).join(', ')}]::text[]`;
2163
+ }
2164
+ function escapeSqlLiteral(value) {
2165
+ return value.replaceAll("'", "''");
2166
+ }
1371
2167
  //# sourceMappingURL=index.js.map