create-bw-app 0.19.0 → 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 CHANGED
@@ -39,7 +39,7 @@ bw admin create --email owner@example.com
39
39
  - `bw add <moduleKey>` resolves requirements, installs thin package mounts and module wiring, and appends migrations.
40
40
  - `bw upgrade [moduleKey]` includes the existing managed update flow plus forward-only module migrations.
41
41
  - `bw doctor` checks package, config, scaffold, environment-name, migration, configured function-region, and deployed function-region consistency. Pass `--deployment-url` to inspect the deployed `x-vercel-id`; add `--report` to stamp the result in the app manifest.
42
- - `bw admin create --email <email>` creates a passwordless Supabase Auth user, transactionally ensures its profile and `admin` assignment, then sends the Core Auth `/reset-password` flow. It refuses an existing admin unless `--force` is passed and never promotes an existing Auth user.
42
+ - `bw admin create --email <email>` creates a passwordless Supabase Auth user, transactionally ensures its profile and `admin` assignment, then sends the Core Auth `/reset-password` flow. It always refuses when the project already has an admin (use the in-app admin role controls to add administrators) and never promotes an existing Auth user.
43
43
  - All mutating commands support `--dry-run`.
44
44
 
45
45
  ## Update existing apps
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "create-bw-app",
3
3
  "private": false,
4
- "version": "0.19.0",
4
+ "version": "0.20.0",
5
5
  "type": "module",
