@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.
- package/CHANGELOG.md +12 -0
- package/README.md +2 -2
- package/dist/adapters/minikube/auth/supabase/index.d.ts.map +1 -1
- package/dist/adapters/minikube/auth/supabase/index.js +12 -161
- package/dist/adapters/minikube/auth/supabase/index.js.map +1 -1
- package/dist/adapters/minikube/auth/supabase/profile.d.ts +21 -0
- package/dist/adapters/minikube/auth/supabase/profile.d.ts.map +1 -0
- package/dist/adapters/minikube/auth/supabase/profile.js +452 -0
- package/dist/adapters/minikube/auth/supabase/profile.js.map +1 -0
- package/dist/adapters/minikube/base/index.d.ts.map +1 -1
- package/dist/adapters/minikube/base/index.js +849 -53
- package/dist/adapters/minikube/base/index.js.map +1 -1
- package/dist/{cli.d.ts → cli/bin.d.ts} +3 -3
- package/dist/cli/bin.d.ts.map +1 -0
- package/dist/{cli.js → cli/bin.js} +3 -3
- package/dist/cli/bin.js.map +1 -0
- package/dist/readme-usage.js +1 -1
- package/dist/readme-usage.js.map +1 -1
- package/package.json +2 -2
- package/src/adapters/minikube/auth/supabase/index.test.ts +152 -18
- package/src/adapters/minikube/auth/supabase/index.ts +16 -203
- package/src/adapters/minikube/auth/supabase/profile.integration.test.ts +860 -0
- package/src/adapters/minikube/auth/supabase/profile.ts +526 -0
- package/src/adapters/minikube/base/index.test.ts +100 -6
- package/src/adapters/minikube/base/index.ts +879 -53
- package/src/{cli.ts → cli/bin.ts} +3 -3
- package/src/{cli.test.ts → cli/cli.test.ts} +3 -3
- package/src/index.test.ts +13 -2
- package/src/package.test.ts +1 -1
- package/src/readme-usage.ts +1 -1
- package/dist/cli.d.ts.map +0 -1
- package/dist/cli.js.map +0 -1
|
@@ -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({
|
|
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(
|
|
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(
|
|
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
|
-
-
|
|
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\`
|
|
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: {
|
|
581
|
-
|
|
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
|
|
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
|
|
695
|
+
echo "Supabase local bootstrap failed: missing executable \${SUPABASE_LOCAL_ENV_SCRIPT}."
|
|
696
|
+
exit 1
|
|
661
697
|
else
|
|
662
|
-
echo "Supabase
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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(
|
|
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:-\${
|
|
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
|
-
|
|
1109
|
-
|
|
1110
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
1355
|
-
|
|
1356
|
-
|
|
1357
|
-
|
|
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
|
-
|
|
1361
|
-
|
|
1362
|
-
|
|
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
|
-
|
|
1366
|
-
|
|
1367
|
-
|
|
1368
|
-
|
|
1369
|
-
|
|
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
|
+
}
|