@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,5 +1,10 @@
|
|
|
1
1
|
import type { InfraManifestInput } from '../../../../types';
|
|
2
2
|
import type { MinikubeAdapterArtifacts } from '../../contracts';
|
|
3
|
+
import {
|
|
4
|
+
getSupabaseProfileReconciliation,
|
|
5
|
+
type ResolvedProfileModel,
|
|
6
|
+
resolveSupabaseProfileModel,
|
|
7
|
+
} from './profile';
|
|
3
8
|
|
|
4
9
|
export function generateSupabaseAuthArtifacts(args: {
|
|
5
10
|
manifest: InfraManifestInput;
|
|
@@ -10,12 +15,12 @@ export function generateSupabaseAuthArtifacts(args: {
|
|
|
10
15
|
const root = 'infra/minikube/k8s/auth/supabase';
|
|
11
16
|
const resourceRoot = 'auth/supabase';
|
|
12
17
|
const docsRoot = 'infra/minikube/auth';
|
|
13
|
-
const
|
|
18
|
+
const generatedRoot = 'infra/minikube/supabase/generated';
|
|
14
19
|
const scope = manifest.auth?.scope ?? 'global';
|
|
15
|
-
const authzEngine = manifest.auth?.authorization
|
|
16
|
-
const authzKind = manifest.auth?.authorization
|
|
20
|
+
const authzEngine = manifest.auth?.authorization?.engine ?? 'native';
|
|
21
|
+
const authzKind = manifest.auth?.authorization?.kind ?? 'RBAC';
|
|
17
22
|
const authFieldModel = resolveAuthFieldModel(manifest);
|
|
18
|
-
const profileModel =
|
|
23
|
+
const profileModel = resolveSupabaseProfileModel(manifest);
|
|
19
24
|
|
|
20
25
|
const warnings: string[] = [];
|
|
21
26
|
if (scope !== 'global') {
|
|
@@ -55,8 +60,8 @@ export function generateSupabaseAuthArtifacts(args: {
|
|
|
55
60
|
...(profileModel.enabled
|
|
56
61
|
? [
|
|
57
62
|
{
|
|
58
|
-
path: `${
|
|
59
|
-
content:
|
|
63
|
+
path: `${generatedRoot}/auth_profiles.sql`,
|
|
64
|
+
content: getSupabaseProfileReconciliation(profileModel),
|
|
60
65
|
},
|
|
61
66
|
]
|
|
62
67
|
: []),
|
|
@@ -175,12 +180,12 @@ function getSupabaseRuntimeWiringGuide(profileModel: ResolvedProfileModel) {
|
|
|
175
180
|
Identity remains provider-owned in \`auth.users\`. App-facing profile data is generated in
|
|
176
181
|
\`public.${profileModel.table}\` and linked through \`${profileModel.table}.id -> auth.users.id\`.
|
|
177
182
|
|
|
178
|
-
The generated
|
|
183
|
+
The generated profile reconciliation:
|
|
179
184
|
|
|
180
185
|
- creates the profile table
|
|
181
186
|
- enables row-level security
|
|
182
187
|
- allows signed-in users to read and update their own profile row
|
|
183
|
-
-
|
|
188
|
+
- drops stale Ankhorage-managed profile columns that are no longer configured
|
|
184
189
|
- creates a trigger for new auth users when \`AUTH_PROFILE_CREATE_STRATEGY=trigger\`
|
|
185
190
|
`
|
|
186
191
|
: '';
|
|
@@ -250,11 +255,6 @@ const DEFAULT_SIGN_IN_IDENTIFIERS = ['email'];
|
|
|
250
255
|
const DEFAULT_SIGN_UP_REQUIRED_FIELDS = ['email', 'password'];
|
|
251
256
|
const DEFAULT_SIGN_UP_OPTIONAL_FIELDS = ['firstName', 'lastName'];
|
|
252
257
|
const DEFAULT_SIGN_UP_POLICY = 'autoSignIn';
|
|
253
|
-
const DEFAULT_PROFILE_FIELDS = ['email', 'firstName', 'lastName'];
|
|
254
|
-
const DEFAULT_PROFILE_PRIMARY_KEY = 'authUserId';
|
|
255
|
-
const DEFAULT_PROFILE_CREATE_STRATEGY = 'trigger';
|
|
256
|
-
const DEFAULT_PROFILE_UPDATE_STRATEGY = 'api';
|
|
257
|
-
|
|
258
258
|
interface ResolvedAuthFieldModel {
|
|
259
259
|
signInIdentifiersCsv: string;
|
|
260
260
|
signUpRequiredFieldsCsv: string;
|
|
@@ -263,22 +263,6 @@ interface ResolvedAuthFieldModel {
|
|
|
263
263
|
profileFieldsCsv: string;
|
|
264
264
|
}
|
|
265
265
|
|
|
266
|
-
interface ProfileColumnSpec {
|
|
267
|
-
readonly field: string;
|
|
268
|
-
readonly column: string;
|
|
269
|
-
readonly sqlType: string;
|
|
270
|
-
readonly fromNewUser: string;
|
|
271
|
-
}
|
|
272
|
-
|
|
273
|
-
interface ResolvedProfileModel {
|
|
274
|
-
readonly enabled: boolean;
|
|
275
|
-
readonly table: string;
|
|
276
|
-
readonly primaryKey: string;
|
|
277
|
-
readonly createStrategy: string;
|
|
278
|
-
readonly updateStrategy: string;
|
|
279
|
-
readonly columns: readonly ProfileColumnSpec[];
|
|
280
|
-
}
|
|
281
|
-
|
|
282
266
|
function resolveAuthFieldModel(manifest: InfraManifestInput): ResolvedAuthFieldModel {
|
|
283
267
|
const signInIdentifiers = normalizeFieldList(
|
|
284
268
|
manifest.auth?.signIn?.identifiers,
|
|
@@ -293,7 +277,11 @@ function resolveAuthFieldModel(manifest: InfraManifestInput): ResolvedAuthFieldM
|
|
|
293
277
|
DEFAULT_SIGN_UP_OPTIONAL_FIELDS,
|
|
294
278
|
).filter((field) => !signUpRequiredFields.includes(field));
|
|
295
279
|
const signUpPolicy = manifest.auth?.signUp?.signUpPolicy ?? DEFAULT_SIGN_UP_POLICY;
|
|
296
|
-
const profileFields = normalizeFieldList(manifest.auth?.profile?.fields,
|
|
280
|
+
const profileFields = normalizeFieldList(manifest.auth?.profile?.fields, [
|
|
281
|
+
'email',
|
|
282
|
+
'firstName',
|
|
283
|
+
'lastName',
|
|
284
|
+
]);
|
|
297
285
|
|
|
298
286
|
return {
|
|
299
287
|
signInIdentifiersCsv: signInIdentifiers.join(','),
|
|
@@ -304,164 +292,6 @@ function resolveAuthFieldModel(manifest: InfraManifestInput): ResolvedAuthFieldM
|
|
|
304
292
|
};
|
|
305
293
|
}
|
|
306
294
|
|
|
307
|
-
function resolveProfileModel(manifest: InfraManifestInput): ResolvedProfileModel {
|
|
308
|
-
const table = normalizeIdentifier(manifest.auth?.profile?.table ?? '');
|
|
309
|
-
const fields = normalizeFieldList(manifest.auth?.profile?.fields, DEFAULT_PROFILE_FIELDS);
|
|
310
|
-
|
|
311
|
-
return {
|
|
312
|
-
enabled: table.length > 0,
|
|
313
|
-
table,
|
|
314
|
-
primaryKey: manifest.auth?.profile?.primaryKey ?? DEFAULT_PROFILE_PRIMARY_KEY,
|
|
315
|
-
createStrategy: manifest.auth?.profile?.createStrategy ?? DEFAULT_PROFILE_CREATE_STRATEGY,
|
|
316
|
-
updateStrategy: manifest.auth?.profile?.updateStrategy ?? DEFAULT_PROFILE_UPDATE_STRATEGY,
|
|
317
|
-
columns: resolveProfileColumns(fields),
|
|
318
|
-
};
|
|
319
|
-
}
|
|
320
|
-
|
|
321
|
-
function getSupabaseProfileMigration(profileModel: ResolvedProfileModel): string {
|
|
322
|
-
const table = quoteIdentifier(profileModel.table);
|
|
323
|
-
const functionName = quoteIdentifier(`handle_new_${profileModel.table}_user`);
|
|
324
|
-
const triggerName = quoteIdentifier(`on_auth_user_created_${profileModel.table}`);
|
|
325
|
-
const columnDefinitions = profileModel.columns
|
|
326
|
-
.map((column) => ` ${quoteIdentifier(column.column)} ${column.sqlType}`)
|
|
327
|
-
.join(',\n');
|
|
328
|
-
const insertColumns = ['id', ...profileModel.columns.map((column) => column.column)]
|
|
329
|
-
.map(quoteIdentifier)
|
|
330
|
-
.join(', ');
|
|
331
|
-
const insertValues = ['new.id', ...profileModel.columns.map((column) => column.fromNewUser)].join(
|
|
332
|
-
', ',
|
|
333
|
-
);
|
|
334
|
-
const updateAssignments = profileModel.columns
|
|
335
|
-
.filter((column) => column.field === 'email')
|
|
336
|
-
.map(
|
|
337
|
-
(column) => `${quoteIdentifier(column.column)} = excluded.${quoteIdentifier(column.column)}`,
|
|
338
|
-
);
|
|
339
|
-
const conflictUpdate = [...updateAssignments, 'updated_at = now()'].join(',\n ');
|
|
340
|
-
const updateGrantColumns = profileModel.columns.map((column) => quoteIdentifier(column.column));
|
|
341
|
-
const updateGrantSql =
|
|
342
|
-
updateGrantColumns.length > 0
|
|
343
|
-
? `grant update (${updateGrantColumns.join(', ')}) on table public.${table} to authenticated;\n`
|
|
344
|
-
: '';
|
|
345
|
-
const triggerSql =
|
|
346
|
-
profileModel.createStrategy === 'trigger'
|
|
347
|
-
? `
|
|
348
|
-
create or replace function public.${functionName}()
|
|
349
|
-
returns trigger
|
|
350
|
-
language plpgsql
|
|
351
|
-
security definer
|
|
352
|
-
set search_path = public
|
|
353
|
-
as $$
|
|
354
|
-
begin
|
|
355
|
-
insert into public.${table} (${insertColumns})
|
|
356
|
-
values (${insertValues})
|
|
357
|
-
on conflict (id) do update set
|
|
358
|
-
${conflictUpdate};
|
|
359
|
-
|
|
360
|
-
return new;
|
|
361
|
-
end;
|
|
362
|
-
$$;
|
|
363
|
-
|
|
364
|
-
drop trigger if exists ${triggerName} on auth.users;
|
|
365
|
-
create trigger ${triggerName}
|
|
366
|
-
after insert on auth.users
|
|
367
|
-
for each row execute function public.${functionName}();
|
|
368
|
-
`
|
|
369
|
-
: '';
|
|
370
|
-
|
|
371
|
-
return `-- Generated by @ankhorage/infra from manifest.infra.auth.profile.
|
|
372
|
-
-- Supabase Auth owns identity in auth.users. App-facing profile data lives in public.${profileModel.table}.
|
|
373
|
-
|
|
374
|
-
create table if not exists public.${table} (
|
|
375
|
-
id uuid primary key references auth.users(id) on delete cascade${
|
|
376
|
-
columnDefinitions ? `,\n${columnDefinitions}` : ''
|
|
377
|
-
},
|
|
378
|
-
role text not null default 'user',
|
|
379
|
-
created_at timestamptz not null default now(),
|
|
380
|
-
updated_at timestamptz not null default now()
|
|
381
|
-
);
|
|
382
|
-
|
|
383
|
-
alter table public.${table} enable row level security;
|
|
384
|
-
|
|
385
|
-
revoke all on table public.${table} from anon, authenticated;
|
|
386
|
-
grant select on table public.${table} to authenticated;
|
|
387
|
-
${updateGrantSql}
|
|
388
|
-
create policy ${quoteIdentifier(`${profileModel.table}_select_own`)}
|
|
389
|
-
on public.${table}
|
|
390
|
-
for select
|
|
391
|
-
using (auth.uid() = id);
|
|
392
|
-
|
|
393
|
-
create policy ${quoteIdentifier(`${profileModel.table}_update_own`)}
|
|
394
|
-
on public.${table}
|
|
395
|
-
for update
|
|
396
|
-
using (auth.uid() = id)
|
|
397
|
-
with check (auth.uid() = id);
|
|
398
|
-
${triggerSql}`;
|
|
399
|
-
}
|
|
400
|
-
|
|
401
|
-
function resolveProfileColumns(fields: readonly string[]): ProfileColumnSpec[] {
|
|
402
|
-
const columns = new Map<string, ProfileColumnSpec>();
|
|
403
|
-
|
|
404
|
-
for (const field of fields) {
|
|
405
|
-
const column = mapProfileFieldToColumn(field);
|
|
406
|
-
if (!column || columns.has(column.column)) {
|
|
407
|
-
continue;
|
|
408
|
-
}
|
|
409
|
-
columns.set(column.column, column);
|
|
410
|
-
}
|
|
411
|
-
|
|
412
|
-
return [...columns.values()];
|
|
413
|
-
}
|
|
414
|
-
|
|
415
|
-
function mapProfileFieldToColumn(field: string): ProfileColumnSpec | null {
|
|
416
|
-
switch (field) {
|
|
417
|
-
case 'email':
|
|
418
|
-
return { field, column: 'email', sqlType: 'text', fromNewUser: 'new.email' };
|
|
419
|
-
case 'displayName':
|
|
420
|
-
return {
|
|
421
|
-
field,
|
|
422
|
-
column: 'display_name',
|
|
423
|
-
sqlType: 'text',
|
|
424
|
-
fromNewUser:
|
|
425
|
-
"coalesce(new.raw_user_meta_data->>'displayName', new.raw_user_meta_data->>'display_name', split_part(new.email, '@', 1))",
|
|
426
|
-
};
|
|
427
|
-
case 'firstName':
|
|
428
|
-
return {
|
|
429
|
-
field,
|
|
430
|
-
column: 'first_name',
|
|
431
|
-
sqlType: 'text',
|
|
432
|
-
fromNewUser:
|
|
433
|
-
"coalesce(new.raw_user_meta_data->>'firstName', new.raw_user_meta_data->>'first_name')",
|
|
434
|
-
};
|
|
435
|
-
case 'lastName':
|
|
436
|
-
return {
|
|
437
|
-
field,
|
|
438
|
-
column: 'last_name',
|
|
439
|
-
sqlType: 'text',
|
|
440
|
-
fromNewUser:
|
|
441
|
-
"coalesce(new.raw_user_meta_data->>'lastName', new.raw_user_meta_data->>'last_name')",
|
|
442
|
-
};
|
|
443
|
-
case 'avatarUrl':
|
|
444
|
-
return {
|
|
445
|
-
field,
|
|
446
|
-
column: 'avatar_url',
|
|
447
|
-
sqlType: 'text',
|
|
448
|
-
fromNewUser:
|
|
449
|
-
"coalesce(new.raw_user_meta_data->>'avatarUrl', new.raw_user_meta_data->>'avatar_url')",
|
|
450
|
-
};
|
|
451
|
-
case 'username':
|
|
452
|
-
return {
|
|
453
|
-
field,
|
|
454
|
-
column: 'username',
|
|
455
|
-
sqlType: 'text',
|
|
456
|
-
fromNewUser: "new.raw_user_meta_data->>'username'",
|
|
457
|
-
};
|
|
458
|
-
case 'phone':
|
|
459
|
-
return { field, column: 'phone', sqlType: 'text', fromNewUser: 'new.phone' };
|
|
460
|
-
default:
|
|
461
|
-
return null;
|
|
462
|
-
}
|
|
463
|
-
}
|
|
464
|
-
|
|
465
295
|
function normalizeFieldList(
|
|
466
296
|
values: readonly string[] | undefined,
|
|
467
297
|
fallback: readonly string[],
|
|
@@ -484,23 +314,6 @@ function normalizeFieldList(
|
|
|
484
314
|
return [...fallback];
|
|
485
315
|
}
|
|
486
316
|
|
|
487
|
-
function normalizeIdentifier(value: string): string {
|
|
488
|
-
const trimmed = value.trim();
|
|
489
|
-
if (!trimmed) return '';
|
|
490
|
-
|
|
491
|
-
if (!/^[a-z_][a-z0-9_]*$/.test(trimmed)) {
|
|
492
|
-
throw new Error(
|
|
493
|
-
`Invalid Supabase profile table identifier "${trimmed}". Use snake_case letters, numbers, and underscores.`,
|
|
494
|
-
);
|
|
495
|
-
}
|
|
496
|
-
|
|
497
|
-
return trimmed;
|
|
498
|
-
}
|
|
499
|
-
|
|
500
|
-
function quoteIdentifier(value: string): string {
|
|
501
|
-
return `"${value.replaceAll('"', '""')}"`;
|
|
502
|
-
}
|
|
503
|
-
|
|
504
317
|
function escapeYamlDoubleQuoted(value: string): string {
|
|
505
318
|
return value.replaceAll('\\', '\\\\').replaceAll('"', '\\"');
|
|
506
319
|
}
|