6
6
  "bin": {
7
7
  "create-bw-app": "bin/create-bw-app.mjs",
package/src/add.mjs CHANGED
@@ -2,7 +2,7 @@ import fs from "node:fs/promises";
2
2
  import path from "node:path";
3
3
  import { stdout as output } from "node:process";
4
4
  import { SELECTABLE_MODULES } from "./constants.mjs";
5
- import { TEMPLATE_ROOT, createAppContextFile, createDbInstallPlan, createNextConfig, createPlatformGlobalsCss, createPlatformModulesConfigFile, createShellConfig, getDbModuleRegistry, getVersionMap, pathExists, readJsonIfPresent } from "./generator.mjs";
5
+ import { TEMPLATE_ROOT, createAppContextFile, createDbInstallPlan, createModuleToolbarControlsConfig, createNextConfig, createOptionalModuleRouteFiles, createPlatformGlobalsCss, createPlatformModulesConfigFile, createShellConfig, getDbModuleRegistry, getVersionMap, pathExists, readJsonIfPresent } from "./generator.mjs";
6
6
  import { collectScaffoldFiles, findWorkspaceRoot, loadModuleCatalog, MODULE_PACKAGES, readAppManifest, resolveModuleClosure, satisfiesVersion, writeAppManifest } from "./app-manifest.mjs";
7
7
  import { applyMigrationWrites, planMigrationAppends } from "./migrations.mjs";
8
8
 
@@ -66,9 +66,11 @@ export async function addBrightwebModule(moduleKey, argvOptions = {}, runtimeOpt
66
66
  const managedWrites = {
67
67
  "next.config.ts": createNextConfig({ template: "platform", selectedModules: installedModuleKeys }),
68
68
  "app/globals.css": await createPlatformGlobalsCss(installedModuleKeys),
69
+ "config/module-toolbar-controls.tsx": createModuleToolbarControlsConfig(installedModuleKeys),
69
70
  "config/modules.ts": createPlatformModulesConfigFile(installedModuleKeys),
70
71
  "config/shell.ts": createShellConfig(installedModuleKeys),
71
72
  "docs/ai/app-context.json": createAppContextFile({ slug: appManifest.app.slug, template: "platform", selectedModules: installedModuleKeys.filter((key) => key !== "orgs"), dbInstallPlan }),
73
+ ...createOptionalModuleRouteFiles(installedModuleKeys),
72
74
  };
73
75
 
74
76
  const summary = [
@@ -93,7 +95,13 @@ export async function addBrightwebModule(moduleKey, argvOptions = {}, runtimeOpt
93
95
  for (const key of newModules) appManifest.modules[key] = { version: catalog[key].version, installedAt: now, exposed: true };
94
96
  appManifest.migrationCursor = migrationPlan.nextCursor;
95
97
  const collectedScaffoldFiles = await collectScaffoldFiles(targetDir, installedModuleKeys);
96
- appManifest.scaffoldFiles = { ...collectedScaffoldFiles, ...appManifest.scaffoldFiles };
98
+ const refreshedScaffoldFiles = Object.fromEntries(
99
+ Object.entries(collectedScaffoldFiles).map(([relativePath, record]) => {
100
+ const intent = appManifest.scaffoldFiles[relativePath]?.intent;
101
+ return [relativePath, intent ? { ...record, intent } : record];
102
+ }),
103
+ );
104
+ appManifest.scaffoldFiles = { ...appManifest.scaffoldFiles, ...refreshedScaffoldFiles };
97
105
  await writeAppManifest(targetDir, appManifest);
98
106
  output.write(`Installed ${newModules.length} module${newModules.length === 1 ? "" : "s"}. ${migrationPlan.writes.length > 0 ? "Run your Supabase migration apply command. " : ""}Run your package manager install command next.\n`);
99
107
  return { dryRun: false, newModules, migrationPlan };
package/src/admin.mjs CHANGED
@@ -8,7 +8,6 @@ const HELP = `Usage: bw admin create --email <email> [options]
8
8
  Options:
9
9
  --email <email> Email address for the first administrator
10
10
  --target-dir <path> App directory (defaults to cwd)
11
- --force Allow creation when the project already has an admin
12
11
  --dry-run Validate and inspect without creating the user
13
12
  --help Show this help
14
13
 
@@ -94,7 +93,7 @@ async function deleteCreatedAuthUser(supabase, userId) {
94
93
  function bootstrapErrorMessage(error) {
95
94
  const message = error?.message || "Unknown database error";
96
95
  if (message.toLowerCase().includes("administrator already exists")) {
97
- return "A project administrator already exists. Re-run with --force only when intentionally adding another new admin.";
96
+ return "A project administrator already exists. Use the in-app admin role controls to add administrators.";
98
97
  }
99
98
  return `Could not create the administrator profile and role: ${message}`;
100
99
  }
@@ -110,6 +109,9 @@ export async function createFirstAdmin(action, argvOptions = {}, runtimeOptions
110
109
  if ("password" in argvOptions) {
111
110
  throw new Error("Passwords are not accepted by bw admin create. The command sends a password-set email instead.");
112
111
  }
112
+ if ("force" in argvOptions) {
113
+ throw new Error("--force has been removed; use the in-app admin role controls to add administrators.");
114
+ }
113
115
 
114
116
  const email = normalizeEmail(argvOptions.email);
115
117
  if (!validateEmail(email)) {
@@ -129,9 +131,9 @@ export async function createFirstAdmin(action, argvOptions = {}, runtimeOptions
129
131
  });
130
132
 
131
133
  const hasAdmin = await projectHasAdmin(supabase);
132
- if (hasAdmin && !argvOptions.force) {
134
+ if (hasAdmin) {
133
135
  throw new Error(
134
- "A project administrator already exists. Refusing bootstrap; pass --force only when intentionally adding another new admin.",
136
+ "A project administrator already exists. Refusing bootstrap; use the in-app admin role controls to add administrators.",
135
137
  );
136
138
  }
137
139
 
@@ -144,9 +146,9 @@ export async function createFirstAdmin(action, argvOptions = {}, runtimeOptions
144
146
 
145
147
  if (argvOptions.dryRun) {
146
148
  (runtimeOptions.output || output).write(
147
- `DRY RUN ${email} can be created${hasAdmin ? " with --force" : " as the first administrator"}.\n`,
149
+ `DRY RUN ${email} can be created as the first administrator.\n`,
148
150
  );
149
- return { dryRun: true, email, forced: Boolean(argvOptions.force) };
151
+ return { dryRun: true, email };
150
152
  }
151
153
 
152
154
  const { data: created, error: createError } = await supabase.auth.admin.createUser({
@@ -165,7 +167,6 @@ export async function createFirstAdmin(action, argvOptions = {}, runtimeOptions
165
167
  {
166
168
  p_user_id: userId,
167
169
  p_email: email,
168
- p_force: Boolean(argvOptions.force),
169
170
  },
170
171
  );
171
172
  if (bootstrapError) throw new Error(bootstrapErrorMessage(bootstrapError));
@@ -196,7 +197,6 @@ export async function createFirstAdmin(action, argvOptions = {}, runtimeOptions
196
197
  email,
197
198
  userId,
198
199
  profileId,
199
- forced: Boolean(argvOptions.force),
200
200
  resetPasswordUrl,
201
201
  };
202
202
  }
@@ -3,6 +3,7 @@ import fs from "node:fs/promises";
3
3
  import path from "node:path";
4
4
  import { fileURLToPath } from "node:url";
5
5
  import { APP_DEPENDENCY_DEFAULTS, MODULE_STARTER_FILES, PLATFORM_STARTER_FILES, SELECTABLE_MODULES } from "./constants.mjs";
6
+ import { normalizeSafeRelativePath, resolveSafeRelativePath } from "./safe-path.mjs";
6
7
 
7
8
  const TEMPLATE_ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..", "template");
8
9
 
@@ -86,6 +87,10 @@ export async function readAppManifest(targetDir, { required = true } = {}) {
86
87
  if (!manifest && required) {
87
88
  throw new Error(`No BrightWeb app manifest found at ${APP_MANIFEST_PATH}. Pre-manifest apps must be adopted before using bw.`);
88
89
  }
90
+ if (manifest) {
91
+ const errors = validateAppManifest(manifest);
92
+ if (errors.length > 0) throw new Error(`Invalid BrightWeb app manifest: ${errors.join("; ")}`);
93
+ }
89
94
  return manifest;
90
95
  }
91
96
 
@@ -114,10 +119,14 @@ export function validateAppManifest(manifest) {
114
119
  if (!manifest[key] || typeof manifest[key] !== "object" || Array.isArray(manifest[key])) errors.push(`${key} must be an object`);
115
120
  }
116
121
  if (!Array.isArray(manifest.managedFiles) || manifest.managedFiles.some((entry) => typeof entry !== "string")) errors.push("managedFiles must be an array of paths");
122
+ else for (const [index, relativePath] of manifest.managedFiles.entries()) {
123
+ try { normalizeSafeRelativePath(relativePath, `managedFiles[${index}]`); } catch (error) { errors.push(error.message); }
124
+ }
117
125
  for (const [key, entry] of Object.entries(manifest.modules || {})) {
118
126
  if (!entry || !cleanVersion(entry.version) || typeof entry.installedAt !== "string" || typeof entry.exposed !== "boolean") errors.push(`modules.${key} is invalid`);
119
127
  }
120
128
  for (const [relativePath, entry] of Object.entries(manifest.scaffoldFiles || {})) {
129
+ try { normalizeSafeRelativePath(relativePath, `scaffoldFiles path`); } catch (error) { errors.push(error.message); }
121
130
  if (!entry || typeof entry.module !== "string" || !/^sha256:[a-f0-9]{64}$/.test(entry.hash || "") || !["current", "drifted", "missing"].includes(entry.status) || (entry.intent != null && !["managed", "owned", "skipped"].includes(entry.intent))) errors.push(`scaffoldFiles.${relativePath} is invalid`);
122
131
  }
123
132
  if (manifest.lastDoctor != null && (typeof manifest.lastDoctor.at !== "string" || typeof manifest.lastDoctor.ok !== "boolean")) errors.push("lastDoctor is invalid");
@@ -160,7 +169,7 @@ export async function collectScaffoldFiles(targetDir, selectedModules) {
160
169
  }
161
170
  const result = {};
162
171
  for (const [relativePath, moduleKey] of Array.from(files.entries()).sort()) {
163
- const targetPath = path.join(targetDir, relativePath);
172
+ const targetPath = resolveSafeRelativePath(targetDir, relativePath, "Scaffold file path");
164
173
  if (await pathExists(targetPath)) result[relativePath] = { module: moduleKey, hash: await hashFile(targetPath), status: "current" };
165
174
  }
166
175
  return result;
package/src/constants.mjs CHANGED
@@ -154,6 +154,7 @@ export const PLATFORM_STARTER_FILES = [
154
154
  "app/(shell)/dashboard/dashboard-live-mount.tsx",
155
155
  "app/(shell)/dashboard/page.tsx",
156
156
  "app/api/account/route.ts",
157
+ "app/api/cron/keepalive/route.ts",
157
158
  "app/api/invitations/_dependencies.ts",
158
159
  "app/api/invitations/[invitationId]/route.ts",
159
160
  "app/api/invitations/[invitationId]/accept/route.ts",
@@ -170,16 +171,16 @@ export const PLATFORM_STARTER_FILES = [
170
171
  ];
171
172
 
172
173
  export const APP_DEPENDENCY_DEFAULTS = {
173
- "@brightweblabs/app-shell": "^0.7.4",
174
- "@brightweblabs/core-auth": "^0.7.2",
174
+ "@brightweblabs/app-shell": "^0.8.0",
175
+ "@brightweblabs/core-auth": "^0.7.3",
175
176
  "@brightweblabs/infra": "^0.4.0",
176
- "@brightweblabs/module-admin": "^0.5.10",
177
- "@brightweblabs/module-crm": "^0.10.0",
178
- "@brightweblabs/module-marketing": "^0.2.9",
179
- "@brightweblabs/module-orgs": "^0.3.10",
180
- "@brightweblabs/module-projects": "^0.9.1",
181
- "@brightweblabs/theme": "^0.5.0",
182
- "@brightweblabs/ui": "^1.1.1",
177
+ "@brightweblabs/module-admin": "^0.5.11",
178
+ "@brightweblabs/module-crm": "^0.10.1",
179
+ "@brightweblabs/module-marketing": "^0.2.10",
180
+ "@brightweblabs/module-orgs": "^0.3.11",
181
+ "@brightweblabs/module-projects": "^0.9.2",
182
+ "@brightweblabs/theme": "^0.5.1",
183
+ "@brightweblabs/ui": "^1.2.0",
183
184
  "geist": "1.7.2",
184
185
  "lucide-react": "^1.8.0",
185
186
  "next": "^16.0.0",
package/src/diff.mjs CHANGED
@@ -4,6 +4,7 @@ import { stdout as output } from "node:process";
4
4
  import { findWorkspaceRoot, readAppManifest } from "./app-manifest.mjs";
5
5
  import { pathExists } from "./generator.mjs";
6
6
  import { findTrackedTemplate, scaffoldDrift } from "./scaffold.mjs";
7
+ import { normalizeSafeRelativePath, resolveSafeRelativePath } from "./safe-path.mjs";
7
8
 
8
9
  const HELP = `Usage: bw diff <relpath> [options]\n bw diff --list [options]\n\nOptions:\n --target-dir <path> App directory (defaults to cwd)\n --workspace-root <path> BrightWeb workspace root\n --list Print tracked scaffold drift status\n --help Show this help`;
9
10
 
@@ -58,8 +59,7 @@ export async function diffBrightwebScaffold(relativePath, argvOptions = {}, runt
58
59
  return { list: true, drift };
59
60
  }
60
61
  if (!relativePath) throw new Error("bw diff requires a tracked scaffold <relpath>, or pass --list.");
61
- const normalized = path.normalize(relativePath).replace(/^\.\//, "");
62
- if (path.isAbsolute(relativePath) || normalized.startsWith(`..${path.sep}`)) throw new Error(`Scaffold path must be relative to the app: ${relativePath}`);
62
+ const normalized = normalizeSafeRelativePath(relativePath, "Scaffold path");
63
63
  const workspaceRoot = runtimeOptions.workspaceRoot || argvOptions.workspaceRoot || await findWorkspaceRoot(targetDir);
64
64
  const located = await findTrackedTemplate({ relativePath: normalized, manifest, targetDir, workspaceRoot });
65
65
  if (!located.record) throw new Error(`${normalized} is not a tracked scaffold file.`);
@@ -68,7 +68,7 @@ export async function diffBrightwebScaffold(relativePath, argvOptions = {}, runt
68
68
  output.write(`${warning}\n`);
69
69
  return { supported: false, warning };
70
70
  }
71
- const appPath = path.join(targetDir, normalized);
71
+ const appPath = resolveSafeRelativePath(targetDir, normalized, "Scaffold path");
72
72
  const [templateContent, appContent] = await Promise.all([
73
73
  fs.readFile(located.templatePath, "utf8"),
74
74
  pathExists(appPath) ? fs.readFile(appPath, "utf8") : Promise.resolve(""),
package/src/generator.mjs CHANGED
@@ -839,7 +839,7 @@ export function createPackageJson({
839
839
  version: "0.0.0",
840
840
  scripts: {
841
841
  dev: "next dev",
842
- build: "next build",
842
+ build: "next build --webpack",
843
843
  start: "next start",
844
844
  lint: "tsc --noEmit",
845
845
  },
@@ -897,6 +897,7 @@ export async function createPlatformGlobalsCss(selectedModules) {
897
897
  const sourcePackages = [
898
898
  "@brightweblabs/ui",
899
899
  "@brightweblabs/app-shell",
900
+ "@brightweblabs/core-auth",
900
901
  ...SELECTABLE_MODULES
901
902
  .filter((moduleDefinition) => selectedModules.includes(moduleDefinition.key))
902
903
  .map((moduleDefinition) => moduleDefinition.packageName),
@@ -1018,6 +1019,211 @@ export function createShellConfig(selectedModules) {
1018
1019
  ].join("\n");
1019
1020
  }
1020
1021
 
1022
+ export function createModuleToolbarControlsConfig(selectedModules) {
1023
+ const imports = [];
1024
+ const branches = [];
1025
+
1026
+ if (selectedModules.includes("admin")) {
1027
+ imports.push('import { AdminToolbarControls } from "@brightweblabs/module-admin/ui";');
1028
+ branches.push(' if (pathname === "/admin/users") return <AdminToolbarControls />;');
1029
+ }
1030
+ if (selectedModules.includes("crm")) {
1031
+ imports.push('import { CrmToolbarControls } from "@brightweblabs/module-crm/ui";');
1032
+ branches.push(' if (pathname === "/crm") return <CrmToolbarControls />;');
1033
+ }
1034
+ if (selectedModules.includes("projects")) {
1035
+ imports.push('import { ProjectsToolbarControls } from "@brightweblabs/module-projects/ui";');
1036
+ branches.push(' if (pathname === projectsBaseHref) return <ProjectsToolbarControls />;');
1037
+ }
1038
+
1039
+ return [
1040
+ '"use client";',
1041
+ "",
1042
+ "// MANAGED BY BRIGHTWEB — regenerated when modules are added, removed, or updated.",
1043
+ ...imports,
1044
+ imports.length > 0 ? "" : null,
1045
+ "export function getModuleToolbarControls(pathname: string, projectsBaseHref: string) {",
1046
+ ...branches,
1047
+ " return null;",
1048
+ "}",
1049
+ "",
1050
+ ].filter((line) => line !== null).join("\n");
1051
+ }
1052
+
1053
+ function createOrganizationRoute(methods, enabled) {
1054
+ const lines = ['export const dynamic = "force-dynamic";', ""];
1055
+ for (const { method, handler, context = false } of methods) {
1056
+ if (enabled) {
1057
+ lines.push(
1058
+ `export async function ${method}(request: Request${context ? ', context: { params: Promise<{ id: string }> }' : ""}) {`,
1059
+ ` const { ${handler} } = await import("@brightweblabs/module-orgs");`,
1060
+ ` return ${handler}(request${context ? ", context" : ""});`,
1061
+ "}",
1062
+ "",
1063
+ );
1064
+ } else {
1065
+ lines.push(
1066
+ `export async function ${method}(_request: Request${context ? ', _context: { params: Promise<{ id: string }> }' : ""}) {`,
1067
+ ' return new Response(null, { status: 404 });',
1068
+ "}",
1069
+ "",
1070
+ );
1071
+ }
1072
+ }
1073
+ return lines.join("\n");
1074
+ }
1075
+
1076
+ export function createOptionalModuleRouteFiles(selectedModules) {
1077
+ const adminEnabled = selectedModules.includes("admin");
1078
+ const crmEnabled = selectedModules.includes("crm");
1079
+ const orgsEnabled = selectedModules.includes("orgs")
1080
+ || crmEnabled
1081
+ || selectedModules.includes("marketing")
1082
+ || selectedModules.includes("projects");
1083
+ const dependencyImports = [
1084
+ 'import { requireServerUserAccess } from "@brightweblabs/core-auth/server";',
1085
+ 'import type { InvitationHttpDependencies } from "@brightweblabs/core-auth/routes";',
1086
+ 'import { requireServiceRoleClient } from "@brightweblabs/infra/server";',
1087
+ ];
1088
+
1089
+ if (adminEnabled) {
1090
+ dependencyImports.push(
1091
+ 'import { getAdminUserInvitationDetails, registerUserFromAdminInvitation } from "@brightweblabs/module-admin";',
1092
+ );
1093
+ }
1094
+ if (orgsEnabled) {
1095
+ dependencyImports.push(
1096
+ 'import { acceptOrganizationInvitation, getOrganizationInvitationDetails, registerUserFromOrganizationInvitation } from "@brightweblabs/module-orgs";',
1097
+ );
1098
+ }
1099
+ if (crmEnabled) {
1100
+ dependencyImports.push(
1101
+ 'import { ensureCrmContactForProfile } from "@brightweblabs/module-crm";',
1102
+ );
1103
+ }
1104
+
1105
+ const invitationDependencies = adminEnabled && orgsEnabled && crmEnabled
1106
+ ? [
1107
+ 'import { requireServerUserAccess } from "@brightweblabs/core-auth/server";',
1108
+ 'import { requireServiceRoleClient } from "@brightweblabs/infra/server";',
1109
+ "import {",
1110
+ " getAdminUserInvitationDetails,",
1111
+ " registerUserFromAdminInvitation,",
1112
+ '} from "@brightweblabs/module-admin";',
1113
+ 'import { ensureCrmContactForProfile } from "@brightweblabs/module-crm";',
1114
+ "import {",
1115
+ " acceptOrganizationInvitation,",
1116
+ " getOrganizationInvitationDetails,",
1117
+ " registerUserFromOrganizationInvitation,",
1118
+ '} from "@brightweblabs/module-orgs";',
1119
+ "",
1120
+ "export const invitationHttpDependencies = {",
1121
+ " getServiceClient: requireServiceRoleClient,",
1122
+ " getAccess: requireServerUserAccess,",
1123
+ " getOrganizationInvitation: getOrganizationInvitationDetails,",
1124
+ " getAdminInvitation: getAdminUserInvitationDetails,",
1125
+ " registerOrganizationInvitation: (client: never, input: {",
1126
+ " invitationId: string;",
1127
+ " firstName: string;",
1128
+ " lastName: string;",
1129
+ " password: string;",
1130
+ " }) => registerUserFromOrganizationInvitation(client, {",
1131
+ " ...input,",
1132
+ " ensureCrmContactForProfile,",
1133
+ " }),",
1134
+ " registerAdminInvitation: registerUserFromAdminInvitation,",
1135
+ " acceptOrganizationInvitation: (client: never, input: {",
1136
+ " invitationId: string;",
1137
+ " profileId: string;",
1138
+ " userEmail: string;",
1139
+ " }) => acceptOrganizationInvitation(client, {",
1140
+ " ...input,",
1141
+ " ensureCrmContactForProfile,",
1142
+ " }),",
1143
+ "};",
1144
+ "",
1145
+ ].join("\n")
1146
+ : [
1147
+ ...dependencyImports,
1148
+ "",
1149
+ ...(!adminEnabled ? [
1150
+ "const getAdminUserInvitationDetails = async (_client: never, _invitationId: string) => null;",
1151
+ 'const registerUserFromAdminInvitation = async (_client: never, _input: unknown): Promise<never> => { throw new Error("INVITATION_NOT_FOUND"); };',
1152
+ ] : []),
1153
+ ...(!orgsEnabled ? [
1154
+ "const getOrganizationInvitationDetails = async (_client: never, _invitationId: string) => null;",
1155
+ 'const registerUserFromOrganizationInvitation = async (_client: never, _input: unknown): Promise<never> => { throw new Error("INVITATION_NOT_FOUND"); };',
1156
+ 'const acceptOrganizationInvitation = async (_client: never, _input: unknown): Promise<never> => { throw new Error("Convite não encontrado."); };',
1157
+ ] : []),
1158
+ ...(!crmEnabled ? [
1159
+ "const ensureCrmContactForProfile = async () => ({ success: true as const });",
1160
+ ] : []),
1161
+ (!adminEnabled || !orgsEnabled || !crmEnabled) ? "" : null,
1162
+ "export const invitationHttpDependencies = {",
1163
+ " getServiceClient: requireServiceRoleClient,",
1164
+ " getAccess: requireServerUserAccess,",
1165
+ " getOrganizationInvitation: getOrganizationInvitationDetails,",
1166
+ " getAdminInvitation: getAdminUserInvitationDetails,",
1167
+ " registerOrganizationInvitation: (client: never, input: {",
1168
+ " invitationId: string;",
1169
+ " firstName: string;",
1170
+ " lastName: string;",
1171
+ " password: string;",
1172
+ " }) => registerUserFromOrganizationInvitation(client, {",
1173
+ " ...input,",
1174
+ " ensureCrmContactForProfile,",
1175
+ " }),",
1176
+ " registerAdminInvitation: registerUserFromAdminInvitation,",
1177
+ " acceptOrganizationInvitation: (client: never, input: {",
1178
+ " invitationId: string;",
1179
+ " profileId: string;",
1180
+ " userEmail: string;",
1181
+ " }) => acceptOrganizationInvitation(client, {",
1182
+ " ...input,",
1183
+ " ensureCrmContactForProfile,",
1184
+ " }),",
1185
+ "} satisfies InvitationHttpDependencies;",
1186
+ "",
1187
+ ].filter((line) => line !== null).join("\n");
1188
+
1189
+ return {
1190
+ "app/api/invitations/_dependencies.ts": invitationDependencies,
1191
+ "app/api/organizations/route.ts": createOrganizationRoute([
1192
+ { method: "POST", handler: "handleOrganizationsPostRequest" },
1193
+ ], orgsEnabled),
1194
+ "app/api/organizations/[id]/route.ts": createOrganizationRoute([
1195
+ { method: "PATCH", handler: "handleOrganizationPatchRequest", context: true },
1196
+ ], orgsEnabled),
1197
+ "app/api/organizations/[id]/invitations/route.ts": createOrganizationRoute([
1198
+ { method: "GET", handler: "handleOrganizationInvitationsGetRequest", context: true },
1199
+ { method: "POST", handler: "handleOrganizationInvitationsPostRequest", context: true },
1200
+ ], orgsEnabled),
1201
+ "app/api/organizations/[id]/invitations/[invitationId]/route.ts": orgsEnabled
1202
+ ? [
1203
+ 'export const dynamic = "force-dynamic";',
1204
+ "",
1205
+ "export async function DELETE(",
1206
+ " request: Request,",
1207
+ " context: { params: Promise<{ id: string; invitationId: string }> },",
1208
+ ") {",
1209
+ ' const { handleOrganizationInvitationDeleteRequest } = await import("@brightweblabs/module-orgs");',
1210
+ " return handleOrganizationInvitationDeleteRequest(request, context);",
1211
+ "}",
1212
+ "",
1213
+ ].join("\n")
1214
+ : [
1215
+ 'export const dynamic = "force-dynamic";',
1216
+ "",
1217
+ 'type RouteContext = { params: Promise<{ id: string; invitationId: string }> };',
1218
+ "",
1219
+ "export async function DELETE(_request: Request, _context: RouteContext) {",
1220
+ " return new Response(null, { status: 404 });",
1221
+ "}",
1222
+ "",
1223
+ ].join("\n"),
1224
+ };
1225
+ }
1226
+
1021
1227
  function createSiteConfigFile(slug) {
1022
1228
  const siteName = titleizeSlug(slug);
1023
1229
 
@@ -1361,6 +1567,10 @@ async function scaffoldPlatformProject({
1361
1567
  await ensureDirectory(path.dirname(targetDir));
1362
1568
  await copyDirectory(baseTemplateDir, targetDir);
1363
1569
 
1570
+ if (!workspaceMode && packageManager === "pnpm") {
1571
+ await fs.writeFile(path.join(targetDir, "pnpm-workspace.yaml"), "allowBuilds:\n sharp: true\n");
1572
+ }
1573
+
1364
1574
  for (const moduleDefinition of SELECTABLE_MODULES) {
1365
1575
  if (!selectedModules.includes(moduleDefinition.key)) continue;
1366
1576
  const moduleTemplateDir = path.join(TEMPLATE_ROOT, "modules", moduleDefinition.templateFolder);
@@ -1390,7 +1600,14 @@ async function scaffoldPlatformProject({
1390
1600
  createPlatformBrandConfigFile({ slug: answers.slug, brandValues }),
1391
1601
  );
1392
1602
  await fs.writeFile(path.join(targetDir, "config", "modules.ts"), createPlatformModulesConfigFile(selectedModules));
1603
+ await fs.writeFile(
1604
+ path.join(targetDir, "config", "module-toolbar-controls.tsx"),
1605
+ createModuleToolbarControlsConfig(selectedModules),
1606
+ );
1393
1607
  await fs.writeFile(path.join(targetDir, "config", "shell.ts"), createShellConfig(selectedModules));
1608
+ for (const [relativePath, content] of Object.entries(createOptionalModuleRouteFiles(selectedModules))) {
1609
+ await fs.writeFile(path.join(targetDir, relativePath), content);
1610
+ }
1394
1611
  await fs.writeFile(
1395
1612
  path.join(targetDir, "docs", "ai", "app-context.json"),
1396
1613
  createAppContextFile({
@@ -1464,6 +1681,9 @@ async function scaffoldSiteProject({
1464
1681
 
1465
1682
  await ensureDirectory(path.dirname(targetDir));
1466
1683
  await copyDirectory(baseTemplateDir, targetDir);
1684
+ if (!workspaceMode && packageManager === "pnpm") {
1685
+ await fs.writeFile(path.join(targetDir, "pnpm-workspace.yaml"), "allowBuilds:\n sharp: true\n");
1686
+ }
1467
1687
  await ensureDirectory(path.join(targetDir, "config"));
1468
1688
  await ensureDirectory(path.join(targetDir, "docs", "ai"));
1469
1689
 
@@ -1,5 +1,6 @@
1
1
  import fs from "node:fs/promises";
2
2
  import path from "node:path";
3
+ import { resolveSafeRelativePath } from "./safe-path.mjs";
3
4
  import { TEMPLATE_ROOT, pathExists } from "./generator.mjs";
4
5
 
5
6
  export async function findAppMigrationsDirectory(targetDir) {
@@ -16,7 +17,9 @@ export async function findAppMigrationsDirectory(targetDir) {
16
17
  export async function getModuleMigrations(moduleKey, catalogEntry = {}) {
17
18
  const candidates = [];
18
19
  const configuredPath = catalogEntry.manifest?.database?.migrations;
19
- if (catalogEntry.packageRoot && configuredPath) candidates.push(path.resolve(catalogEntry.packageRoot, configuredPath));
20
+ if (catalogEntry.packageRoot && configuredPath) {
21
+ candidates.push(resolveSafeRelativePath(catalogEntry.packageRoot, configuredPath, `${catalogEntry.key || "Module"} migration manifest path`));
22
+ }
20
23
  if (catalogEntry.packageRoot) candidates.push(path.join(catalogEntry.packageRoot, "migrations"));
21
24
  candidates.push(path.join(TEMPLATE_ROOT, "supabase", "modules", moduleKey, "migrations"));
22
25
  for (const directory of candidates) {
package/src/remove.mjs CHANGED
@@ -12,7 +12,9 @@ import {
12
12
  import {
13
13
  createAppContextFile,
14
14
  createDbInstallPlan,
15
+ createModuleToolbarControlsConfig,
15
16
  createNextConfig,
17
+ createOptionalModuleRouteFiles,
16
18
  createPlatformGlobalsCss,
17
19
  createPlatformModulesConfigFile,
18
20
  createShellConfig,
@@ -20,6 +22,7 @@ import {
20
22
  pathExists,
21
23
  readJsonIfPresent,
22
24
  } from "./generator.mjs";
25
+ import { resolveSafeRelativePath } from "./safe-path.mjs";
23
26
 
24
27
  const HELP = `Usage: bw remove <moduleKey> [options]\n\nOptions:\n --target-dir <path> App directory (defaults to cwd)\n --workspace-root <path> BrightWeb workspace root\n --dry-run Print the removal plan without writing\n --yes Apply the removal plan\n --help Show this help`;
25
28
 
@@ -61,16 +64,18 @@ export async function removeBrightwebModule(moduleKey, argvOptions = {}, runtime
61
64
  const managedWrites = {
62
65
  "next.config.ts": createNextConfig({ template: "platform", selectedModules: remainingModules }),
63
66
  "app/globals.css": await createPlatformGlobalsCss(remainingModules),
67
+ "config/module-toolbar-controls.tsx": createModuleToolbarControlsConfig(remainingModules),
64
68
  "config/modules.ts": createPlatformModulesConfigFile(remainingModules),
65
69
  "config/shell.ts": createShellConfig(remainingModules),
66
70
  "docs/ai/app-context.json": createAppContextFile({ slug: appManifest.app.slug, template: "platform", selectedModules: remainingModules.filter((key) => key !== "orgs"), dbInstallPlan }),
71
+ ...createOptionalModuleRouteFiles(remainingModules),
67
72
  };
68
73
 
69
74
  const cleanFiles = [];
70
75
  const driftedFiles = [];
71
76
  for (const [relativePath, record] of Object.entries(appManifest.scaffoldFiles || {})) {
72
77
  if (record.module !== moduleKey) continue;
73
- const filePath = path.join(targetDir, relativePath);
78
+ const filePath = resolveSafeRelativePath(targetDir, relativePath, "Manifest scaffold file path");
74
79
  if (!(await pathExists(filePath))) continue;
75
80
  if ((record.intent || "managed") === "managed" && await hashFile(filePath) === record.hash) cleanFiles.push(relativePath);
76
81
  else driftedFiles.push(relativePath);
@@ -86,14 +91,25 @@ export async function removeBrightwebModule(moduleKey, argvOptions = {}, runtime
86
91
  if (!apply) return { dryRun: true, moduleKey, cleanFiles, driftedFiles, notice };
87
92
 
88
93
  await fs.writeFile(packagePath, `${JSON.stringify(nextPackageJson, null, 2)}\n`, "utf8");
89
- for (const relativePath of cleanFiles) await fs.rm(path.join(targetDir, relativePath));
94
+ for (const relativePath of cleanFiles) await fs.rm(resolveSafeRelativePath(targetDir, relativePath, "Manifest scaffold file path"));
90
95
  for (const [relativePath, content] of Object.entries(managedWrites)) {
91
96
  const targetPath = path.join(targetDir, relativePath);
92
97
  await fs.mkdir(path.dirname(targetPath), { recursive: true });
93
98
  await fs.writeFile(targetPath, content, "utf8");
94
99
  }
95
100
  delete appManifest.modules[moduleKey];
101
+ if (appManifest.modules.orgs) {
102
+ appManifest.modules.orgs.exposed = remainingModules.some((key) => ["crm", "marketing", "projects"].includes(key));
103
+ }
96
104
  for (const [relativePath, record] of Object.entries(appManifest.scaffoldFiles || {})) if (record.module === moduleKey) delete appManifest.scaffoldFiles[relativePath];
105
+ for (const relativePath of Object.keys(managedWrites)) {
106
+ const record = appManifest.scaffoldFiles[relativePath];
107
+ if (!record) continue;
108
+ const targetPath = resolveSafeRelativePath(targetDir, relativePath, "Manifest scaffold file path");
109
+ if (!(await pathExists(targetPath))) continue;
110
+ record.hash = await hashFile(targetPath);
111
+ record.status = "current";
112
+ }
97
113
  await writeAppManifest(targetDir, appManifest);
98
114
  output.write(`Removed ${moduleKey} package wiring and ${cleanFiles.length} clean scaffold file${cleanFiles.length === 1 ? "" : "s"}. Install dependencies next.\n`);
99
115
  return { dryRun: false, moduleKey, cleanFiles, driftedFiles, notice };
@@ -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,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;
@@ -0,0 +1,106 @@
1
+ create or replace function public.process_marketing_resend_webhook(
2
+ p_provider_event_id text,
3
+ p_event_type text,
4
+ p_provider_message_id text,
5
+ p_payload jsonb,
6
+ p_occurred_at timestamptz
7
+ )
8
+ returns table (duplicate boolean, event_type text, recipient_id uuid)
9
+ language plpgsql
10
+ security definer
11
+ set search_path = public
12
+ as $$
13
+ declare
14
+ recipient public.marketing_campaign_recipients%rowtype;
15
+ inserted_event_id uuid;
16
+ suppression_reason text;
17
+ normalized_email text;
18
+ begin
19
+ if nullif(trim(p_provider_message_id), '') is not null then
20
+ select * into recipient
21
+ from public.marketing_campaign_recipients
22
+ where provider_message_id = p_provider_message_id
23
+ order by id
24
+ limit 1;
25
+ end if;
26
+
27
+ insert into public.marketing_message_events (
28
+ campaign_id,
29
+ recipient_id,
30
+ contact_id,
31
+ provider,
32
+ event_type,
33
+ provider_event_id,
34
+ payload,
35
+ occurred_at
36
+ ) values (
37
+ recipient.campaign_id,
38
+ recipient.id,
39
+ recipient.contact_id,
40
+ 'resend',
41
+ p_event_type,
42
+ nullif(trim(p_provider_event_id), ''),
43
+ p_payload,
44
+ coalesce(p_occurred_at, now())
45
+ )
46
+ on conflict (provider, provider_event_id) where provider_event_id is not null
47
+ do nothing
48
+ returning id into inserted_event_id;
49
+
50
+ if inserted_event_id is null then
51
+ return query select true, p_event_type, recipient.id;
52
+ return;
53
+ end if;
54
+
55
+ if recipient.id is not null and p_event_type in ('delivered', 'sent') then
56
+ update public.marketing_campaign_recipients
57
+ set status = 'sent',
58
+ sent_at = coalesce(p_occurred_at, now()),
59
+ error = null
60
+ where id = recipient.id;
61
+ elsif recipient.id is not null and p_event_type = 'failed' then
62
+ update public.marketing_campaign_recipients
63
+ set status = 'failed',
64
+ error = 'Resend reported delivery failure.'
65
+ where id = recipient.id;
66
+ elsif recipient.id is not null and p_event_type in ('bounced', 'complained', 'unsubscribed') then
67
+ suppression_reason := case p_event_type
68
+ when 'bounced' then 'bounced'
69
+ when 'complained' then 'complained'
70
+ else 'unsubscribed_all'
71
+ end;
72
+ normalized_email := lower(trim(recipient.email));
73
+
74
+ update public.marketing_campaign_recipients
75
+ set status = 'suppressed',
76
+ error = 'Resend reported ' || p_event_type || '.',
77
+ next_attempt_at = null
78
+ where id = recipient.id;
79
+
80
+ insert into public.marketing_suppressions (email, reason, source)
81
+ values (normalized_email, suppression_reason, 'resend_webhook')
82
+ on conflict (email) do update
83
+ set reason = excluded.reason,
84
+ source = excluded.source;
85
+
86
+ update public.marketing_campaign_recipients
87
+ set status = 'suppressed',
88
+ error = 'Email suppressed: ' || suppression_reason || '.',
89
+ next_attempt_at = null
90
+ where lower(trim(email)) = normalized_email
91
+ and status in ('queued', 'sending');
92
+
93
+ if recipient.contact_id is not null then
94
+ update public.marketing_subscriptions
95
+ set status = 'unsubscribed',
96
+ unsubscribed_at = coalesce(p_occurred_at, now())
97
+ where contact_id = recipient.contact_id;
98
+ end if;
99
+ end if;
100
+
101
+ return query select false, p_event_type, recipient.id;
102
+ end;
103
+ $$;
104
+
105
+ revoke all on function public.process_marketing_resend_webhook(text, text, text, jsonb, timestamptz) from public;
106
+ grant execute on function public.process_marketing_resend_webhook(text, text, text, jsonb, timestamptz) to service_role;