@ankhorage/infra 0.3.2 → 0.3.4
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 +14 -163
- 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/authz/cerbos/index.d.ts +1 -1
- package/dist/adapters/minikube/authz/cerbos/index.d.ts.map +1 -1
- package/dist/adapters/minikube/authz/cerbos/index.js +5 -3
- package/dist/adapters/minikube/authz/cerbos/index.js.map +1 -1
- package/dist/adapters/minikube/authz/index.d.ts +1 -1
- package/dist/adapters/minikube/authz/index.d.ts.map +1 -1
- package/dist/adapters/minikube/authz/index.js +1 -1
- package/dist/adapters/minikube/authz/index.js.map +1 -1
- package/dist/adapters/minikube/base/index.d.ts.map +1 -1
- package/dist/adapters/minikube/base/index.js +850 -54
- package/dist/adapters/minikube/base/index.js.map +1 -1
- package/dist/adapters/minikube/index.js +2 -1
- package/dist/adapters/minikube/index.js.map +1 -1
- package/dist/infraValidation.js +1 -1
- package/dist/infraValidation.js.map +1 -1
- package/dist/project.d.ts.map +1 -1
- package/dist/project.js +1 -18
- package/dist/project.js.map +1 -1
- package/dist/testSupport.d.ts.map +1 -1
- package/dist/testSupport.js +0 -6
- package/dist/testSupport.js.map +1 -1
- package/dist/types.d.ts +1 -1
- package/dist/types.d.ts.map +1 -1
- package/dist/types.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 +18 -205
- 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/authz/cerbos/index.ts +7 -5
- package/src/adapters/minikube/authz/index.ts +2 -2
- package/src/adapters/minikube/base/index.test.ts +100 -6
- package/src/adapters/minikube/base/index.ts +880 -54
- package/src/adapters/minikube/index.ts +3 -1
- package/src/index.test.ts +13 -2
- package/src/infraValidation.ts +1 -1
- package/src/project.ts +2 -22
- package/src/testSupport.ts +0 -6
- package/src/types.ts +1 -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;
|
|
@@ -12,11 +13,13 @@ export function generateMinikubeBaseArtifacts(args) {
|
|
|
12
13
|
const supabaseLocalPorts = resolveSupabaseLocalPorts(namespace);
|
|
13
14
|
const authScope = manifest.auth?.scope ?? 'none';
|
|
14
15
|
const authProvider = manifest.auth?.provider ?? 'none';
|
|
15
|
-
const authzEngine = manifest.auth?.authorization
|
|
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({
|
|
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(
|
|
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(
|
|
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
|
-
-
|
|
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\`
|
|
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
|
|
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
|
|
591
|
+
echo "Supabase local bootstrap failed: missing executable \${SUPABASE_LOCAL_ENV_SCRIPT}."
|
|
592
|
+
exit 1
|
|
571
593
|
else
|
|
572
|
-
echo "Supabase
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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(
|
|
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:-\${
|
|
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
|
-
|
|
1009
|
-
|
|
1010
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
1255
|
-
|
|
1256
|
-
|
|
1257
|
-
|
|
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
|
-
|
|
1261
|
-
|
|
1262
|
-
|
|
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
|
-
|
|
1266
|
-
|
|
1267
|
-
|
|
1268
|
-
|
|
1269
|
-
|
|
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
|