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.
- package/README.md +7 -1
- package/package.json +3 -2
- package/src/add.mjs +10 -2
- package/src/admin.mjs +204 -0
- package/src/app-manifest.mjs +31 -2
- package/src/bw.mjs +3 -1
- package/src/constants.mjs +11 -9
- package/src/diff.mjs +3 -3
- package/src/doctor.mjs +151 -9
- package/src/env.mjs +43 -0
- package/src/generator.mjs +291 -9
- package/src/migrations.mjs +4 -1
- package/src/regions.mjs +60 -0
- package/src/remove.mjs +18 -2
- package/src/safe-path.mjs +46 -0
- package/src/scaffold-cmd.mjs +3 -10
- package/src/scaffold.mjs +3 -2
- package/src/update.mjs +35 -0
- package/src/upgrade.mjs +5 -3
- package/template/base/app/(auth)/login/page.tsx +5 -1
- package/template/base/app/(shell)/shell-layout-client.tsx +29 -15
- package/template/base/app/layout.tsx +12 -4
- package/template/base/config/module-toolbar-controls.tsx +6 -0
- package/template/supabase/modules/admin/migrations/20260729120000_bootstrap_first_admin.sql +114 -0
- package/template/supabase/modules/admin/migrations/20260729150000_bootstrap_first_admin_remove_force.sql +116 -0
- package/template/supabase/modules/marketing/migrations/20260729170000_marketing_webhook_transaction.sql +106 -0
|
@@ -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
|
+
}
|
package/src/scaffold-cmd.mjs
CHANGED
|
@@ -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(
|
|
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 =
|
|
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 =
|
|
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 =
|
|
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 =
|
|
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
|
-
|
|
64
|
-
|
|
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
|
}
|
|
@@ -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
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
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
|
-
|
|
90
|
-
|
|
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
|
-
{
|
|
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="
|
|
22
|
+
<html lang="pt-PT" className={`${geistSans.variable} ${geistMono.variable}`} suppressHydrationWarning>
|
|
15
23
|
<head>
|
|
16
|
-
<ThemeScript defaultTheme="
|
|
24
|
+
<ThemeScript defaultTheme="system" />
|
|
17
25
|
</head>
|
|
18
26
|
<body>
|
|
19
|
-
<ThemeProvider defaultTheme="
|
|
27
|
+
<ThemeProvider defaultTheme="system" disableTransitionOnChange>
|
|
20
28
|
{children}
|
|
21
29
|
</ThemeProvider>
|
|
22
30
|
</body>
|
|
@@ -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;
|