create-bw-app 0.18.6 → 0.20.0

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.
@@ -0,0 +1,46 @@
1
+ import path from "node:path";
2
+
3
+ const WINDOWS_DRIVE_PATH = /^[A-Za-z]:/;
4
+
5
+ export function normalizeSafeRelativePath(relativePath, label = "Path") {
6
+ if (typeof relativePath !== "string" || relativePath.trim() === "") {
7
+ throw new Error(`${label} must be a non-empty relative path.`);
8
+ }
9
+
10
+ const value = relativePath.trim();
11
+ if (
12
+ path.posix.isAbsolute(value)
13
+ || path.win32.isAbsolute(value)
14
+ || WINDOWS_DRIVE_PATH.test(value)
15
+ || value.startsWith("\\\\")
16
+ ) {
17
+ throw new Error(`${label} must be relative to the target directory: ${relativePath}`);
18
+ }
19
+
20
+ const portablePath = value.replaceAll("\\", "/");
21
+ if (portablePath.split("/").includes("..")) {
22
+ throw new Error(`${label} must not contain parent-directory traversal: ${relativePath}`);
23
+ }
24
+
25
+ const normalized = path.posix.normalize(portablePath).replace(/^\.\//, "");
26
+ if (normalized === "." || normalized === "") {
27
+ throw new Error(`${label} must identify a path inside the target directory.`);
28
+ }
29
+ return normalized;
30
+ }
31
+
32
+ export function resolveSafeRelativePath(targetDir, relativePath, label = "Path") {
33
+ const root = path.resolve(targetDir);
34
+ const normalized = normalizeSafeRelativePath(relativePath, label);
35
+ const resolved = path.resolve(root, ...normalized.split("/"));
36
+ const relativeToRoot = path.relative(root, resolved);
37
+ if (
38
+ relativeToRoot === ""
39
+ || relativeToRoot === ".."
40
+ || relativeToRoot.startsWith(`..${path.sep}`)
41
+ || path.isAbsolute(relativeToRoot)
42
+ ) {
43
+ throw new Error(`${label} resolves outside the target directory: ${relativePath}`);
44
+ }
45
+ return resolved;
46
+ }
@@ -3,17 +3,10 @@ import { stdout as output } from "node:process";
3
3
  import { findWorkspaceRoot, hashFile, readAppManifest, writeAppManifest } from "./app-manifest.mjs";
4
4
  import { pathExists } from "./generator.mjs";
5
5
  import { findTrackedTemplate, scaffoldDrift } from "./scaffold.mjs";
6
+ import { normalizeSafeRelativePath, resolveSafeRelativePath } from "./safe-path.mjs";
6
7
 
7
8
  const HELP = `Usage: bw scaffold <action> [paths...] [options]\n\nActions:\n list List tracked files, live status, and intent\n own <path>... Mark existing tracked files as app-owned\n skip <path>... Mark missing tracked files as intentionally absent\n manage <path>... Return tracked files to BrightWeb management\n\nOptions:\n --target-dir <path> App directory (defaults to cwd)\n --workspace-root <path> BrightWeb workspace root\n --help Show this help`;
8
9
 
9
- function normalizeTrackedPath(relativePath) {
10
- const normalized = path.normalize(String(relativePath)).replace(/^\.\//, "");
11
- if (path.isAbsolute(String(relativePath)) || normalized === ".." || normalized.startsWith(`..${path.sep}`)) {
12
- throw new Error(`Scaffold path must be relative to the app: ${relativePath}`);
13
- }
14
- return normalized;
15
- }
16
-
17
10
  export async function scaffoldBrightwebApp(action, paths = [], argvOptions = {}, runtimeOptions = {}) {
18
11
  if (argvOptions.help || !action) { output.write(`${HELP}\n`); return { help: true }; }
19
12
  if (!Array.isArray(paths)) paths = [paths];
@@ -30,7 +23,7 @@ export async function scaffoldBrightwebApp(action, paths = [], argvOptions = {},
30
23
  return { action, entries: live.entries };
31
24
  }
32
25
 
33
- const normalizedPaths = Array.from(new Set(paths.map(normalizeTrackedPath)));
26
+ const normalizedPaths = Array.from(new Set(paths.map((entry) => normalizeSafeRelativePath(entry, "Scaffold path"))));
34
27
  for (const relativePath of normalizedPaths) {
35
28
  if (!manifest.scaffoldFiles?.[relativePath]) throw new Error(`${relativePath} is not a tracked scaffold file.`);
36
29
  }
@@ -47,7 +40,7 @@ export async function scaffoldBrightwebApp(action, paths = [], argvOptions = {},
47
40
  const record = manifest.scaffoldFiles[relativePath];
48
41
  const previousIntent = record.intent || "managed";
49
42
  const nextIntent = action === "manage" ? "managed" : action === "own" ? "owned" : "skipped";
50
- const appPath = path.join(targetDir, relativePath);
43
+ const appPath = resolveSafeRelativePath(targetDir, relativePath, "Manifest scaffold file path");
51
44
  const exists = await pathExists(appPath);
52
45
  if (action === "manage") {
53
46
  const located = await findTrackedTemplate({ relativePath, manifest, targetDir, workspaceRoot });
package/src/scaffold.mjs CHANGED
@@ -4,6 +4,7 @@ import { fileURLToPath } from "node:url";
4
4
  import { MODULE_STARTER_FILES, PLATFORM_STARTER_FILES, SELECTABLE_MODULES } from "./constants.mjs";
5
5
  import { hashFile } from "./app-manifest.mjs";
6
6
  import { pathExists } from "./generator.mjs";
7
+ import { resolveSafeRelativePath } from "./safe-path.mjs";
7
8
 
8
9
  const BUNDLED_TEMPLATE_ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..", "template");
9
10
 
@@ -42,7 +43,7 @@ export async function inventoryScaffoldFiles({ targetDir, moduleKeys, templateRo
42
43
  unsupported.push(definition.relativePath);
43
44
  continue;
44
45
  }
45
- const appPath = path.join(targetDir, definition.relativePath);
46
+ const appPath = resolveSafeRelativePath(targetDir, definition.relativePath, "Scaffold file path");
46
47
  const templateHash = await hashFile(templatePath);
47
48
  const exists = await pathExists(appPath);
48
49
  records[definition.relativePath] = {
@@ -60,7 +61,7 @@ export async function scaffoldDrift(targetDir, scaffoldFiles = {}) {
60
61
  const missing = [];
61
62
  const entries = [];
62
63
  for (const [relativePath, record] of Object.entries(scaffoldFiles)) {
63
- const appPath = path.join(targetDir, relativePath);
64
+ const appPath = resolveSafeRelativePath(targetDir, relativePath, "Manifest scaffold file path");
64
65
  const intent = record.intent || "managed";
65
66
  let status = "missing";
66
67
  if (await pathExists(appPath)) {
package/src/update.mjs CHANGED
@@ -13,7 +13,9 @@ import {
13
13
  TEMPLATE_ROOT,
14
14
  createAppContextFile,
15
15
  createDbInstallPlan,
16
+ createModuleToolbarControlsConfig,
16
17
  createNextConfig,
18
+ createOptionalModuleRouteFiles,
17
19
  createPackageJson,
18
20
  createPlatformGlobalsCss,
19
21
  createPlatformModulesConfigFile,
@@ -25,12 +27,19 @@ import {
25
27
  readJsonIfPresent,
26
28
  runInstall,
27
29
  } from "./generator.mjs";
30
+ import { readAppManifest } from "./app-manifest.mjs";
28
31
 
29
32
  const MANAGED_PLATFORM_FILES = [
30
33
  "next.config.ts",
31
34
  path.join("app", "globals.css"),
35
+ path.join("config", "module-toolbar-controls.tsx"),
32
36
  path.join("config", "modules.ts"),
33
37
  path.join("config", "shell.ts"),
38
+ path.join("app", "api", "invitations", "_dependencies.ts"),
39
+ path.join("app", "api", "organizations", "route.ts"),
40
+ path.join("app", "api", "organizations", "[id]", "route.ts"),
41
+ path.join("app", "api", "organizations", "[id]", "invitations", "route.ts"),
42
+ path.join("app", "api", "organizations", "[id]", "invitations", "[invitationId]", "route.ts"),
34
43
  path.join("docs", "ai", "app-context.json"),
35
44
  ];
36
45
 
@@ -38,6 +47,14 @@ const MANAGED_SITE_FILES = [
38
47
  path.join("docs", "ai", "app-context.json"),
39
48
  ];
40
49
 
50
+ const MODULE_SELECTED_PLATFORM_FILES = new Set([
51
+ path.join("app", "api", "invitations", "_dependencies.ts"),
52
+ path.join("app", "api", "organizations", "route.ts"),
53
+ path.join("app", "api", "organizations", "[id]", "route.ts"),
54
+ path.join("app", "api", "organizations", "[id]", "invitations", "route.ts"),
55
+ path.join("app", "api", "organizations", "[id]", "invitations", "[invitationId]", "route.ts"),
56
+ ]);
57
+
41
58
  function resolveUpdateTargetDirectory(runtimeOptions, argvOptions) {
42
59
  if (runtimeOptions.targetDir || argvOptions.targetDir) {
43
60
  return path.resolve(runtimeOptions.targetDir || argvOptions.targetDir);
@@ -264,6 +281,21 @@ async function getStarterFileStatus(targetDir, installedModules) {
264
281
  continue;
265
282
  }
266
283
 
284
+ // These base files are generated from the installed module set and are
285
+ // validated/repaired through MANAGED_PLATFORM_FILES, not raw template
286
+ // byte parity.
287
+ if (MODULE_SELECTED_PLATFORM_FILES.has(relativePath)) {
288
+ starterFiles.push({
289
+ moduleKey: "platform-base",
290
+ relativePath,
291
+ sourcePath,
292
+ targetPath,
293
+ status: "current",
294
+ refreshable: false,
295
+ });
296
+ continue;
297
+ }
298
+
267
299
  const [sourceContent, targetContent] = await Promise.all([
268
300
  fs.readFile(sourcePath, "utf8"),
269
301
  fs.readFile(targetPath, "utf8"),
@@ -424,6 +456,7 @@ function renderPlanSummary(plan, options = {}) {
424
456
 
425
457
  export async function buildBrightwebAppUpdatePlan(argvOptions = {}, runtimeOptions = {}) {
426
458
  const targetDir = resolveUpdateTargetDirectory(runtimeOptions, argvOptions);
459
+ await readAppManifest(targetDir, { required: false });
427
460
  const packageJsonPath = path.join(targetDir, "package.json");
428
461
  const manifest = await readJsonIfPresent(packageJsonPath);
429
462
 
@@ -493,8 +526,10 @@ export async function buildBrightwebAppUpdatePlan(argvOptions = {}, runtimeOptio
493
526
  const canonicalConfigFiles = {
494
527
  "next.config.ts": createNextConfig({ template: "platform", selectedModules: installedModules }),
495
528
  [path.join("app", "globals.css")]: await createPlatformGlobalsCss(installedModules),
529
+ [path.join("config", "module-toolbar-controls.tsx")]: createModuleToolbarControlsConfig(installedModules),
496
530
  [path.join("config", "modules.ts")]: createPlatformModulesConfigFile(installedModules),
497
531
  [path.join("config", "shell.ts")]: createShellConfig(installedModules),
532
+ ...createOptionalModuleRouteFiles(installedModules),
498
533
  [path.join("docs", "ai", "app-context.json")]: createAppContextFile({
499
534
  slug: manifest.name || path.basename(targetDir),
500
535
  template: "platform",
package/src/upgrade.mjs CHANGED
@@ -5,6 +5,7 @@ import { hashFile, findWorkspaceRoot, loadModuleCatalog, readAppManifest, writeA
5
5
  import { pathExists, runInstall } from "./generator.mjs";
6
6
  import { applyMigrationWrites, getModuleMigrations, planMigrationAppends } from "./migrations.mjs";
7
7
  import { buildBrightwebAppUpdatePlan } from "./update.mjs";
8
+ import { resolveSafeRelativePath } from "./safe-path.mjs";
8
9
 
9
10
  const HELP = `Usage: bw upgrade [moduleKey] [options]\n\nOptions:\n --target-dir <path> App directory (defaults to cwd)\n --workspace-root <path> BrightWeb workspace root\n --allow-stale-fallback Use baked-in versions if npm lookup fails\n --install Install changed dependencies\n --refresh-starters Refresh unchanged starter files\n --dry-run Print the upgrade plan without writing\n --help Show this help`;
10
11
 
@@ -21,7 +22,7 @@ export async function upgradeBrightwebApp(moduleKey, argvOptions = {}, runtimeOp
21
22
  const intentional = [];
22
23
  for (const [relativePath, record] of Object.entries(appManifest.scaffoldFiles)) {
23
24
  if (["owned", "skipped"].includes(record.intent)) intentional.push(relativePath);
24
- const filePath = path.join(targetDir, relativePath);
25
+ const filePath = resolveSafeRelativePath(targetDir, relativePath, "Manifest scaffold file path");
25
26
  if (!(await pathExists(filePath))) { missing.push(relativePath); continue; }
26
27
  if (await hashFile(filePath) !== record.hash) drifted.push(relativePath);
27
28
  }
@@ -60,8 +61,9 @@ export async function upgradeBrightwebApp(moduleKey, argvOptions = {}, runtimeOp
60
61
  if (key && appManifest.modules[key]) appManifest.modules[key].version = cleanVersion(update.to) || appManifest.modules[key].version;
61
62
  }
62
63
  for (const relativePath of plan.starterFilesToRefresh || []) {
63
- if (!protectedPaths.has(relativePath) && appManifest.scaffoldFiles[relativePath] && await pathExists(path.join(targetDir, relativePath))) {
64
- appManifest.scaffoldFiles[relativePath].hash = await hashFile(path.join(targetDir, relativePath));
64
+ const targetPath = resolveSafeRelativePath(targetDir, relativePath, "Manifest scaffold file path");
65
+ if (!protectedPaths.has(relativePath) && appManifest.scaffoldFiles[relativePath] && await pathExists(targetPath)) {
66
+ appManifest.scaffoldFiles[relativePath].hash = await hashFile(targetPath);
65
67
  appManifest.scaffoldFiles[relativePath].status = "current";
66
68
  }
67
69
  }
@@ -1 +1,5 @@
1
- export { LoginPage as default } from "@brightweblabs/core-auth/ui";
1
+ import { LoginPage } from "@brightweblabs/core-auth/ui";
2
+
3
+ export default function Page() {
4
+ return <LoginPage />;
5
+ }
@@ -7,14 +7,18 @@ import {
7
7
  AppShellFrame,
8
8
  DesktopSidebar,
9
9
  MobileNav,
10
+ ShellActionsProvider,
10
11
  computeInitials,
11
12
  isShellNavItemActive,
13
+ useShellAction,
14
+ useShellActionDispatch,
12
15
  useShellNavState,
13
16
  type ShellContextualAction,
14
17
  type ShellNavStateGroup,
15
18
  } from "@brightweblabs/app-shell";
16
19
  import { createAuthUiClient } from "@brightweblabs/core-auth/ui";
17
20
  import { Toaster } from "@brightweblabs/ui";
21
+ import { getModuleToolbarControls } from "../../config/module-toolbar-controls";
18
22
  import { getStarterShellConfig } from "../../config/shell";
19
23
  import "@brightweblabs/app-shell/dashboard.css";
20
24
 
@@ -36,6 +40,17 @@ const toolbarWindowEventByAction: Record<string, string> = {
36
40
  export function ShellLayoutClient({
37
41
  children,
38
42
  viewer,
43
+ }: Readonly<{ children: ReactNode; viewer: ShellViewer }>) {
44
+ return (
45
+ <ShellActionsProvider aliases={toolbarWindowEventByAction}>
46
+ <ShellLayoutInner viewer={viewer}>{children}</ShellLayoutInner>
47
+ </ShellActionsProvider>
48
+ );
49
+ }
50
+
51
+ function ShellLayoutInner({
52
+ children,
53
+ viewer,
39
54
  }: Readonly<{ children: ReactNode; viewer: ShellViewer }>) {
40
55
  const pathname = usePathname() ?? "";
41
56
  const router = useRouter();
@@ -72,23 +87,21 @@ export function ShellLayoutClient({
72
87
  viewer.email ||
73
88
  "Conta";
74
89
  const projectsBaseHref = pathname.startsWith("/projects") ? "/projects" : "/projetos";
90
+ const toolbarControls = getModuleToolbarControls(pathname, projectsBaseHref);
75
91
 
76
- const handleToolbarAction = (item: ShellContextualAction) => {
77
- if (item.action === "projects-back-to-portfolio") {
78
- router.push(projectsBaseHref);
79
- return;
80
- }
81
- if (item.action === "projects-open-board") {
82
- const projectId = pathname.split("/")[2];
83
- if (projectId) {
84
- router.push(`${projectsBaseHref}/${projectId}/${projectsBaseHref === "/projects" ? "tasks" : "tarefas"}`);
85
- }
86
- return;
92
+ const dispatchShellAction = useShellActionDispatch();
93
+ useShellAction("projects-back-to-portfolio", () => {
94
+ router.push(projectsBaseHref);
95
+ });
96
+ useShellAction("projects-open-board", () => {
97
+ const projectId = pathname.split("/")[2];
98
+ if (projectId) {
99
+ router.push(`${projectsBaseHref}/${projectId}/${projectsBaseHref === "/projects" ? "tasks" : "tarefas"}`);
87
100
  }
101
+ });
88
102
 
89
- if (item.action) {
90
- window.dispatchEvent(new Event(toolbarWindowEventByAction[item.action] ?? item.action));
91
- }
103
+ const handleToolbarAction = (item: ShellContextualAction) => {
104
+ if (item.action) dispatchShellAction(item.action);
92
105
  };
93
106
 
94
107
  return (
@@ -143,8 +156,9 @@ export function ShellLayoutClient({
143
156
  toolbarRoutes={toolbarRoutes}
144
157
  toolbarActions={toolbarActions}
145
158
  onToolbarAction={handleToolbarAction}
159
+ notifications={{}}
146
160
  >
147
- {null}
161
+ {toolbarControls}
148
162
  </AppHeader>
149
163
  }
150
164
  mobileNav={
@@ -1,22 +1,30 @@
1
1
  import type { ReactNode } from "react";
2
+ import type { Metadata, Viewport } from "next";
2
3
  import { starterBrandConfig } from "../config/brand";
3
4
  import { ThemeProvider, ThemeScript } from "@brightweblabs/app-shell";
4
5
  import { geistMono, geistSans } from "./fonts";
5
6
  import "./globals.css";
6
7
 
7
- export const metadata = {
8
+ export const metadata: Metadata = {
8
9
  title: `${starterBrandConfig.companyName} Starter`,
9
10
  description: starterBrandConfig.tagline,
10
11
  };
11
12
 
13
+ export const viewport: Viewport = {
14
+ themeColor: [
15
+ { media: "(prefers-color-scheme: light)", color: "white" },
16
+ { media: "(prefers-color-scheme: dark)", color: "black" },
17
+ ],
18
+ };
19
+
12
20
  export default function RootLayout({ children }: { children: ReactNode }) {
13
21
  return (
14
- <html lang="en" className={`${geistSans.variable} ${geistMono.variable}`} suppressHydrationWarning>
22
+ <html lang="pt-PT" className={`${geistSans.variable} ${geistMono.variable}`} suppressHydrationWarning>
15
23
  <head>
16
- <ThemeScript defaultTheme="light" />
24
+ <ThemeScript defaultTheme="system" />
17
25
  </head>
18
26
  <body>
19
- <ThemeProvider defaultTheme="light" disableTransitionOnChange>
27
+ <ThemeProvider defaultTheme="system" disableTransitionOnChange>
20
28
  {children}
21
29
  </ThemeProvider>
22
30
  </body>
@@ -0,0 +1,6 @@
1
+ "use client";
2
+
3
+ // MANAGED BY BRIGHTWEB — regenerated when modules are added, removed, or updated.
4
+ export function getModuleToolbarControls(_pathname: string, _projectsBaseHref: string) {
5
+ return null;
6
+ }
@@ -0,0 +1,114 @@
1
+ -- Transactional database half of `bw admin create`.
2
+
3
+ CREATE OR REPLACE FUNCTION public.bootstrap_first_admin(
4
+ p_user_id uuid,
5
+ p_email text,
6
+ p_force boolean DEFAULT false
7
+ )
8
+ RETURNS TABLE (
9
+ profile_id uuid,
10
+ previous_role_code text
11
+ )
12
+ LANGUAGE plpgsql
13
+ SECURITY DEFINER
14
+ SET search_path = public, auth
15
+ AS $$
16
+ DECLARE
17
+ v_email text;
18
+ v_auth_email text;
19
+ v_profile_id uuid;
20
+ v_previous_role_code text;
21
+ BEGIN
22
+ IF COALESCE(auth.jwt() ->> 'role', '') <> 'service_role' THEN
23
+ RAISE EXCEPTION 'bootstrap_first_admin requires service_role'
24
+ USING ERRCODE = '42501';
25
+ END IF;
26
+
27
+ v_email := NULLIF(lower(trim(COALESCE(p_email, ''))), '');
28
+ IF p_user_id IS NULL OR v_email IS NULL THEN
29
+ RAISE EXCEPTION 'A user id and email are required'
30
+ USING ERRCODE = '22023';
31
+ END IF;
32
+
33
+ SELECT lower(trim(u.email))
34
+ INTO v_auth_email
35
+ FROM auth.users u
36
+ WHERE u.id = p_user_id;
37
+
38
+ IF v_auth_email IS NULL OR v_auth_email <> v_email THEN
39
+ RAISE EXCEPTION 'Auth user and email do not match'
40
+ USING ERRCODE = '22023';
41
+ END IF;
42
+
43
+ PERFORM pg_advisory_xact_lock(hashtextextended('brightweb:first-admin-bootstrap', 0));
44
+
45
+ IF NOT p_force AND EXISTS (
46
+ SELECT 1
47
+ FROM public.user_role_assignments ura
48
+ WHERE ura.role_code = 'admin'
49
+ ) THEN
50
+ RAISE EXCEPTION 'A project administrator already exists'
51
+ USING ERRCODE = '42501';
52
+ END IF;
53
+
54
+ SELECT p.id
55
+ INTO v_profile_id
56
+ FROM public.profiles p
57
+ WHERE p.user_id = p_user_id
58
+ FOR UPDATE;
59
+
60
+ IF v_profile_id IS NULL THEN
61
+ IF EXISTS (
62
+ SELECT 1
63
+ FROM public.profiles p
64
+ WHERE lower(p.email) = v_email
65
+ ) THEN
66
+ RAISE EXCEPTION 'A profile already exists for this email'
67
+ USING ERRCODE = '23505';
68
+ END IF;
69
+
70
+ INSERT INTO public.profiles (user_id, email)
71
+ VALUES (p_user_id, v_email)
72
+ RETURNING id INTO v_profile_id;
73
+ ELSE
74
+ UPDATE public.profiles
75
+ SET email = v_email,
76
+ updated_at = now()
77
+ WHERE id = v_profile_id;
78
+ END IF;
79
+
80
+ SELECT ura.role_code
81
+ INTO v_previous_role_code
82
+ FROM public.user_role_assignments ura
83
+ WHERE ura.profile_id = v_profile_id
84
+ FOR UPDATE;
85
+
86
+ INSERT INTO public.user_role_assignments (
87
+ profile_id,
88
+ role_code,
89
+ assigned_by_profile_id,
90
+ assigned_at,
91
+ reason
92
+ )
93
+ VALUES (
94
+ v_profile_id,
95
+ 'admin',
96
+ NULL,
97
+ now(),
98
+ 'bw_admin_create_bootstrap'
99
+ )
100
+ ON CONFLICT (profile_id)
101
+ DO UPDATE SET
102
+ role_code = EXCLUDED.role_code,
103
+ assigned_by_profile_id = NULL,
104
+ assigned_at = EXCLUDED.assigned_at,
105
+ reason = EXCLUDED.reason;
106
+
107
+ RETURN QUERY SELECT v_profile_id, v_previous_role_code;
108
+ END;
109
+ $$;
110
+
111
+ REVOKE ALL ON FUNCTION public.bootstrap_first_admin(uuid, text, boolean) FROM PUBLIC;
112
+ REVOKE ALL ON FUNCTION public.bootstrap_first_admin(uuid, text, boolean) FROM anon;
113
+ REVOKE ALL ON FUNCTION public.bootstrap_first_admin(uuid, text, boolean) FROM authenticated;
114
+ GRANT EXECUTE ON FUNCTION public.bootstrap_first_admin(uuid, text, boolean) TO service_role;
@@ -0,0 +1,116 @@
1
+ -- Removes the p_force escape hatch from `bw admin create`.
2
+ -- The 3-arg signature is already applied in production, so drop and recreate.
3
+
4
+ DROP FUNCTION IF EXISTS public.bootstrap_first_admin(uuid, text, boolean);
5
+
6
+ CREATE FUNCTION public.bootstrap_first_admin(
7
+ p_user_id uuid,
8
+ p_email text
9
+ )
10
+ RETURNS TABLE (
11
+ profile_id uuid,
12
+ previous_role_code text
13
+ )
14
+ LANGUAGE plpgsql
15
+ SECURITY DEFINER
16
+ SET search_path = public, auth
17
+ AS $$
18
+ DECLARE
19
+ v_email text;
20
+ v_auth_email text;
21
+ v_profile_id uuid;
22
+ v_previous_role_code text;
23
+ BEGIN
24
+ IF COALESCE(auth.jwt() ->> 'role', '') <> 'service_role' THEN
25
+ RAISE EXCEPTION 'bootstrap_first_admin requires service_role'
26
+ USING ERRCODE = '42501';
27
+ END IF;
28
+
29
+ v_email := NULLIF(lower(trim(COALESCE(p_email, ''))), '');
30
+ IF p_user_id IS NULL OR v_email IS NULL THEN
31
+ RAISE EXCEPTION 'A user id and email are required'
32
+ USING ERRCODE = '22023';
33
+ END IF;
34
+
35
+ SELECT lower(trim(u.email))
36
+ INTO v_auth_email
37
+ FROM auth.users u
38
+ WHERE u.id = p_user_id;
39
+
40
+ IF v_auth_email IS NULL OR v_auth_email <> v_email THEN
41
+ RAISE EXCEPTION 'Auth user and email do not match'
42
+ USING ERRCODE = '22023';
43
+ END IF;
44
+
45
+ PERFORM pg_advisory_xact_lock(hashtextextended('brightweb:first-admin-bootstrap', 0));
46
+
47
+ IF EXISTS (
48
+ SELECT 1
49
+ FROM public.user_role_assignments ura
50
+ WHERE ura.role_code = 'admin'
51
+ ) THEN
52
+ RAISE EXCEPTION 'A project administrator already exists'
53
+ USING ERRCODE = '42501';
54
+ END IF;
55
+
56
+ SELECT p.id
57
+ INTO v_profile_id
58
+ FROM public.profiles p
59
+ WHERE p.user_id = p_user_id
60
+ FOR UPDATE;
61
+
62
+ IF v_profile_id IS NULL THEN
63
+ IF EXISTS (
64
+ SELECT 1
65
+ FROM public.profiles p
66
+ WHERE lower(p.email) = v_email
67
+ ) THEN
68
+ RAISE EXCEPTION 'A profile already exists for this email'
69
+ USING ERRCODE = '23505';
70
+ END IF;
71
+
72
+ INSERT INTO public.profiles (user_id, email)
73
+ VALUES (p_user_id, v_email)
74
+ RETURNING id INTO v_profile_id;
75
+ ELSE
76
+ UPDATE public.profiles
77
+ SET email = v_email,
78
+ updated_at = now()
79
+ WHERE id = v_profile_id;
80
+ END IF;
81
+
82
+ SELECT ura.role_code
83
+ INTO v_previous_role_code
84
+ FROM public.user_role_assignments ura
85
+ WHERE ura.profile_id = v_profile_id
86
+ FOR UPDATE;
87
+
88
+ INSERT INTO public.user_role_assignments (
89
+ profile_id,
90
+ role_code,
91
+ assigned_by_profile_id,
92
+ assigned_at,
93
+ reason
94
+ )
95
+ VALUES (
96
+ v_profile_id,
97
+ 'admin',
98
+ NULL,
99
+ now(),
100
+ 'bw_admin_create_bootstrap'
101
+ )
102
+ ON CONFLICT (profile_id)
103
+ DO UPDATE SET
104
+ role_code = EXCLUDED.role_code,
105
+ assigned_by_profile_id = NULL,
106
+ assigned_at = EXCLUDED.assigned_at,
107
+ reason = EXCLUDED.reason;
108
+
109
+ RETURN QUERY SELECT v_profile_id, v_previous_role_code;
110
+ END;
111
+ $$;
112
+
113
+ REVOKE ALL ON FUNCTION public.bootstrap_first_admin(uuid, text) FROM PUBLIC;
114
+ REVOKE ALL ON FUNCTION public.bootstrap_first_admin(uuid, text) FROM anon;
115
+ REVOKE ALL ON FUNCTION public.bootstrap_first_admin(uuid, text) FROM authenticated;
116
+ GRANT EXECUTE ON FUNCTION public.bootstrap_first_admin(uuid, text) TO service_role;