@getstrata/starter 1.0.9 → 1.1.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/dist/cli.js CHANGED
@@ -2,7 +2,7 @@
2
2
  // @bun
3
3
 
4
4
  // src/generate.ts
5
- import { existsSync as existsSync2, mkdirSync as mkdirSync2, rmSync as rmSync2 } from "fs";
5
+ import { copyFileSync, existsSync as existsSync2, mkdirSync as mkdirSync2, rmSync as rmSync2 } from "fs";
6
6
  import { basename, join as join2, resolve } from "path";
7
7
 
8
8
  // src/copy.ts
@@ -186,11 +186,7 @@ function dockerLayerForNeeded(layers, enabled) {
186
186
  if (!enabled || needed.length === 0) {
187
187
  return { enabled: false, services: emptyDockerServices() };
188
188
  }
189
- const names = [...needed];
190
- if (dockerDatabaseService(layers.database)) {
191
- names.push("adminer");
192
- }
193
- return { enabled: true, services: enableDockerServices(names) };
189
+ return { enabled: true, services: enableDockerServices(needed) };
194
190
  }
195
191
 
196
192
  // src/presets.ts
@@ -1121,18 +1117,6 @@ async function resolveStarterPlan(flags, injected) {
1121
1117
  }
1122
1118
 
1123
1119
  // src/renderAuthFlows.ts
1124
- function ph(layers, count, start = 1) {
1125
- if (layers.database === "postgres") {
1126
- return Array.from({ length: count }, (_, index) => `$${start + index}`).join(", ");
1127
- }
1128
- return Array.from({ length: count }, () => "?").join(", ");
1129
- }
1130
- function sqlFalse(layers) {
1131
- return layers.database === "postgres" ? "false" : "0";
1132
- }
1133
- function sqlTrue(layers) {
1134
- return layers.database === "postgres" ? "true" : "1";
1135
- }
1136
1120
  function renderPendingMfaTs() {
1137
1121
  return `import { createHmac, timingSafeEqual } from "node:crypto";
1138
1122
  import { sessionSecret } from "./config.ts";
@@ -1147,11 +1131,13 @@ function sign(userId: number, issuedAt: number): string {
1147
1131
 
1148
1132
  export function pendingMfaSetCookie(userId: number): string {
1149
1133
  const issuedAt = Date.now();
1150
- return \`\${COOKIE}=\${sign(userId, issuedAt)}; Path=/; HttpOnly; SameSite=Lax; Max-Age=600\`;
1134
+ const secure = process.env.APP_ENV === "production" || process.env.APP_ENV === "staging" ? "; Secure" : "";
1135
+ return \`\${COOKIE}=\${sign(userId, issuedAt)}; Path=/; HttpOnly; SameSite=Lax; Max-Age=600\${secure}\`;
1151
1136
  }
1152
1137
 
1153
1138
  export function pendingMfaClearCookie(): string {
1154
- return \`\${COOKIE}=; Path=/; HttpOnly; SameSite=Lax; Max-Age=0\`;
1139
+ const secure = process.env.APP_ENV === "production" || process.env.APP_ENV === "staging" ? "; Secure" : "";
1140
+ return \`\${COOKIE}=; Path=/; HttpOnly; SameSite=Lax; Max-Age=0\${secure}\`;
1155
1141
  }
1156
1142
 
1157
1143
  export function readPendingMfaUserId(request: Request): number | null {
@@ -1193,17 +1179,12 @@ function renderAuthModule(layers) {
1193
1179
  const verify = Boolean(layers.extras.emailVerification);
1194
1180
  const jsonApi = authUsesToken(layers.auth) || authUsesJwt(layers.auth);
1195
1181
  const tenantInsert = usesTenantTable(layers.tenancy);
1196
- const insertCols = tenantInsert ? "name, email, password, is_admin, tenant_id" : "name, email, password, is_admin";
1197
- const insertPh = tenantInsert ? ph(layers, 5) : ph(layers, 4);
1198
- const insertTail = tenantInsert ? `, ${sqlFalse(layers)}, 1` : `, ${sqlFalse(layers)}`;
1199
- const passwordPh = `${ph(layers, 1)}`;
1200
- const emailPh = `${ph(layers, 1, 2)}`;
1201
- const idPh = `${ph(layers, 1, 2)}`;
1202
- const verifiedPh = `${ph(layers, 1)}`;
1203
- const mfaUpdatePh = `${ph(layers, 1)}, ${ph(layers, 1, 2)}, ${ph(layers, 1, 3)}`;
1204
- const mfaIdPh = `${ph(layers, 1, 4)}`;
1182
+ const tenantCreateField = tenantInsert ? `
1183
+ tenant_id: currentTenantId(),` : "";
1184
+ const samlTenantCreateField = tenantInsert ? `
1185
+ tenant_id: currentTenantId(),` : "";
1205
1186
  const imports = [];
1206
- if (authUsesToken(layers.auth)) {
1187
+ if (authUsesToken(layers.auth) || cookie) {
1207
1188
  imports.push(`import { randomBytes } from "node:crypto";`);
1208
1189
  }
1209
1190
  imports.push(`import { CORE_AUTH_TOKEN } from "@getstrata/bootstrap/config";`);
@@ -1212,6 +1193,7 @@ function renderAuthModule(layers) {
1212
1193
  imports.push(`import { parseFormBody } from "@getstrata/bootstrap/web/forms";`);
1213
1194
  imports.push(`import { wrapWebLogin, wrapWebRegister } from "@getstrata/bootstrap/web/routing";`);
1214
1195
  imports.push(`import type { CookieSessionAuthManager } from "@getstrata/bootstrap/web/session";`);
1196
+ imports.push(`import { createSamlServiceProvider } from "@getstrata/core/auth/saml/samlServiceProvider";`);
1215
1197
  }
1216
1198
  if (jsonApi) {
1217
1199
  imports.push(`import { AuthManager } from "@getstrata/core/auth/guard";`);
@@ -1220,28 +1202,49 @@ function renderAuthModule(layers) {
1220
1202
  imports.push(`import { jwtTtlSeconds, signJwt } from "@getstrata/core/auth/jwt";`);
1221
1203
  }
1222
1204
  imports.push(`import { hashPassword, verifyPassword } from "@getstrata/core/auth/password";`);
1205
+ imports.push(`import { AUTH_ONE_TIME_PURPOSES, consumeOneTimeToken, generateOneTimeToken, hashOneTimeToken, revokeUserSessions as revokeStoredUserSessions } from "@getstrata/core/auth/oneTimeToken";`);
1206
+ imports.push(`import { completePasswordLogin, persistConsumedRecoveryHash } from "@getstrata/core/auth/passwordLogin";`);
1207
+ if (mfa) {
1208
+ imports.push(`import { createPasswordConfirmCookie } from "@getstrata/core/auth/passwordConfirmCookie";`);
1209
+ }
1210
+ imports.push(`import { emailRule, minLength, required, stringRule, validateObject } from "@getstrata/core/validation/rules";`);
1211
+ imports.push(`import { parseJsonBody } from "@getstrata/core/http/validation";`);
1223
1212
  if (authUsesToken(layers.auth)) {
1224
1213
  imports.push(`import { hashApiToken } from "@getstrata/core/auth/tokenHash";`);
1225
1214
  imports.push(`import { sqlTimestamp } from "@getstrata/core/database/dialect";`);
1226
1215
  imports.push(`import { resolveDefaultTokenExpiryDays } from "@getstrata/core/security/tokenExpiry";`);
1227
1216
  }
1228
1217
  if (mfa) {
1229
- imports.push(`import { protectMfaSecret, revealMfaSecret } from "@getstrata/core/crypto/mfaSecret";`);
1218
+ imports.push(`import { protectMfaSecret } from "@getstrata/core/crypto/mfaSecret";`);
1230
1219
  }
1231
1220
  if (cookie) {
1232
1221
  imports.push(`import { flashResponse } from "@getstrata/core/http/flashSession";`);
1222
+ if (mfa) {
1223
+ imports.push(`import { sanitizeInternalPath } from "@getstrata/core/http/safeInternalPath";`);
1224
+ }
1225
+ imports.push(`import { createOAuthState, verifyOAuthState } from "@getstrata/core/security/oauthState";`);
1233
1226
  }
1234
- if (jsonApi) {
1227
+ if (jsonApi || cookie) {
1235
1228
  imports.push(`import { jsonResponse, withErrorHandling } from "@getstrata/core/http/response";`);
1229
+ imports.push(`import { resolveCsrfTokenForRequest } from "@getstrata/core/http/csrfToken";`);
1236
1230
  }
1237
- imports.push(`import { absoluteTemporarySignedUrl, assertValidSignature } from "@getstrata/core/http/signedUrl";`);
1231
+ imports.push(cookie && verify ? `import { absoluteTemporarySignedUrl, assertValidSignature } from "@getstrata/core/http/signedUrl";` : `import { absoluteTemporarySignedUrl } from "@getstrata/core/http/signedUrl";`);
1238
1232
  imports.push(`import { mailer } from "@getstrata/core/mail/mailer";`);
1239
1233
  if (mfa) {
1240
- imports.push(`import { generateRecoveryCodes, hashRecoveryCode, recoveryCodeMatches } from "@getstrata/core/security/recoveryCodes";`);
1234
+ imports.push(`import { generateRecoveryCodes, hashRecoveryCode } from "@getstrata/core/security/recoveryCodes";`);
1241
1235
  imports.push(`import { buildOtpauthUrl, generateTotpSecret, verifyTotp } from "@getstrata/core/security/totp";`);
1242
1236
  }
1237
+ if (tenantInsert) {
1238
+ imports.push(`import { currentTenantId } from "@getstrata/core/tenant/tenantContext";`);
1239
+ }
1240
+ imports.push(`import { runWithMigrationBypassForIdentifier } from "@getstrata/core/tenant/databaseTenantContext";`);
1243
1241
  imports.push(`import { starterAuthDirectory } from "../../bootstrap/authDirectory.ts";`);
1244
1242
  imports.push(`import { getSql } from "../../bootstrap/database.ts";`);
1243
+ imports.push(`import { User } from "../../models/User.ts";`);
1244
+ imports.push(`import { AuthOneTimeToken } from "../../models/AuthOneTimeToken.ts";`);
1245
+ if (authUsesToken(layers.auth)) {
1246
+ imports.push(`import { ApiToken } from "../../models/ApiToken.ts";`);
1247
+ }
1245
1248
  if (cookie) {
1246
1249
  imports.push(`import { renderPage } from "../../lib/view.ts";`);
1247
1250
  }
@@ -1249,14 +1252,85 @@ function renderAuthModule(layers) {
1249
1252
  imports.push(`import { pendingMfaClearCookie, pendingMfaSetCookie, readPendingMfaUserId } from "../../bootstrap/pendingMfa.ts";`);
1250
1253
  }
1251
1254
  const helpers = `
1252
- async function sendSignedMail(to: string, subject: string, path: string, query: Record<string, string>) {
1253
- const link = absoluteTemporarySignedUrl(path, 3600, query);
1255
+ function parseLoginCredentials(payload: unknown) {
1256
+ const body = validateObject(payload, {
1257
+ email: [required(), stringRule(), emailRule()],
1258
+ password: [required(), stringRule()],
1259
+ mfa_code: [stringRule()],
1260
+ });
1261
+ return {
1262
+ email: String(body.email).toLowerCase(),
1263
+ password: String(body.password ?? ""),
1264
+ mfa_code: typeof body.mfa_code === "string" ? body.mfa_code : undefined,
1265
+ };
1266
+ }
1267
+
1268
+ function parseRegisterCredentials(payload: unknown) {
1269
+ const body = validateObject(payload, {
1270
+ name: [required(), stringRule()],
1271
+ email: [required(), stringRule(), emailRule()],
1272
+ password: [required(), stringRule(), minLength(8)],
1273
+ });
1274
+ return {
1275
+ name: String(body.name ?? ""),
1276
+ email: String(body.email).toLowerCase(),
1277
+ password: String(body.password ?? ""),
1278
+ };
1279
+ }
1280
+
1281
+ function parseForgotPasswordEmail(payload: unknown) {
1282
+ const body = validateObject(payload, {
1283
+ email: [required(), stringRule(), emailRule()],
1284
+ });
1285
+ return String(body.email).toLowerCase();
1286
+ }
1287
+
1288
+ async function runAuthWrite<T>(identifier: string | number, callback: () => Promise<T>): Promise<T> {
1289
+ return await runWithMigrationBypassForIdentifier(identifier, callback);
1290
+ }
1291
+
1292
+ async function issueSignedAuthMail(to: string, subject: string, path: string, purpose: string, userId: number, extra: Record<string, string> = {}) {
1293
+ const issued = generateOneTimeToken();
1294
+ const expiresAt = new Date(Date.now() + 60 * 60 * 1000);
1295
+ await runAuthWrite(userId, async () => {
1296
+ await AuthOneTimeToken.create({
1297
+ purpose,
1298
+ user_id: userId,
1299
+ token_hash: issued.hash,
1300
+ expires_at: expiresAt.toISOString(),
1301
+ });
1302
+ });
1303
+ const link = absoluteTemporarySignedUrl(path, 3600, { ...extra, token: issued.plain });
1254
1304
  await mailer().send({
1255
1305
  to,
1256
1306
  subject,
1257
1307
  body: \`\${subject}\\n\\n\${link}\\n\`,
1258
1308
  });
1259
1309
  }
1310
+
1311
+ async function consumeSignedAuthToken(request: Request, purpose: string): Promise<number | null> {
1312
+ const token = new URL(request.url).searchParams.get("token") ?? "";
1313
+ if (!token) {
1314
+ return null;
1315
+ }
1316
+ return await runAuthWrite(hashOneTimeToken(token), async () => consumeOneTimeToken(getSql(), purpose, token));
1317
+ }
1318
+
1319
+ async function revokeUserSessions(userId: number) {
1320
+ await runAuthWrite(userId, async () => {
1321
+ await revokeStoredUserSessions(getSql(), userId);
1322
+ });
1323
+ }
1324
+
1325
+ async function persistAuthRecovery(
1326
+ userId: number,
1327
+ currentRaw: string | null | undefined,
1328
+ consumedHash: string | undefined,
1329
+ ) {
1330
+ await runAuthWrite(userId, async () => {
1331
+ await persistConsumedRecoveryHash(getSql(), userId, currentRaw, consumedHash);
1332
+ });
1333
+ }
1260
1334
  ${cookie ? `
1261
1335
  function redirectTo(path: string, status = 302): Response {
1262
1336
  return new Response(null, { status, headers: { location: path } });
@@ -1273,32 +1347,39 @@ function sessionUser(user: { id: number; name?: string | null; email?: string |
1273
1347
  ` : ""}`;
1274
1348
  const tokenLogin = authUsesToken(layers.auth) ? `
1275
1349
  "/api/v1/auth/login": {
1276
- POST: kernel.wrap("api", withErrorHandling(async (request) => {
1277
- const body = (await request.json()) as { email?: string; password?: string };
1278
- const email = (body.email ?? "").trim().toLowerCase();
1279
- const password = body.password ?? "";
1280
- const user = await starterAuthDirectory.verifyCredentials?.(email, password);
1281
- if (!user) {
1350
+ POST: kernel.wrap("api", kernel.wrapLogin(withErrorHandling(async (request) => {
1351
+ const { email, password, mfa_code } = await parseJsonBody(request, parseLoginCredentials);
1352
+ const record = await starterAuthDirectory.findByEmail?.(email);
1353
+ if (!record?.password || !(await verifyPassword(password, record.password))) {
1282
1354
  return jsonResponse({ error: "Invalid credentials" }, { status: 422 });
1283
1355
  }
1356
+ const mfaResult = completePasswordLogin(record, { mfaCode: mfa_code });
1357
+ if (!mfaResult.ok) {
1358
+ return jsonResponse({ error: mfaResult.error }, { status: mfaResult.error === "mfa_required" ? 401 : 422 });
1359
+ }
1360
+ await persistAuthRecovery(record.id, record.mfa_recovery_codes, mfaResult.consumedRecoveryHash);
1361
+ const user = { id: record.id, role: record.role };
1284
1362
  const plain = \`strp_\${randomBytes(24).toString("hex")}\`;
1285
1363
  // API_TOKEN_DEFAULT_EXPIRY_DAYS bounds every minted token; unset means no expiry.
1286
1364
  const expiryDays = resolveDefaultTokenExpiryDays();
1287
1365
  const expiresAt = expiryDays
1288
1366
  ? new Date(Date.now() + expiryDays * 24 * 60 * 60 * 1000)
1289
1367
  : null;
1290
- await getSql().unsafe(
1291
- "INSERT INTO api_tokens (user_id, name, token_hash, abilities, expires_at) VALUES (${ph(layers, 5)})",
1292
- [
1293
- user.id,
1294
- "spa",
1295
- hashApiToken(plain),
1296
- JSON.stringify(["profile:read"]),
1297
- expiresAt ? sqlTimestamp(expiresAt) : null,
1298
- ],
1299
- );
1300
- return jsonResponse({ token: plain, expires_at: expiresAt?.toISOString() ?? null });
1301
- })),
1368
+ await runAuthWrite(user.id, async () => {
1369
+ await ApiToken.create({
1370
+ user_id: user.id,
1371
+ name: "spa",
1372
+ token_hash: hashApiToken(plain),
1373
+ abilities: JSON.stringify([]),
1374
+ expires_at: expiresAt ? sqlTimestamp(expiresAt) : null,
1375
+ });
1376
+ });
1377
+ const payload = jsonResponse({ token: plain, expires_at: expiresAt?.toISOString() ?? null });
1378
+ ${cookie ? `const sessionAuth = dependencies.container.resolve<CookieSessionAuthManager>(CORE_AUTH_TOKEN);
1379
+ const { setCookie } = await sessionAuth.signIn(sessionUser(record));
1380
+ payload.headers.append("set-cookie", setCookie);
1381
+ ` : ""}return payload;
1382
+ }))),
1302
1383
  },
1303
1384
  "/api/v1/auth/me": {
1304
1385
  GET: kernel.wrapApi(async (request) => {
@@ -1314,43 +1395,50 @@ function sessionUser(user: { id: number; name?: string | null; email?: string |
1314
1395
  },` : "";
1315
1396
  const jsonRegister = jsonApi ? `
1316
1397
  "/api/v1/auth/register": {
1317
- POST: kernel.wrap("api", withErrorHandling(async (request) => {
1318
- const body = (await request.json()) as { name?: string; email?: string; password?: string };
1319
- const name = (body.name ?? "").trim();
1320
- const email = (body.email ?? "").trim().toLowerCase();
1321
- const password = body.password ?? "";
1322
- if (!name || !email || password.length < 8) {
1323
- return jsonResponse({ error: "Name, email, and a password of 8+ characters are required." }, { status: 422 });
1398
+ POST: kernel.wrap("api", kernel.wrapRegister(withErrorHandling(async (request) => {
1399
+ if ((process.env.FEATURE_REGISTRATION ?? "true") === "false") {
1400
+ return jsonResponse({ error: "Not found" }, { status: 404 });
1324
1401
  }
1325
- if (await starterAuthDirectory.findByEmail?.(email)) {
1326
- return jsonResponse({ error: "Email is already registered." }, { status: 422 });
1402
+ const { name, email, password } = await parseJsonBody(request, parseRegisterCredentials);
1403
+ const existing = await starterAuthDirectory.findByEmail?.(email);
1404
+ await hashPassword(password);
1405
+ if (existing) {
1406
+ return jsonResponse({ ok: true }, { status: 201 });
1327
1407
  }
1328
1408
  const hashed = await hashPassword(password);
1329
- await getSql().unsafe(
1330
- "INSERT INTO users (${insertCols}) VALUES (${insertPh})",
1331
- [name, email, hashed${insertTail}],
1332
- );
1409
+ await runAuthWrite(email, async () => {
1410
+ await User.create({
1411
+ name,
1412
+ email,
1413
+ password: hashed,
1414
+ is_admin: false,${tenantCreateField}
1415
+ });
1416
+ });
1333
1417
  const created = await starterAuthDirectory.findByEmail?.(email);
1334
1418
  ${verify ? `if (created) {
1335
- await sendSignedMail(email, "Verify your email", "/api/v1/auth/verify-email", { id: String(created.id) });
1419
+ await issueSignedAuthMail(email, "Verify your email", "/api/v1/auth/verify-email", AUTH_ONE_TIME_PURPOSES.emailVerify, created.id, { id: String(created.id) });
1336
1420
  }` : ""}
1337
1421
  return jsonResponse({ ok: true }, { status: 201 });
1338
- })),
1422
+ }))),
1339
1423
  },` : "";
1340
1424
  const jwtLogin = authUsesJwt(layers.auth) ? `
1341
1425
  "/api/auth/token": {
1342
- POST: kernel.wrap("api", withErrorHandling(async (request) => {
1343
- const body = (await request.json()) as { email?: string; password?: string };
1344
- const email = (body.email ?? "").trim().toLowerCase();
1345
- const password = body.password ?? "";
1346
- const user = await starterAuthDirectory.verifyCredentials?.(email, password);
1347
- if (!user) {
1426
+ POST: kernel.wrap("api", kernel.wrapLogin(withErrorHandling(async (request) => {
1427
+ const { email, password, mfa_code } = await parseJsonBody(request, parseLoginCredentials);
1428
+ const record = await starterAuthDirectory.findByEmail?.(email);
1429
+ if (!record?.password || !(await verifyPassword(password, record.password))) {
1348
1430
  return jsonResponse({ error: "Invalid credentials" }, { status: 422 });
1349
1431
  }
1432
+ const mfaResult = completePasswordLogin(record, { mfaCode: mfa_code });
1433
+ if (!mfaResult.ok) {
1434
+ return jsonResponse({ error: mfaResult.error }, { status: mfaResult.error === "mfa_required" ? 401 : 422 });
1435
+ }
1436
+ await persistAuthRecovery(record.id, record.mfa_recovery_codes, mfaResult.consumedRecoveryHash);
1437
+ const user = { id: record.id, role: record.role, emailVerifiedAt: record.email_verified_at ?? null };
1350
1438
  const token = signJwt({
1351
1439
  sub: user.id,
1352
1440
  role: user.role,
1353
- abilities: user.role === "admin" ? ["profile:read", "reports:export"] : ["profile:read"],${verify ? `
1441
+ abilities: [],${verify ? `
1354
1442
  emailVerifiedAt: user.emailVerifiedAt ?? null,` : ""}
1355
1443
  });
1356
1444
  return jsonResponse({
@@ -1358,7 +1446,7 @@ function sessionUser(user: { id: number; name?: string | null; email?: string |
1358
1446
  token_type: "bearer",
1359
1447
  expires_in: jwtTtlSeconds(),
1360
1448
  });
1361
- })),
1449
+ }))),
1362
1450
  },` : "";
1363
1451
  const apiUser = jsonApi ? `
1364
1452
  "/api/user": {
@@ -1366,64 +1454,195 @@ function sessionUser(user: { id: number; name?: string | null; email?: string |
1366
1454
  const user = await dependencies.container.resolve<AuthManager>(CORE_AUTH_TOKEN).requireUser(request);
1367
1455
  return jsonResponse({ id: user.id, role: user.role ?? "member" });
1368
1456
  }),
1457
+ },
1458
+ "/api/v1/auth/logout": {
1459
+ POST: kernel.wrap("api", withErrorHandling(async (request) => {
1460
+ const payload = jsonResponse({ ok: true });
1461
+ ${cookie ? `const sessionAuth = dependencies.container.resolve<CookieSessionAuthManager>(CORE_AUTH_TOKEN);
1462
+ const { setCookie } = await sessionAuth.signOut(request);
1463
+ payload.headers.append("set-cookie", setCookie);
1464
+ ` : ""}return payload;
1465
+ })),
1369
1466
  },` : "";
1370
1467
  const jsonPassword = jsonApi ? `
1371
1468
  "/api/v1/auth/forgot-password": {
1372
1469
  POST: kernel.wrap("api", withErrorHandling(async (request) => {
1373
- const body = (await request.json()) as { email?: string };
1374
- const email = (body.email ?? "").trim().toLowerCase();
1470
+ const email = await parseJsonBody(request, parseForgotPasswordEmail);
1375
1471
  const user = await starterAuthDirectory.findByEmail?.(email);
1376
1472
  if (user) {
1377
- await sendSignedMail(email, "Reset your password", "/api/v1/auth/reset-password", { email });
1473
+ await issueSignedAuthMail(email, "Reset your password", "/api/v1/auth/reset-password", AUTH_ONE_TIME_PURPOSES.passwordReset, user.id, { email });
1378
1474
  }
1379
1475
  return jsonResponse({ ok: true });
1380
1476
  })),
1381
1477
  },
1382
1478
  "/api/v1/auth/reset-password": {
1383
- POST: kernel.wrap("api", withErrorHandling(async (request) => {
1384
- assertValidSignature(request);
1479
+ POST: kernel.wrap("api", kernel.wrapSigned(withErrorHandling(async (request) => {
1480
+ const userId = await consumeSignedAuthToken(request, AUTH_ONE_TIME_PURPOSES.passwordReset);
1385
1481
  const body = (await request.json()) as { password?: string };
1386
- const email = new URL(request.url).searchParams.get("email") ?? "";
1387
- if (!email || !(body.password && body.password.length >= 8)) {
1388
- return jsonResponse({ error: "Invalid reset payload." }, { status: 422 });
1482
+ const nextPassword = body.password ?? "";
1483
+ if (!userId || nextPassword.length < 8) {
1484
+ return jsonResponse({ error: "Invalid or expired reset link." }, { status: 403 });
1389
1485
  }
1390
- await getSql().unsafe(
1391
- "UPDATE users SET password = ${passwordPh} WHERE email = ${emailPh}",
1392
- [await hashPassword(body.password), email],
1393
- );
1486
+ await runAuthWrite(userId, async () => {
1487
+ const user = await User.find(userId);
1488
+ await user?.update({ password: await hashPassword(nextPassword) });
1489
+ });
1490
+ await revokeUserSessions(userId);
1394
1491
  return jsonResponse({ ok: true });
1395
- })),
1492
+ }))),
1396
1493
  },` : "";
1397
1494
  const jsonVerify = jsonApi && verify ? `
1398
1495
  "/api/v1/auth/verify-email": {
1399
- POST: kernel.wrap("api", withErrorHandling(async (request) => {
1400
- assertValidSignature(request);
1401
- const id = Number.parseInt(new URL(request.url).searchParams.get("id") ?? "", 10);
1402
- if (!Number.isInteger(id) || id <= 0) {
1403
- return jsonResponse({ error: "Invalid verification link." }, { status: 422 });
1496
+ POST: kernel.wrap("api", kernel.wrapSigned(withErrorHandling(async (request) => {
1497
+ const id = await consumeSignedAuthToken(request, AUTH_ONE_TIME_PURPOSES.emailVerify);
1498
+ if (!id) {
1499
+ return jsonResponse({ error: "Invalid or expired verification link." }, { status: 403 });
1404
1500
  }
1405
- await getSql().unsafe(
1406
- "UPDATE users SET email_verified_at = ${verifiedPh} WHERE id = ${idPh}",
1407
- [${nowTimestampLiteral(layers.database)}, id],
1408
- );
1501
+ await runAuthWrite(id, async () => {
1502
+ const user = await User.find(id);
1503
+ await user?.update({ email_verified_at: ${nowTimestampLiteral(layers.database)} });
1504
+ });
1409
1505
  return jsonResponse({ ok: true });
1506
+ }))),
1507
+ },` : "";
1508
+ const csrfRoute = `
1509
+ "/api/v1/auth/csrf": {
1510
+ GET: kernel.wrap("api", withErrorHandling(async (request) => {
1511
+ return jsonResponse({ token: resolveCsrfTokenForRequest(request) });
1512
+ })),
1513
+ },`;
1514
+ const cookieJsonAuth = cookie && !jsonApi ? `
1515
+ "/api/v1/auth/login": {
1516
+ POST: kernel.wrap("api", kernel.wrapLogin(withErrorHandling(async (request) => {
1517
+ const { email, password, mfa_code } = await parseJsonBody(request, parseLoginCredentials);
1518
+ const record = await starterAuthDirectory.findByEmail?.(email);
1519
+ if (!record?.password || !(await verifyPassword(password, record.password))) {
1520
+ return jsonResponse({ error: "Invalid credentials" }, { status: 422 });
1521
+ }
1522
+ const mfaResult = completePasswordLogin(record, { mfaCode: mfa_code });
1523
+ if (!mfaResult.ok) {
1524
+ return jsonResponse({ error: mfaResult.error }, { status: mfaResult.error === "mfa_required" ? 401 : 422 });
1525
+ }
1526
+ await persistAuthRecovery(record.id, record.mfa_recovery_codes, mfaResult.consumedRecoveryHash);
1527
+ const sessionAuth = dependencies.container.resolve<CookieSessionAuthManager>(CORE_AUTH_TOKEN);
1528
+ const { setCookie } = await sessionAuth.signIn(sessionUser(record));
1529
+ const payload = jsonResponse({ ok: true });
1530
+ payload.headers.append("set-cookie", setCookie);
1531
+ return payload;
1532
+ }))),
1533
+ },
1534
+ "/api/v1/auth/me": {
1535
+ GET: kernel.wrapApi(async (request) => {
1536
+ const user = await dependencies.container.resolve<CookieSessionAuthManager>(CORE_AUTH_TOKEN).requireUser(request);
1537
+ const record = await starterAuthDirectory.findByIdOrThrow(Number(user.id));
1538
+ return jsonResponse({
1539
+ id: record.id,
1540
+ name: record.name ?? record.email,
1541
+ email: record.email,
1542
+ role: record.role,
1543
+ });
1544
+ }),
1545
+ },
1546
+ "/api/v1/auth/logout": {
1547
+ POST: kernel.wrap("api", withErrorHandling(async (request) => {
1548
+ const sessionAuth = dependencies.container.resolve<CookieSessionAuthManager>(CORE_AUTH_TOKEN);
1549
+ const { setCookie } = await sessionAuth.signOut(request);
1550
+ const payload = jsonResponse({ ok: true });
1551
+ payload.headers.append("set-cookie", setCookie);
1552
+ return payload;
1410
1553
  })),
1411
1554
  },` : "";
1412
- const apiRoutes = jsonApi ? `
1555
+ const samlRoutes = cookie ? `
1556
+ "/auth/saml": {
1557
+ GET: kernel.wrap("api", withErrorHandling(async () => {
1558
+ if (process.env.FEATURE_SAML !== "true") {
1559
+ return new Response("Not found", { status: 404 });
1560
+ }
1561
+ const issued = createOAuthState();
1562
+ const url = await createSamlServiceProvider().authorizationUrl(issued.state);
1563
+ return new Response(null, { status: 302, headers: { location: url } });
1564
+ })),
1565
+ },
1566
+ "/auth/saml/acs": {
1567
+ POST: kernel.wrap("api", withErrorHandling(async (request) => {
1568
+ if (process.env.FEATURE_SAML !== "true") {
1569
+ return new Response("Not found", { status: 404 });
1570
+ }
1571
+ const form = await request.formData();
1572
+ const samlResponse = String(form.get("SAMLResponse") ?? "");
1573
+ const relayState = String(form.get("RelayState") ?? "");
1574
+ if (!verifyOAuthState(request, relayState)) {
1575
+ return jsonResponse({ error: "Invalid SAML state." }, { status: 403 });
1576
+ }
1577
+ const profile = await createSamlServiceProvider()
1578
+ .consumePost(samlResponse, relayState)
1579
+ .catch(() => null);
1580
+ if (!profile) {
1581
+ return jsonResponse({ error: "Invalid SAML response." }, { status: 400 });
1582
+ }
1583
+ let record = await starterAuthDirectory.findByEmail?.(profile.email);
1584
+ if (!record) {
1585
+ if ((process.env.FEATURE_REGISTRATION ?? "true") === "false") {
1586
+ return jsonResponse({ error: "SAML user is not provisioned." }, { status: 403 });
1587
+ }
1588
+ const hashed = await hashPassword(randomBytes(18).toString("hex"));
1589
+ try {
1590
+ await runAuthWrite(profile.email, async () => {
1591
+ await User.create({
1592
+ name: profile.name,
1593
+ email: profile.email,
1594
+ password: hashed,
1595
+ is_admin: false,${samlTenantCreateField}
1596
+ });
1597
+ });
1598
+ } catch {
1599
+ // Unique email: another request already provisioned this user.
1600
+ }
1601
+ record = await starterAuthDirectory.findByEmail?.(profile.email);
1602
+ }
1603
+ if (!record) {
1604
+ return jsonResponse({ error: "Could not complete SAML login." }, { status: 500 });
1605
+ }
1606
+ ${mfa ? `if (record.mfa_enabled) {
1607
+ const pending = redirectTo("/login/mfa");
1608
+ pending.headers.append("set-cookie", pendingMfaSetCookie(record.id));
1609
+ return pending;
1610
+ }
1611
+ ` : ""}const auth = dependencies.container.resolve<CookieSessionAuthManager>(CORE_AUTH_TOKEN);
1612
+ return auth.signInRedirect(sessionUser(record), "/");
1613
+ })),
1614
+ },` : "";
1615
+ const apiRoutes = jsonApi || cookie ? `
1413
1616
  routes({ kernel, dependencies }) {
1414
- return {${tokenLogin}${jsonRegister}${jwtLogin}${apiUser}${jsonPassword}${jsonVerify}
1617
+ return {${csrfRoute}${samlRoutes}${cookieJsonAuth}${tokenLogin}${jsonRegister}${jwtLogin}${apiUser}${jsonPassword}${jsonVerify}
1415
1618
  };
1416
1619
  },` : "";
1417
- const mfaLoginBranch = mfa ? `if (user.mfa_enabled) {
1418
- const pending = redirectTo("/login/mfa");
1419
- pending.headers.append("set-cookie", pendingMfaSetCookie(user.id));
1420
- return pending;
1421
- }` : "";
1422
- const verifyRegisterBranch = verify ? `await sendSignedMail(email, "Verify your email", "/email/verify", { id: String(insertedId) });
1620
+ const mfaLoginBranch = `const mfaResult = completePasswordLogin(user, { mfaCode: mfa_code });
1621
+ if (!mfaResult.ok) {
1622
+ ${mfa ? `if (mfaResult.error === "mfa_required") {
1623
+ const pending = redirectTo("/login/mfa");
1624
+ pending.headers.append("set-cookie", pendingMfaSetCookie(user.id));
1625
+ return pending;
1626
+ }` : ""}
1627
+ return renderPage(
1628
+ "auth/login.eta",
1629
+ { layout: { title: "Sign in" }, errors: { email: "These credentials do not match our records." }, email, password: "" },
1630
+ request,
1631
+ );
1632
+ }
1633
+ await persistAuthRecovery(user.id, user.mfa_recovery_codes, mfaResult.consumedRecoveryHash);`;
1634
+ const registerSuccessExisting = `return flashResponse(
1635
+ redirectTo("/login"),
1636
+ { level: "success", message: "If that email is available, continue from the sign-in page." },
1637
+ );`;
1638
+ const verifyRegisterBranch = verify ? `await issueSignedAuthMail(email, "Verify your email", "/email/verify", AUTH_ONE_TIME_PURPOSES.emailVerify, insertedId, { id: String(insertedId) });
1423
1639
  return flashResponse(
1424
- await auth.signInRedirect(sessionUser({ id: insertedId, name, email, role: "member" }), "/email/verify"),
1425
- { level: "info", message: "Check your email for a verification link." },
1426
- );` : `return auth.signInRedirect(sessionUser({ id: insertedId, name, email, role: "member" }), "/");`;
1640
+ redirectTo("/login"),
1641
+ { level: "success", message: "If that email is available, continue from the sign-in page." },
1642
+ );` : `return flashResponse(
1643
+ redirectTo("/login"),
1644
+ { level: "success", message: "If that email is available, continue from the sign-in page." },
1645
+ );`;
1427
1646
  const cookieRoutes = cookie ? `
1428
1647
  webRoutes({ kernel, dependencies }) {
1429
1648
  const auth = dependencies.container.resolve<CookieSessionAuthManager>(CORE_AUTH_TOKEN);
@@ -1436,8 +1655,7 @@ function sessionUser(user: { id: number; name?: string | null; email?: string |
1436
1655
  kernel,
1437
1656
  async (request) => {
1438
1657
  const { fields } = await parseFormBody(request);
1439
- const email = (fields.email ?? "").trim().toLowerCase();
1440
- const password = fields.password ?? "";
1658
+ const { email, password, mfa_code } = parseLoginCredentials(fields);
1441
1659
  const user = await starterAuthDirectory.findByEmail?.(email);
1442
1660
  if (!user?.password || !(await verifyPassword(password, user.password))) {
1443
1661
  return renderPage(
@@ -1459,44 +1677,37 @@ function sessionUser(user: { id: number; name?: string | null; email?: string |
1459
1677
  ),
1460
1678
  },
1461
1679
  "/register": {
1462
- GET: kernel.wrapWebGuest(async (request) =>
1463
- renderPage("auth/register.eta", { layout: { title: "Create account" }, errors: {}, name: "", email: "", password: "" }, request),
1464
- ),
1680
+ GET: kernel.wrapWebGuest(async (request) => {
1681
+ if ((process.env.FEATURE_REGISTRATION ?? "true") === "false") {
1682
+ return new Response("Not found", { status: 404 });
1683
+ }
1684
+ return renderPage("auth/register.eta", { layout: { title: "Create account" }, errors: {}, name: "", email: "", password: "" }, request);
1685
+ }),
1465
1686
  POST: wrapWebRegister(
1466
1687
  kernel,
1467
1688
  async (request) => {
1468
- const { fields } = await parseFormBody(request);
1469
- const name = (fields.name ?? "").trim();
1470
- const email = (fields.email ?? "").trim().toLowerCase();
1471
- const password = fields.password ?? "";
1472
- const errors: Record<string, string> = {};
1473
- if (!name) {
1474
- errors.name = "Name is required.";
1475
- }
1476
- if (!email) {
1477
- errors.email = "Email is required.";
1478
- }
1479
- if (password.length < 8) {
1480
- errors.password = "Use at least 8 characters.";
1481
- }
1482
- if (email && (await starterAuthDirectory.findByEmail?.(email))) {
1483
- errors.email = "Email is already registered.";
1689
+ if ((process.env.FEATURE_REGISTRATION ?? "true") === "false") {
1690
+ return new Response("Not found", { status: 404 });
1484
1691
  }
1485
- if (Object.keys(errors).length > 0) {
1486
- return renderPage(
1487
- "auth/register.eta",
1488
- { layout: { title: "Create account" }, errors, name, email, password: "" },
1489
- request,
1490
- );
1692
+ const { fields } = await parseFormBody(request);
1693
+ const { name, email, password } = parseRegisterCredentials(fields);
1694
+ const existing = await starterAuthDirectory.findByEmail?.(email);
1695
+ await hashPassword(password);
1696
+ if (existing) {
1697
+ ${registerSuccessExisting}
1491
1698
  }
1492
1699
  const hashed = await hashPassword(password);
1493
- await getSql().unsafe(
1494
- "INSERT INTO users (${insertCols}) VALUES (${insertPh})",
1495
- [name, email, hashed${insertTail}],
1496
- );
1497
- const created = await starterAuthDirectory.findByEmail?.(email);
1700
+ await runAuthWrite(email, async () => {
1701
+ await User.create({
1702
+ name,
1703
+ email,
1704
+ password: hashed,
1705
+ is_admin: false,${tenantCreateField}
1706
+ });
1707
+ });
1708
+ ${verify ? `const created = await starterAuthDirectory.findByEmail?.(email);
1498
1709
  const insertedId = created?.id ?? 0;
1499
- ${verifyRegisterBranch}
1710
+ ${verifyRegisterBranch}` : verifyRegisterBranch}
1500
1711
  },
1501
1712
  async (request) =>
1502
1713
  renderPage(
@@ -1513,10 +1724,10 @@ function sessionUser(user: { id: number; name?: string | null; email?: string |
1513
1724
  ),
1514
1725
  POST: kernel.wrapWeb(async (request) => {
1515
1726
  const { fields } = await parseFormBody(request);
1516
- const email = (fields.email ?? "").trim().toLowerCase();
1727
+ const email = parseForgotPasswordEmail(fields);
1517
1728
  const user = await starterAuthDirectory.findByEmail?.(email);
1518
1729
  if (user) {
1519
- await sendSignedMail(email, "Reset your password", "/reset-password", { email });
1730
+ await issueSignedAuthMail(email, "Reset your password", "/reset-password", AUTH_ONE_TIME_PURPOSES.passwordReset, user.id, { email });
1520
1731
  }
1521
1732
  return flashResponse(redirectTo("/forgot-password"), {
1522
1733
  level: "success",
@@ -1525,33 +1736,33 @@ function sessionUser(user: { id: number; name?: string | null; email?: string |
1525
1736
  }),
1526
1737
  },
1527
1738
  "/reset-password": {
1528
- GET: kernel.wrapWebGuest(async (request) => {
1529
- assertValidSignature(request);
1739
+ GET: kernel.wrapWeb(kernel.wrapSigned(async (request) => {
1530
1740
  const email = new URL(request.url).searchParams.get("email") ?? "";
1531
1741
  return renderPage(
1532
1742
  "auth/reset-password.eta",
1533
1743
  { layout: { title: "Reset password" }, errors: {}, password: "", email, action: \`\${new URL(request.url).pathname}\${new URL(request.url).search}\` },
1534
1744
  request,
1535
1745
  );
1536
- }),
1537
- POST: kernel.wrapWeb(async (request) => {
1538
- assertValidSignature(request);
1746
+ })),
1747
+ POST: kernel.wrapWeb(kernel.wrapSigned(async (request) => {
1748
+ const userId = await consumeSignedAuthToken(request, AUTH_ONE_TIME_PURPOSES.passwordReset);
1539
1749
  const { fields } = await parseFormBody(request);
1540
1750
  const email = new URL(request.url).searchParams.get("email") ?? fields.email ?? "";
1541
1751
  const password = fields.password ?? "";
1542
- if (!email || password.length < 8) {
1752
+ if (!userId || !email || password.length < 8) {
1543
1753
  return renderPage(
1544
1754
  "auth/reset-password.eta",
1545
- { layout: { title: "Reset password" }, errors: { password: "Use at least 8 characters." }, password: "", email, action: \`\${new URL(request.url).pathname}\${new URL(request.url).search}\` },
1755
+ { layout: { title: "Reset password" }, errors: { password: "Invalid or expired reset link." }, password: "", email, action: \`\${new URL(request.url).pathname}\${new URL(request.url).search}\` },
1546
1756
  request,
1547
1757
  );
1548
1758
  }
1549
- await getSql().unsafe(
1550
- "UPDATE users SET password = ${passwordPh} WHERE email = ${emailPh}",
1551
- [await hashPassword(password), email],
1552
- );
1759
+ await runAuthWrite(userId, async () => {
1760
+ const user = await User.find(userId);
1761
+ await user?.update({ password: await hashPassword(password) });
1762
+ });
1763
+ await revokeUserSessions(userId);
1553
1764
  return flashResponse(redirectTo("/login"), { level: "success", message: "Password updated. Sign in." });
1554
- }),
1765
+ })),
1555
1766
  },
1556
1767
  "/logout": {
1557
1768
  POST: kernel.wrapWebAuthenticatedAllowUnverified((request) => auth.signOutRedirect(request, "/")),
@@ -1561,18 +1772,21 @@ function sessionUser(user: { id: number; name?: string | null; email?: string |
1561
1772
  const url = new URL(request.url);
1562
1773
  if (url.searchParams.get("signature")) {
1563
1774
  assertValidSignature(request);
1564
- const id = Number.parseInt(url.searchParams.get("id") ?? "", 10);
1565
- if (Number.isInteger(id) && id > 0) {
1566
- await getSql().unsafe(
1567
- "UPDATE users SET email_verified_at = ${verifiedPh} WHERE id = ${idPh}",
1568
- [${nowTimestampLiteral(layers.database)}, id],
1569
- );
1570
- const record = await starterAuthDirectory.findByIdOrThrow(id);
1775
+ const id = await consumeSignedAuthToken(request, AUTH_ONE_TIME_PURPOSES.emailVerify);
1776
+ if (id) {
1777
+ await runAuthWrite(id, async () => {
1778
+ const user = await User.find(id);
1779
+ await user?.update({ email_verified_at: ${nowTimestampLiteral(layers.database)} });
1780
+ });
1571
1781
  return flashResponse(
1572
- await auth.signInRedirect(sessionUser(record), "/"),
1573
- { level: "success", message: "Email verified." },
1782
+ redirectTo("/login"),
1783
+ { level: "success", message: "Email verified. Sign in." },
1574
1784
  );
1575
1785
  }
1786
+ return flashResponse(
1787
+ redirectTo("/login"),
1788
+ { level: "error", message: "Invalid or expired verification link." },
1789
+ );
1576
1790
  }
1577
1791
  return renderPage("auth/verify-email.eta", { layout: { title: "Verify email" } }, request);
1578
1792
  }),
@@ -1582,7 +1796,7 @@ function sessionUser(user: { id: number; name?: string | null; email?: string |
1582
1796
  const user = await auth.user(request);
1583
1797
  if (user) {
1584
1798
  const record = await starterAuthDirectory.findByIdOrThrow(Number(user.id));
1585
- await sendSignedMail(record.email ?? "", "Verify your email", "/email/verify", { id: String(record.id) });
1799
+ await issueSignedAuthMail(record.email ?? "", "Verify your email", "/email/verify", AUTH_ONE_TIME_PURPOSES.emailVerify, Number(record.id), { id: String(record.id) });
1586
1800
  }
1587
1801
  return flashResponse(redirectTo("/email/verify"), { level: "info", message: "Verification link sent." });
1588
1802
  }),
@@ -1594,7 +1808,9 @@ function sessionUser(user: { id: number; name?: string | null; email?: string |
1594
1808
  }
1595
1809
  return renderPage("auth/mfa-challenge.eta", { layout: { title: "MFA" }, errors: {}, code: "" }, request);
1596
1810
  }),
1597
- POST: kernel.wrapWeb(async (request) => {
1811
+ POST: wrapWebLogin(
1812
+ kernel,
1813
+ async (request) => {
1598
1814
  const pendingId = readPendingMfaUserId(request);
1599
1815
  if (!pendingId) {
1600
1816
  return redirectTo("/login");
@@ -1602,29 +1818,51 @@ function sessionUser(user: { id: number; name?: string | null; email?: string |
1602
1818
  const { fields } = await parseFormBody(request);
1603
1819
  const submitted = (fields.code ?? "").trim();
1604
1820
  const record = await starterAuthDirectory.findByIdOrThrow(pendingId);
1605
- const secret = revealMfaSecret(record.mfa_secret ?? null);
1606
- const hashedCodes: string[] = record.mfa_recovery_codes
1607
- ? (JSON.parse(record.mfa_recovery_codes) as string[])
1608
- : [];
1609
- const totpOk = secret ? verifyTotp(secret, submitted) : false;
1610
- const recoveryOk = hashedCodes.some((hash) => recoveryCodeMatches(submitted, hash));
1611
- if (!totpOk && !recoveryOk) {
1821
+ const mfaResult = completePasswordLogin(record, { mfaCode: submitted });
1822
+ if (!mfaResult.ok) {
1612
1823
  return renderPage(
1613
1824
  "auth/mfa-challenge.eta",
1614
1825
  { layout: { title: "MFA" }, errors: { code: "That code is not valid." }, code: "" },
1615
1826
  request,
1616
1827
  );
1617
1828
  }
1618
- if (recoveryOk) {
1619
- const remaining = hashedCodes.filter((hash) => !recoveryCodeMatches(submitted, hash));
1620
- await getSql().unsafe(
1621
- "UPDATE users SET mfa_recovery_codes = ${passwordPh} WHERE id = ${idPh}",
1622
- [JSON.stringify(remaining), pendingId],
1623
- );
1624
- }
1829
+ await persistAuthRecovery(pendingId, record.mfa_recovery_codes, mfaResult.consumedRecoveryHash);
1625
1830
  const signed = await auth.signInRedirect(sessionUser(record), "/");
1626
1831
  signed.headers.append("set-cookie", pendingMfaClearCookie());
1627
1832
  return signed;
1833
+ },
1834
+ async (request) =>
1835
+ renderPage(
1836
+ "auth/mfa-challenge.eta",
1837
+ { layout: { title: "MFA" }, errors: { code: "Too many login attempts. Try again shortly." }, code: "" },
1838
+ request,
1839
+ 429,
1840
+ ),
1841
+ ),
1842
+ },
1843
+ "/confirm-password": {
1844
+ GET: kernel.wrapWebAuthenticated(async (request) =>
1845
+ renderPage("auth/confirm-password.eta", { layout: { title: "Confirm password" }, errors: {}, password: "" }, request),
1846
+ ),
1847
+ POST: kernel.wrapWebAuthenticated(async (request) => {
1848
+ const user = await auth.user(request);
1849
+ if (!user) {
1850
+ return redirectTo("/login");
1851
+ }
1852
+ const { fields } = await parseFormBody(request);
1853
+ const password = fields.password ?? "";
1854
+ const record = await starterAuthDirectory.findByIdOrThrow(Number(user.id));
1855
+ if (!record.password || !(await verifyPassword(password, record.password))) {
1856
+ return renderPage(
1857
+ "auth/confirm-password.eta",
1858
+ { layout: { title: "Confirm password" }, errors: { password: "That password is not correct." }, password: "" },
1859
+ request,
1860
+ );
1861
+ }
1862
+ const next = sanitizeInternalPath(new URL(request.url).searchParams.get("redirect") ?? "/account/mfa");
1863
+ const confirmed = redirectTo(next);
1864
+ confirmed.headers.append("set-cookie", createPasswordConfirmCookie(Number(user.id)));
1865
+ return confirmed;
1628
1866
  }),
1629
1867
  },
1630
1868
  "/account/mfa": {
@@ -1643,7 +1881,7 @@ function sessionUser(user: { id: number; name?: string | null; email?: string |
1643
1881
  request,
1644
1882
  );
1645
1883
  }),
1646
- POST: kernel.wrapWebAuthenticated(async (request) => {
1884
+ POST: kernel.wrapWebPasswordConfirm(async (request) => {
1647
1885
  const user = await auth.user(request);
1648
1886
  if (!user) {
1649
1887
  return redirectTo("/login");
@@ -1666,10 +1904,15 @@ function sessionUser(user: { id: number; name?: string | null; email?: string |
1666
1904
  }
1667
1905
  const recoveryCodes = generateRecoveryCodes();
1668
1906
  const stored = protectMfaSecret(secret);
1669
- await getSql().unsafe(
1670
- "UPDATE users SET mfa_secret = ${mfaUpdatePh.split(", ")[0]}, mfa_enabled = ${mfaUpdatePh.split(", ")[1]}, mfa_recovery_codes = ${mfaUpdatePh.split(", ")[2]} WHERE id = ${mfaIdPh}",
1671
- [stored, ${sqlTrue(layers)}, JSON.stringify(recoveryCodes.map((item) => hashRecoveryCode(item))), Number(user.id)],
1672
- );
1907
+ await runAuthWrite(Number(user.id), async () => {
1908
+ const record = await User.find(Number(user.id));
1909
+ await record?.update({
1910
+ mfa_secret: stored,
1911
+ mfa_enabled: true,
1912
+ mfa_recovery_codes: JSON.stringify(recoveryCodes.map((item) => hashRecoveryCode(item))),
1913
+ });
1914
+ });
1915
+ await revokeUserSessions(Number(user.id));
1673
1916
  return renderPage(
1674
1917
  "auth/mfa-setup.eta",
1675
1918
  {
@@ -1700,15 +1943,13 @@ export default authModule;
1700
1943
  function renderSiteModule(_layers) {
1701
1944
  return `import type { AppModule } from "@getstrata/bootstrap/contracts";
1702
1945
  import { withErrorHandling } from "@getstrata/core/http/response";
1703
- import { getSql, pingDatabase } from "../../bootstrap/database.ts";
1704
- import { plainText, renderPage } from "../../lib/view.ts";
1946
+ import { pingDatabase } from "../../bootstrap/database.ts";
1705
1947
  import { Note } from "../../models/Note.ts";
1948
+ import { plainText, renderPage } from "../../lib/view.ts";
1706
1949
 
1707
- // Proves the database answers and the schema is migrated. Point it at a table your app owns.
1708
1950
  async function schemaReady(): Promise<boolean> {
1709
1951
  try {
1710
- getSql();
1711
- await Note.query().value("id");
1952
+ await Note.query().limit(1).get();
1712
1953
  return true;
1713
1954
  } catch {
1714
1955
  return false;
@@ -1913,6 +2154,7 @@ function renderLayout(layers, projectName) {
1913
2154
  <meta name="description" content="<%= it.layout.description %>" />
1914
2155
  <% } %>
1915
2156
  <link rel="stylesheet" href="/assets/site.css" />
2157
+ <script src="/assets/htmx.min.js"></script>
1916
2158
  </head>
1917
2159
  <body>
1918
2160
  <header class="site-header">
@@ -1941,7 +2183,7 @@ function renderHomeView(projectName, layers) {
1941
2183
  <a class="button" href="/register">Create account</a>
1942
2184
  <a class="button button-secondary" href="/login">Sign in</a>
1943
2185
  </div>
1944
- <p class="muted">Seeded demo: <code>demo@example.com</code> / <code>password</code>.</p>
2186
+ <p class="muted">Seeded demo: <code>demo@example.com</code> / <code>StrataDemo!ChangeMe</code>.</p>
1945
2187
  <% } else { %>
1946
2188
  <p class="lede">You are signed in as <strong><%= it.currentUser.email %></strong>.</p>
1947
2189
  <p>Add routes in <code>src/modules</code>. This homepage is yours to restyle.</p>
@@ -2017,6 +2259,9 @@ function renderVerifyEmailView() {
2017
2259
  function renderMfaChallengeView() {
2018
2260
  return renderFormView("Two-factor code", `${textField("code", "Authenticator or recovery code", "text", 'required autocomplete="one-time-code"')}`, "Continue", `<a href="/login">Cancel</a>`).replace('action="<%= it.action || "" %>"', 'action="/login/mfa"');
2019
2261
  }
2262
+ function renderConfirmPasswordView() {
2263
+ return renderFormView("Confirm your password", textField("password", "Current password", "password", 'required autocomplete="current-password"'), "Confirm", `<a href="/">Cancel</a>`).replace('action="<%= it.action || "" %>"', 'action=""');
2264
+ }
2020
2265
  function renderMfaSetupView() {
2021
2266
  return `<section class="auth-card">
2022
2267
  <h1>Authenticator app</h1>
@@ -2052,111 +2297,105 @@ function renderAuthDirectory(layers) {
2052
2297
  return null;
2053
2298
  }
2054
2299
  const hashed = hashApiToken(token);
2055
- const rows = await getSql().unsafe<
2056
- {
2057
- id: number;
2058
- user_id: number;
2059
- abilities: string;
2060
- expires_at: Date | string | null;
2061
- role?: string;
2062
- is_admin?: number | boolean;
2063
- email_verified_at?: Date | string | null;
2064
- }
2065
- >(
2066
- \`SELECT t.id, t.user_id, t.abilities, t.expires_at, u.is_admin, u.email_verified_at
2067
- FROM api_tokens t INNER JOIN users u ON u.id = t.user_id
2068
- WHERE t.token_hash = ?\`,
2069
- [hashed],
2070
- );
2071
- const row = rows[0];
2072
- if (!row) {
2300
+ const tokenRow = await runWithMigrationBypassForIdentifier(hashed, async () => {
2301
+ return await ApiToken.where({ token_hash: hashed }).first();
2302
+ });
2303
+ if (!tokenRow) {
2073
2304
  return null;
2074
2305
  }
2075
- if (row.expires_at && new Date(row.expires_at).getTime() <= Date.now()) {
2306
+ const expiresAt = tokenRow.get("expires_at");
2307
+ if (expiresAt && new Date(String(expiresAt)).getTime() <= Date.now()) {
2308
+ return null;
2309
+ }
2310
+ const user_id = Number(tokenRow.get("user_id"));
2311
+ const user = await runWithMigrationBypassForIdentifier(user_id, async () => {
2312
+ return await User.find(user_id);
2313
+ });
2314
+ if (!user) {
2076
2315
  return null;
2077
2316
  }
2078
2317
  let abilities: string[] = [];
2079
2318
  try {
2080
- abilities = JSON.parse(String(row.abilities ?? "[]")) as string[];
2319
+ abilities = JSON.parse(String(tokenRow.get("abilities") ?? "[]")) as string[];
2081
2320
  } catch {
2082
- abilities = ["profile:read"];
2321
+ abilities = [];
2083
2322
  }
2084
2323
  return {
2085
- id: Number(row.user_id),
2086
- role: row.is_admin ? "admin" : "member",
2324
+ id: user_id,
2325
+ role: mapRole(user.get("is_admin")),
2087
2326
  abilities,
2088
- tokenId: Number(row.id),
2089
- emailVerifiedAt: row.email_verified_at ?? null,
2327
+ tokenId: Number(tokenRow.get("id")),
2328
+ emailVerifiedAt: asTimestamp(user.get("email_verified_at")),
2090
2329
  };
2091
2330
  },` : `
2092
2331
  async resolveUserFromToken() {
2093
2332
  return null;
2094
2333
  },`;
2095
- const placeholder = layers.database === "postgres" ? "$1" : "?";
2096
2334
  const hashImport = authUsesToken(layers.auth) ? `import { hashApiToken } from "@getstrata/core/auth/tokenHash";
2097
2335
  ` : "";
2098
- const mfaSelect = layers.extras.mfa ? ", mfa_enabled, mfa_secret, mfa_recovery_codes" : "";
2099
- const mfaColumns = layers.extras.mfa ? `
2100
- mfa_enabled?: number | boolean;
2101
- mfa_secret?: string | null;
2102
- mfa_recovery_codes?: string | null;` : "";
2336
+ const apiTokenImport = authUsesToken(layers.auth) ? `import { ApiToken } from "../models/ApiToken.ts";
2337
+ ` : "";
2103
2338
  const mfaReturn = layers.extras.mfa ? `
2104
- mfa_enabled: row.mfa_enabled === true || row.mfa_enabled === 1,
2105
- mfa_secret: row.mfa_secret ?? null,
2106
- mfa_recovery_codes: row.mfa_recovery_codes ?? null,` : "";
2107
- const userColumns = `id, name, email, is_admin, email_verified_at, password${mfaSelect}`;
2339
+ mfa_enabled: user.get("mfa_enabled") === true || user.get("mfa_enabled") === 1,
2340
+ mfa_secret: asNullableString(user.get("mfa_secret")),
2341
+ mfa_recovery_codes: asNullableString(user.get("mfa_recovery_codes")),` : "";
2342
+ const sessionReturn = `
2343
+ session_valid_after: asTimestamp(user.get("session_valid_after")),`;
2344
+ const nullableStringHelper = layers.extras.mfa ? `
2345
+ function asNullableString(value: unknown): string | null {
2346
+ return typeof value === "string" ? value : null;
2347
+ }
2348
+ ` : "";
2349
+ const mfaKeys = layers.extras.mfa ? ` | "mfa_enabled" | "mfa_secret" | "mfa_recovery_codes"` : "";
2108
2350
  return `import type { AuthUser } from "@getstrata/core/auth/authContext";
2109
2351
  import { verifyPassword } from "@getstrata/core/auth/password";
2110
- ${hashImport}import type { AuthUserDirectory } from "@getstrata/core/contracts/authUserDirectory";
2111
- import { getSql } from "./database.ts";
2352
+ ${hashImport}import type { AuthUserDirectory, AuthUserRecord } from "@getstrata/core/contracts/authUserDirectory";
2353
+ import { runWithMigrationBypassForIdentifier } from "@getstrata/core/tenant/databaseTenantContext";
2354
+ ${apiTokenImport}import { User } from "../models/User.ts";
2112
2355
 
2113
- type UserRow = {
2114
- id: number;
2115
- name: string;
2116
- email: string;
2117
- is_admin: number | boolean;
2118
- email_verified_at: Date | string | null;
2119
- password: string;${mfaColumns}
2356
+ type LoadedUser = {
2357
+ get(key: "id" | "name" | "email" | "is_admin" | "email_verified_at" | "password" | "session_valid_after"${mfaKeys}): unknown;
2120
2358
  };
2121
2359
 
2122
2360
  function mapRole(isAdmin: unknown): string {
2123
2361
  return isAdmin === true || isAdmin === 1 || isAdmin === "1" ? "admin" : "member";
2124
2362
  }
2125
2363
 
2126
- function mapUserRow(row: UserRow) {
2364
+ function asTimestamp(value: unknown): Date | string | null {
2365
+ return value instanceof Date || typeof value === "string" ? value : null;
2366
+ }
2367
+ ${nullableStringHelper}
2368
+ function mapUserRow(user: LoadedUser): AuthUserRecord {
2127
2369
  return {
2128
- id: Number(row.id),
2129
- name: row.name,
2130
- email: row.email,
2131
- role: mapRole(row.is_admin),
2132
- email_verified_at: row.email_verified_at ?? null,
2133
- password: row.password,${mfaReturn}
2370
+ id: Number(user.get("id")),
2371
+ name: String(user.get("name") ?? ""),
2372
+ email: String(user.get("email") ?? ""),
2373
+ role: mapRole(user.get("is_admin")),
2374
+ email_verified_at: asTimestamp(user.get("email_verified_at")),
2375
+ password: String(user.get("password") ?? ""),${mfaReturn}${sessionReturn}
2134
2376
  };
2135
2377
  }
2136
2378
 
2137
2379
  async function findUserById(id: number) {
2138
- const rows = await getSql().unsafe<UserRow>(
2139
- "SELECT ${userColumns} FROM users WHERE id = ${placeholder}",
2140
- [id],
2141
- );
2142
- const row = rows[0];
2143
- if (!row) {
2144
- throw new Error(\`User \${id} not found.\`);
2145
- }
2146
- return mapUserRow(row);
2380
+ return await runWithMigrationBypassForIdentifier(id, async () => {
2381
+ const user = await User.find(id);
2382
+ if (!user) {
2383
+ throw new Error(\`User \${id} not found.\`);
2384
+ }
2385
+ return mapUserRow(user);
2386
+ });
2147
2387
  }
2148
2388
 
2149
2389
  async function findUserByEmail(email: string) {
2150
- const rows = await getSql().unsafe<UserRow>(
2151
- "SELECT ${userColumns} FROM users WHERE email = ${placeholder}",
2152
- [email.trim().toLowerCase()],
2153
- );
2154
- const row = rows[0];
2155
- return row ? mapUserRow(row) : null;
2390
+ email = email.trim().toLowerCase();
2391
+ return await runWithMigrationBypassForIdentifier(email, async () => {
2392
+ const user = await User.where({ email }).first();
2393
+ return user ? mapUserRow(user) : null;
2394
+ });
2156
2395
  }
2157
2396
 
2158
2397
  export const starterAuthDirectory: AuthUserDirectory = {
2159
- ${tokenLookup.replace("WHERE t.token_hash = ?", `WHERE t.token_hash = ${placeholder}`)}
2398
+ ${tokenLookup}
2160
2399
 
2161
2400
  findByIdOrThrow: findUserById,
2162
2401
 
@@ -2167,6 +2406,9 @@ ${tokenLookup.replace("WHERE t.token_hash = ?", `WHERE t.token_hash = ${placehol
2167
2406
  if (!user?.password || !(await verifyPassword(password, user.password))) {
2168
2407
  return null;
2169
2408
  }
2409
+ if ("mfa_enabled" in user && user.mfa_enabled) {
2410
+ return null;
2411
+ }
2170
2412
  return {
2171
2413
  id: user.id,
2172
2414
  role: user.role,
@@ -2185,6 +2427,11 @@ import type { ServiceProvider } from "@getstrata/core/contracts/di";
2185
2427
  import { envFlagEnabled } from "@getstrata/core/runtime/appEnv";
2186
2428
 
2187
2429
  class StarterAuthManager {
2430
+ async resolveWithSource(request?: Request) {
2431
+ const user = await this.resolve(request);
2432
+ return { user, credentialSource: user ? "guest" as const : null };
2433
+ }
2434
+
2188
2435
  async resolve(request?: Request): Promise<AuthUser | null> {
2189
2436
  if (!envFlagEnabled(process.env.AUTH_DEV_HEADERS)) {
2190
2437
  return request ? null : currentAuthUser();
@@ -2230,13 +2477,13 @@ export default authProvider;
2230
2477
  role: user.is_admin ? "admin" : "member",
2231
2478
  ...(user.email_verified_at !== undefined ? { emailVerifiedAt: user.email_verified_at } : {}),
2232
2479
  }),
2233
- });` : ` const fallback = ${authUsesToken(layers.auth) ? "new DatabaseTokenGuard(container)" : "new JwtGuard()"};
2480
+ });` : ` const fallback = ${authUsesToken(layers.auth) ? "new DatabaseTokenGuard(container)" : "new JwtGuard(container)"};
2234
2481
  const auth = new AuthManager(fallback);`;
2235
2482
  const tokenRegs = authUsesToken(layers.auth) ? ` const apiGuard = new DatabaseTokenGuard(container);
2236
2483
  auth.registerGuard("api", apiGuard);
2237
2484
  auth.registerGuard("access_token", apiGuard);
2238
2485
  auth.registerGuard("token", apiGuard);` : "";
2239
- const jwtReg = authUsesJwt(layers.auth) ? ` auth.registerGuard("jwt", new JwtGuard());` : "";
2486
+ const jwtReg = authUsesJwt(layers.auth) ? ` auth.registerGuard("jwt", new JwtGuard(container));` : "";
2240
2487
  const basicReg = authUsesToken(layers.auth) || authUsesJwt(layers.auth) ? ` auth.registerGuard("basic", new BasicAuthGuard(container));` : "";
2241
2488
  const ability = authUsesToken(layers.auth) ? ` container.set(CORE_ABILITY_CHECKER_TOKEN, createTokenAbilityChecker());` : "";
2242
2489
  const imports = [`import { CORE_AUTH_TOKEN } from "@getstrata/bootstrap/config";`];
@@ -2296,18 +2543,75 @@ export default authProvider;
2296
2543
  function envFlag(value) {
2297
2544
  return value ? "true" : "false";
2298
2545
  }
2546
+ var GENERATED_POSTGRES_APP_ROLE = "strata_app";
2547
+ var GENERATED_POSTGRES_APP_PASSWORD = "dev-strata-app-change-me";
2548
+ var GENERATED_POSTGRES_SUPERUSER_PASSWORD = "dev-postgres-change-me";
2549
+ var GENERATED_MYSQL_ROOT_PASSWORD = "dev-mysql-change-me";
2299
2550
  function appDatabaseName(projectName) {
2300
2551
  return projectName.replace(/[^A-Za-z0-9_]/g, "_");
2301
2552
  }
2553
+ function usesComposePostgres(layers) {
2554
+ return selectedDockerServices(layers).includes("postgres");
2555
+ }
2302
2556
  function defaultDatabaseUrl(layers, projectName) {
2303
2557
  if (layers.database === "sqlite") {
2304
2558
  return "sqlite:./storage/app.sqlite";
2305
2559
  }
2306
2560
  const database = appDatabaseName(projectName);
2307
2561
  if (layers.database === "mysql") {
2308
- return `mysql://root:root@localhost:3306/${database}`;
2562
+ return `mysql://root:${GENERATED_MYSQL_ROOT_PASSWORD}@localhost:3306/${database}`;
2309
2563
  }
2310
- return `postgresql://postgres:postgres@localhost:5432/${database}`;
2564
+ return `postgresql://${GENERATED_POSTGRES_APP_ROLE}:${GENERATED_POSTGRES_APP_PASSWORD}@localhost:5432/${database}`;
2565
+ }
2566
+ function defaultMigrationDatabaseUrl(layers, projectName) {
2567
+ if (layers.database !== "postgres") {
2568
+ return null;
2569
+ }
2570
+ const database = appDatabaseName(projectName);
2571
+ return `postgresql://postgres:${GENERATED_POSTGRES_SUPERUSER_PASSWORD}@localhost:5432/${database}`;
2572
+ }
2573
+ function postgresAppRoleEnvNotes(layers) {
2574
+ if (layers.database !== "postgres") {
2575
+ return [];
2576
+ }
2577
+ return [
2578
+ `# Runtime DATABASE_URL is ${GENERATED_POSTGRES_APP_ROLE} (NOSUPERUSER NOBYPASSRLS).`,
2579
+ "# MIGRATION_DATABASE_URL is the postgres superuser for CREATE ROLE / CREATE DATABASE / GRANT / migrate.",
2580
+ "# db/ensure-postgres-app-role.sql is repeatable on an existing volume, not only docker-entrypoint-initdb.d.",
2581
+ layers.tenancy === "rls" ? "# Production TENANCY_DRIVER=rls inspects pg_roles (rolsuper / rolbypassrls), not only the URL username." : "# This app is not TENANCY_DRIVER=rls; still do not point runtime traffic at the postgres superuser."
2582
+ ];
2583
+ }
2584
+ function renderPostgresAppRoleInitSql(projectName, layers) {
2585
+ if (layers.database !== "postgres") {
2586
+ return null;
2587
+ }
2588
+ const database = appDatabaseName(projectName);
2589
+ const role = GENERATED_POSTGRES_APP_ROLE;
2590
+ const password = GENERATED_POSTGRES_APP_PASSWORD;
2591
+ return `-- Application login role. FORCE RLS applies because this role is NOSUPERUSER and NOBYPASSRLS.
2592
+ -- Superuser remains for CREATE ROLE / CREATE DATABASE / GRANT / migrate.
2593
+ -- This script is repeatable on an already-existing volume (not only docker-entrypoint-initdb.d).
2594
+ DO $$
2595
+ BEGIN
2596
+ IF NOT EXISTS (SELECT 1 FROM pg_roles WHERE rolname = '${role}') THEN
2597
+ CREATE ROLE ${role} LOGIN PASSWORD '${password}'
2598
+ NOSUPERUSER NOCREATEDB NOCREATEROLE NOINHERIT NOBYPASSRLS;
2599
+ ELSE
2600
+ ALTER ROLE ${role} WITH LOGIN PASSWORD '${password}'
2601
+ NOSUPERUSER NOCREATEDB NOCREATEROLE NOINHERIT NOBYPASSRLS;
2602
+ END IF;
2603
+ END
2604
+ $$;
2605
+
2606
+ GRANT CONNECT ON DATABASE ${database} TO ${role};
2607
+ GRANT USAGE, CREATE ON SCHEMA public TO ${role};
2608
+ GRANT ALL PRIVILEGES ON ALL TABLES IN SCHEMA public TO ${role};
2609
+ GRANT ALL PRIVILEGES ON ALL SEQUENCES IN SCHEMA public TO ${role};
2610
+ GRANT EXECUTE ON ALL FUNCTIONS IN SCHEMA public TO ${role};
2611
+ ALTER DEFAULT PRIVILEGES IN SCHEMA public GRANT ALL ON TABLES TO ${role};
2612
+ ALTER DEFAULT PRIVILEGES IN SCHEMA public GRANT ALL ON SEQUENCES TO ${role};
2613
+ ALTER DEFAULT PRIVILEGES IN SCHEMA public GRANT EXECUTE ON FUNCTIONS TO ${role};
2614
+ `;
2311
2615
  }
2312
2616
  function renderEnvExample(projectName, layers) {
2313
2617
  const lines = [
@@ -2317,8 +2621,10 @@ function renderEnvExample(projectName, layers) {
2317
2621
  "PORT=3000",
2318
2622
  "APP_URL=http://localhost:3000",
2319
2623
  `DATABASE_URL=${defaultDatabaseUrl(layers, projectName)}`,
2624
+ ...postgresAppRoleEnvNotes(layers),
2625
+ ...layers.database === "postgres" ? [`MIGRATION_DATABASE_URL=${defaultMigrationDatabaseUrl(layers, projectName)}`] : [],
2320
2626
  ...layers.database === "sqlite" ? [] : [
2321
- "# Optional. Migrate and boot against a different database than DATABASE_URL.",
2627
+ "# Optional. Point runtime at a different database than DATABASE_URL.",
2322
2628
  "# APP_DATABASE_URL="
2323
2629
  ],
2324
2630
  `DB_CONNECTION=${layers.database === "postgres" ? "pgsql" : layers.database}`,
@@ -2330,14 +2636,23 @@ function renderEnvExample(projectName, layers) {
2330
2636
  `MAIL_DRIVER=${layers.mail}`,
2331
2637
  `AUTH_DEV_HEADERS=${envFlag(layers.auth === "headers")}`
2332
2638
  ];
2333
- if (layers.frontend === "api") {
2334
- lines.push("FEATURE_PUBLIC_READS=false");
2335
- } else {
2336
- lines.push("# Local convenience so the welcome page reads without a login.", "# Production boot is blocked unless this is false.", "FEATURE_PUBLIC_READS=true");
2337
- }
2639
+ lines.push("APP_DEBUG=false");
2640
+ lines.push("FEATURE_PUBLIC_READS=false");
2641
+ lines.push("FEATURE_SIEM_EXPORT=false");
2642
+ lines.push("FEATURE_REGISTRATION=true");
2643
+ lines.push("FEATURE_SAML=false");
2644
+ lines.push("# SAML_IDP_SSO_URL=");
2645
+ lines.push("# SAML_IDP_CERT=");
2646
+ lines.push("# SAML_SP_ENTITY_ID=");
2647
+ lines.push("# SAML_ACS_URL=");
2648
+ lines.push("# SAML_IDP_ISSUER=");
2649
+ lines.push("# SAML_WANT_RESPONSE_SIGNED=");
2650
+ lines.push("# SAML_DISABLE_REQUESTED_AUTHN_CONTEXT=");
2338
2651
  if (needsRedis(layers)) {
2339
- lines.push("REDIS_URL=redis://127.0.0.1:6379");
2652
+ lines.push("REDIS_PASSWORD=dev-redis-change-me");
2653
+ lines.push("REDIS_URL=redis://:dev-redis-change-me@127.0.0.1:6379");
2340
2654
  } else {
2655
+ lines.push("# REDIS_PASSWORD=");
2341
2656
  lines.push("# REDIS_URL=redis://127.0.0.1:6379");
2342
2657
  }
2343
2658
  if (authUsesCookie(layers.auth) || layers.frontend === "server-htmx" || layers.frontend === "hybrid") {
@@ -2359,6 +2674,11 @@ function renderEnvExample(projectName, layers) {
2359
2674
  lines.push("# FEATURE_API_TOKENS=false");
2360
2675
  }
2361
2676
  lines.push(`FEATURE_MFA=${envFlag(layers.extras.mfa)}`);
2677
+ if (layers.extras.mfa) {
2678
+ lines.push("KMS_ENCRYPTION_KEY=0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef");
2679
+ } else {
2680
+ lines.push("# KMS_ENCRYPTION_KEY=");
2681
+ }
2362
2682
  lines.push(`FEATURE_EMAIL_VERIFICATION=${envFlag(layers.extras.emailVerification)}`);
2363
2683
  if (layers.extras.metrics) {
2364
2684
  lines.push("METRICS_TOKEN=dev-metrics-token-change-me");
@@ -2404,22 +2724,24 @@ function renderDockerCompose(projectName, layers) {
2404
2724
  image: postgres:16-alpine
2405
2725
  environment:
2406
2726
  POSTGRES_USER: postgres
2407
- POSTGRES_PASSWORD: postgres
2727
+ POSTGRES_PASSWORD: ${GENERATED_POSTGRES_SUPERUSER_PASSWORD}
2408
2728
  POSTGRES_DB: ${database}
2409
2729
  ports:
2410
- - "5432:5432"
2730
+ # Localhost only. Do not publish this port on a production host.
2731
+ - "127.0.0.1:5432:5432"
2411
2732
  volumes:
2412
- - pgdata:/var/lib/postgresql/data`);
2733
+ - pgdata:/var/lib/postgresql/data
2734
+ - ./docker/postgres-init:/docker-entrypoint-initdb.d:ro`);
2413
2735
  }
2414
2736
  if (selectedSet.has("mysql")) {
2415
2737
  const database = appDatabaseName(projectName);
2416
2738
  services.push(` mysql:
2417
2739
  image: mysql:8.4
2418
2740
  environment:
2419
- MYSQL_ROOT_PASSWORD: root
2741
+ MYSQL_ROOT_PASSWORD: ${GENERATED_MYSQL_ROOT_PASSWORD}
2420
2742
  MYSQL_DATABASE: ${database}
2421
2743
  ports:
2422
- - "3306:3306"
2744
+ - "127.0.0.1:3306:3306"
2423
2745
  volumes:
2424
2746
  - mysqldata:/var/lib/mysql`);
2425
2747
  }
@@ -2427,25 +2749,28 @@ function renderDockerCompose(projectName, layers) {
2427
2749
  const server = selectedSet.has("mysql") ? "mysql" : "postgres";
2428
2750
  services.push(` adminer:
2429
2751
  image: adminer:5.4.2
2752
+ profiles:
2753
+ - debug
2430
2754
  environment:
2431
2755
  ADMINER_DEFAULT_SERVER: ${server}
2432
2756
  depends_on:
2433
2757
  - ${server}
2434
2758
  ports:
2435
- - "8080:8080"`);
2759
+ - "127.0.0.1:8080:8080"`);
2436
2760
  }
2437
2761
  if (selectedSet.has("redis")) {
2438
2762
  services.push(` redis:
2439
2763
  image: redis:7-alpine
2764
+ command: ["redis-server", "--requirepass", "\${REDIS_PASSWORD:-dev-redis-change-me}"]
2440
2765
  ports:
2441
- - "6379:6379"`);
2766
+ - "127.0.0.1:6379:6379"`);
2442
2767
  }
2443
2768
  if (selectedSet.has("mailpit")) {
2444
2769
  services.push(` mailpit:
2445
2770
  image: axllent/mailpit:latest
2446
2771
  ports:
2447
- - "1025:1025"
2448
- - "8025:8025"`);
2772
+ - "127.0.0.1:1025:1025"
2773
+ - "127.0.0.1:8025:8025"`);
2449
2774
  }
2450
2775
  const volumes = [];
2451
2776
  if (selectedSet.has("postgres")) {
@@ -2531,9 +2856,9 @@ function renderPackageJson(projectName, options = {}) {
2531
2856
  "@getstrata/cli": "workspace:*",
2532
2857
  "@getstrata/core": "workspace:*"
2533
2858
  } : {
2534
- "@getstrata/bootstrap": "^1.0.9",
2535
- "@getstrata/cli": "^1.0.9",
2536
- "@getstrata/core": "^1.0.9"
2859
+ "@getstrata/bootstrap": "^1.1.0",
2860
+ "@getstrata/cli": "^1.1.0",
2861
+ "@getstrata/core": "^1.1.0"
2537
2862
  };
2538
2863
  coreDeps.eta = "^4.6.0";
2539
2864
  if (options.layers?.database === "mysql") {
@@ -2615,8 +2940,11 @@ function renderSupportingToolsReadme(layers) {
2615
2940
  const system = mysql ? "MySQL" : "PostgreSQL";
2616
2941
  const server = mysql ? "mysql" : "postgres";
2617
2942
  const username = mysql ? "root" : "postgres";
2618
- const password = mysql ? "root" : "postgres";
2943
+ const password = mysql ? GENERATED_MYSQL_ROOT_PASSWORD : GENERATED_POSTGRES_SUPERUSER_PASSWORD;
2619
2944
  lines.push(`Adminer: http://localhost:8080 (${system}, server \`${server}\`, username \`${username}\`, password \`${password}\`).`, "");
2945
+ if (!mysql) {
2946
+ lines.push("Adminer uses the Compose `postgres` superuser, which skips FORCE RLS. Application traffic should use `strata_app`.", "");
2947
+ }
2620
2948
  }
2621
2949
  }
2622
2950
  if (localOn.length > 0) {
@@ -2630,13 +2958,13 @@ function renderSupportingToolsReadme(layers) {
2630
2958
  }
2631
2959
  function renderApiDocs(projectName, layers) {
2632
2960
  const rows = ["| Method | Path | Notes |", "| --- | --- | --- |"];
2633
- rows.push("| `GET` | `/health` | Plain text `ok` (200), or `degraded` (503) until the database ping succeeds and the migrated `notes` table exists. Docker HEALTHCHECK uses this path. |", '| `GET` | `/ready` | JSON from the framework: `{"status":"ready","checks":{"database":"ok","redis":"skipped"}}` (200) or `not_ready` (503). `redis` is `ok` or `error` when `REDIS_URL` is set and `skipped` otherwise. It does not check the schema, so use `/health` as the deploy gate. |');
2961
+ rows.push("| `GET` | `/health` | Plain text `ok` (200) when the database ping succeeds and `notes` is readable under the request tenant (zero rows is still 200). `degraded` (503) when the ping or that read fails. Docker HEALTHCHECK uses this path. |", '| `GET` | `/ready` | JSON from the framework: `{"status":"ready","checks":{"database":"ok","redis":"skipped"}}` (200) or `not_ready` (503). `redis` is `ok` or `error` when `REDIS_URL` is set and `skipped` otherwise. It does not check the schema, so use `/health` as the deploy gate. |');
2634
2962
  rows.push("| `GET` | `/` | Welcome page. Restyle or replace it. |");
2635
2963
  if (authUsesToken(layers.auth)) {
2636
- rows.push("| `POST` | `/api/v1/auth/login` | `{ email, password }` returns `{ token }`. |", "| `POST` | `/api/v1/auth/register` | Creates a user and returns a token. |", "| `GET` | `/api/v1/auth/me` | Requires `Authorization: Bearer <token>`. |");
2964
+ rows.push("| `POST` | `/api/v1/auth/login` | `{ email, password }` returns `{ token }`. Requires `GET /api/v1/auth/csrf` then `X-CSRF-Token`. |", "| `POST` | `/api/v1/auth/register` | Creates a user and returns a token. |", "| `GET` | `/api/v1/auth/me` | Requires `Authorization: Bearer <token>`. |");
2637
2965
  }
2638
2966
  if (authUsesJwt(layers.auth)) {
2639
- rows.push("| `POST` | `/api/auth/token` | Mints a short-lived JWT. Not an HTML session. |");
2967
+ rows.push("| `POST` | `/api/auth/token` | Mints a short-lived JWT. Not an HTML session. Requires CSRF like JSON login. |");
2640
2968
  }
2641
2969
  if (authNeedsUsers(layers.auth)) {
2642
2970
  rows.push("| `POST` | `/api/v1/auth/forgot-password` | Sends a signed reset link through the mail driver. |", "| `POST` | `/api/v1/auth/reset-password` | Consumes the signed link. |", "| `GET` | `/api/user` | Current user for the active guard. |");
@@ -2644,7 +2972,7 @@ function renderApiDocs(projectName, layers) {
2644
2972
  if (layers.extras.metrics) {
2645
2973
  rows.push("| `GET` | `/metrics` | Prometheus text. Production requires `Authorization: Bearer <METRICS_TOKEN>`. |");
2646
2974
  }
2647
- const authNote = layers.auth === "headers" ? `Auth is \`headers\`. Send \`x-authenticated-user-id\` (and optional \`x-authenticated-user-role\`) for local work and tests. There are no login endpoints and no \`users\` table. Production must set \`AUTH_DEV_HEADERS=false\`, which turns those headers off and leaves you without a guard, so pick another auth layer before you ship.` : authUsesToken(layers.auth) ? `Sign in with \`POST /api/v1/auth/login\`, then send \`Authorization: Bearer <token>\` on every request. Tokens are stored hashed in \`api_tokens\` and expire after \`API_TOKEN_DEFAULT_EXPIRY_DAYS\` (30 in \`.env.example\`). The response includes \`expires_at\`.` : `Mint a JWT with \`POST /api/auth/token\`, then send \`Authorization: Bearer <jwt>\`. JWTs expire; re-mint rather than refreshing in place.`;
2975
+ const authNote = layers.auth === "headers" ? `Auth is \`headers\`. Send \`x-authenticated-user-id\` (and optional \`x-authenticated-user-role\`) for local work and tests. There are no login endpoints and no \`users\` table. Production must set \`AUTH_DEV_HEADERS=false\`, which turns those headers off and leaves you without a guard, so pick another auth layer before you ship.` : authUsesToken(layers.auth) ? `Sign in with \`GET /api/v1/auth/csrf\` then \`POST /api/v1/auth/login\` (send \`X-CSRF-Token\`), then send \`Authorization: Bearer <token>\` on every request. Tokens are stored hashed in \`api_tokens\` and expire after \`API_TOKEN_DEFAULT_EXPIRY_DAYS\` (30 in \`.env.example\`). The response includes \`expires_at\`.` : `Mint a JWT with \`POST /api/auth/token\`, then send \`Authorization: Bearer <jwt>\`. JWTs expire; re-mint rather than refreshing in place.`;
2648
2976
  return `# ${projectName} API
2649
2977
 
2650
2978
  \`FRONTEND_MODE=${layers.frontend}\`. ${layers.frontend === "api" ? "No server-rendered views beyond the welcome page and no SPA assets." : `HTML is served alongside this API. The SPA is mounted at \`${layers.spaPrefix}\`.`}
@@ -2756,7 +3084,7 @@ ${next.join(`
2756
3084
  Open http://localhost:3000. Health check: \`GET /health\`.
2757
3085
 
2758
3086
  ${renderSupportingToolsReadme(layers)}${layers.auth !== "headers" ? `
2759
- Seeded login (password \`password\`):
3087
+ Seeded login (password \`StrataDemo!ChangeMe\`):
2760
3088
 
2761
3089
  - \`demo@example.com\` (member)
2762
3090
  - \`admin@example.test\` (admin)
@@ -2792,7 +3120,7 @@ Until \`frontend/dist\` exists, \`${layers.spaPrefix}\` answers 503. Use \`bun r
2792
3120
  ` : ""}${layers.database === "sqlite" ? "" : `
2793
3121
  ## Database
2794
3122
 
2795
- The app uses the database named in \`DATABASE_URL\` and creates it on first migrate when the connection user may. Set \`APP_DATABASE_URL\` only when migrations and the app should target a different database than \`DATABASE_URL\`.
3123
+ The app uses the database named in \`DATABASE_URL\` and creates it on first migrate when the connection user may. Set \`APP_DATABASE_URL\` only when migrations and the app should target a different database than \`DATABASE_URL\`.${usesComposePostgres(layers) ? ` \`.env.example\` points \`DATABASE_URL\` at \`${GENERATED_POSTGRES_APP_ROLE}\` (\`NOSUPERUSER\` \`NOBYPASSRLS\`), including \`--no-docker\`. \`db/ensure-postgres-app-role.sql\` is repeatable on an existing volume; Compose init still runs on first empty volume. The \`postgres\` superuser is for CREATE ROLE / GRANT / migrate and Adminer.` : ""}
2796
3124
  `}
2797
3125
  ## Deploy
2798
3126
 
@@ -2816,9 +3144,110 @@ The image sets \`APP_ENV=production\` and \`AUTH_DEV_HEADERS=false\`; everything
2816
3144
  - Set \`FEATURE_PUBLIC_READS=false\`. ${layers.frontend === "api" ? "This app already ships `false`." : "This app ships `true` so the local welcome page reads without a login. Production requires `false`."}
2817
3145
  - Cross-origin browser calls are off in production until you set \`CORS_ALLOWED_ORIGINS\` to explicit origins. A \`*\` entry is rejected. Non-browser clients are unaffected.
2818
3146
  - Behind a reverse proxy or load balancer, set \`TRUST_FORWARDED_FOR=true\` so throttles and session records see the client address instead of the proxy. Only the rightmost public hop of \`X-Forwarded-For\` is trusted.
2819
- ${authUsesCookie(layers.auth) ? "- Set `SESSION_SECRET` to 32+ characters.\n" : ""}${authUsesToken(layers.auth) ? "- Set `TOKEN_HASH_PEPPER`.\n" : ""}${layers.extras.scim ? "- Set `SCIM_BEARER_TOKEN`.\n" : ""}${layers.extras.metrics ? "- Set `METRICS_TOKEN`.\n" : ""}
2820
- \`strata start\` does not migrate when \`APP_ENV=production\`. Run \`bun run db:migrate\` as a deploy step. \`GET /health\` answers 503 until the schema exists, so a fresh deploy stays out of rotation until it is migrated.
3147
+ ${authUsesCookie(layers.auth) ? "- Set `SESSION_SECRET` to 32+ characters.\n" : ""}${authUsesToken(layers.auth) ? "- Set `TOKEN_HASH_PEPPER`.\n" : ""}${layers.extras.scim ? "- Set `SCIM_BEARER_TOKEN`.\n" : ""}${layers.extras.metrics ? "- Set `METRICS_TOKEN`.\n" : ""}${layers.tenancy === "rls" ? `- \`DATABASE_URL\` must be a \`NOBYPASSRLS\` role, not the \`postgres\` superuser. Generated apps create \`${GENERATED_POSTGRES_APP_ROLE}\` via \`db/ensure-postgres-app-role.sql\` (and Compose init on first empty volume). \`MIGRATION_DATABASE_URL\` may stay the superuser for CREATE ROLE / GRANT / migrate. Production boot rejects username \`postgres\` or \`root\`, then inspects \`pg_roles\` for \`rolsuper\` / \`rolbypassrls\`.
3148
+ ` : ""}
3149
+ \`strata start\` does not migrate when \`APP_ENV=production\`. Run \`bun run db:migrate\` as a deploy step. \`GET /health\` is 200 when \`notes\` is readable (including zero rows) and 503 when that read fails.
3150
+ `;
3151
+ }
3152
+
3153
+ // ../../src/core/runtime/appEnv.ts
3154
+ var NON_PRODUCTION_APP_ENVS = new Set(["local", "development", "dev", "test", "testing", "ci"]);
3155
+
3156
+ // ../../src/core/tenant/enableTenantRls.ts
3157
+ function assertSafeIdentifier(identifier) {
3158
+ if (!/^[A-Za-z_][A-Za-z0-9_]*$/.test(identifier)) {
3159
+ throw new Error(`Invalid SQL identifier: ${identifier}`);
3160
+ }
3161
+ return identifier;
3162
+ }
3163
+ var RLS_HELPER_SQL = `
3164
+ CREATE OR REPLACE FUNCTION app_bypass_rls()
3165
+ RETURNS BOOLEAN AS $$
3166
+ BEGIN
3167
+ RETURN COALESCE(current_setting('app.bypass_rls', true), 'false') = 'true';
3168
+ EXCEPTION
3169
+ WHEN others THEN
3170
+ RETURN FALSE;
3171
+ END;
3172
+ $$ LANGUAGE plpgsql STABLE;
3173
+
3174
+ CREATE OR REPLACE FUNCTION app_bypass_identifier()
3175
+ RETURNS TEXT AS $$
3176
+ BEGIN
3177
+ RETURN NULLIF(current_setting('app.bypass_identifier', true), '');
3178
+ EXCEPTION
3179
+ WHEN others THEN
3180
+ RETURN NULL;
3181
+ END;
3182
+ $$ LANGUAGE plpgsql STABLE;
3183
+
3184
+ CREATE OR REPLACE FUNCTION app_current_tenant_id()
3185
+ RETURNS INTEGER AS $$
3186
+ BEGIN
3187
+ RETURN NULLIF(current_setting('app.tenant_id', true), '')::INTEGER;
3188
+ EXCEPTION
3189
+ WHEN others THEN
3190
+ RETURN NULL;
3191
+ END;
3192
+ $$ LANGUAGE plpgsql STABLE;
2821
3193
  `;
3194
+ function tenantIsolationPolicy(table, predicate) {
3195
+ return `
3196
+ ALTER TABLE ${table} ENABLE ROW LEVEL SECURITY;
3197
+ ALTER TABLE ${table} FORCE ROW LEVEL SECURITY;
3198
+ DROP POLICY IF EXISTS tenant_isolation ON ${table};
3199
+ CREATE POLICY tenant_isolation ON ${table}
3200
+ USING (
3201
+ app_bypass_rls()
3202
+ OR ${predicate}
3203
+ )
3204
+ WITH CHECK (
3205
+ app_bypass_rls()
3206
+ OR ${predicate}
3207
+ );
3208
+ `;
3209
+ }
3210
+ function enableTenantRlsSql(tableName) {
3211
+ const table = assertSafeIdentifier(tableName);
3212
+ return tenantIsolationPolicy(table, `tenant_id = app_current_tenant_id()`);
3213
+ }
3214
+ function enableUsersRlsSql() {
3215
+ return tenantIsolationPolicy("users", `tenant_id = app_current_tenant_id()
3216
+ OR (
3217
+ app_bypass_identifier() IS NOT NULL
3218
+ AND (
3219
+ id::text = app_bypass_identifier()
3220
+ OR email = app_bypass_identifier()
3221
+ )
3222
+ )`);
3223
+ }
3224
+ function userOwnedIdentifierPredicate(table, userIdColumn) {
3225
+ const userIdMatch = `${table}.${userIdColumn}::text = app_bypass_identifier()`;
3226
+ if (table === "sessions") {
3227
+ return `(app_bypass_identifier() IS NOT NULL AND (${table}.id::text = app_bypass_identifier() OR ${userIdMatch}))`;
3228
+ }
3229
+ if (table === "api_tokens" || table === "auth_one_time_tokens") {
3230
+ return `(app_bypass_identifier() IS NOT NULL AND (${table}.token_hash = app_bypass_identifier() OR ${userIdMatch}))`;
3231
+ }
3232
+ return `(app_bypass_identifier() IS NOT NULL AND ${userIdMatch})`;
3233
+ }
3234
+ function enableUserOwnedRlsSql(tableName, userIdColumn = "user_id") {
3235
+ const table = assertSafeIdentifier(tableName);
3236
+ const column = assertSafeIdentifier(userIdColumn);
3237
+ return tenantIsolationPolicy(table, `EXISTS (
3238
+ SELECT 1 FROM users u
3239
+ WHERE u.id = ${table}.${column}
3240
+ AND u.tenant_id = app_current_tenant_id()
3241
+ )
3242
+ OR ${userOwnedIdentifierPredicate(table, column)}`);
3243
+ }
3244
+ function generatedRlsBootstrapSql(tables, userOwnedTables = []) {
3245
+ return [
3246
+ RLS_HELPER_SQL,
3247
+ ...tables.map((table) => table === "users" ? enableUsersRlsSql() : enableTenantRlsSql(table)),
3248
+ ...userOwnedTables.map((table) => enableUserOwnedRlsSql(table))
3249
+ ].join(`
3250
+ `);
2822
3251
  }
2823
3252
 
2824
3253
  // src/renderRuntime.ts
@@ -3053,17 +3482,19 @@ function renderMigrateTs(layers) {
3053
3482
  statements.push(`CREATE TABLE IF NOT EXISTS tenant (
3054
3483
  id ${d.id},
3055
3484
  slug ${d.keyText} NOT NULL UNIQUE,
3056
- plan ${d.defaultText} NOT NULL DEFAULT 'enterprise',
3485
+ plan ${d.defaultText} NOT NULL DEFAULT 'free',
3057
3486
  region ${d.defaultText} NOT NULL DEFAULT 'eu'
3058
3487
  )`);
3059
3488
  }
3489
+ const notesTenantColumn = tenancyOn ? `
3490
+ tenant_id INTEGER NOT NULL DEFAULT 1,` : "";
3060
3491
  statements.push(`CREATE TABLE IF NOT EXISTS notes (
3061
3492
  id ${d.id},
3062
- body ${d.text} NOT NULL,
3493
+ body ${d.text} NOT NULL,${notesTenantColumn}
3063
3494
  created_at ${d.timestamp}
3064
3495
  )`);
3065
3496
  if (authNeedsUsers(layers.auth)) {
3066
- const tenantColumn = tenancyOn ? `
3497
+ const tenantColumn = tenancyOn || layers.extras.scim ? `
3067
3498
  tenant_id INTEGER NOT NULL DEFAULT 1,` : "";
3068
3499
  const mfaColumns = mfaOn ? `
3069
3500
  mfa_secret ${d.text},
@@ -3075,8 +3506,17 @@ function renderMigrateTs(layers) {
3075
3506
  email ${d.keyText} NOT NULL UNIQUE,
3076
3507
  password ${d.text} NOT NULL,
3077
3508
  is_admin ${d.bool},${tenantColumn}${mfaColumns}
3509
+ session_valid_after ${d.timestampNull},
3078
3510
  email_verified_at ${d.timestampNull},
3079
3511
  created_at ${d.timestamp}
3512
+ )`);
3513
+ statements.push(`CREATE TABLE IF NOT EXISTS auth_one_time_tokens (
3514
+ id ${d.id},
3515
+ purpose ${d.keyText} NOT NULL,
3516
+ user_id INTEGER NOT NULL,
3517
+ token_hash ${d.keyText} NOT NULL UNIQUE,
3518
+ expires_at ${d.timestamp},
3519
+ consumed_at ${d.timestampNull}
3080
3520
  )`);
3081
3521
  }
3082
3522
  if (authUsesCookie(layers.auth)) {
@@ -3086,7 +3526,15 @@ function renderMigrateTs(layers) {
3086
3526
  expires_at ${d.timestamp},
3087
3527
  user_agent ${d.text},
3088
3528
  ip_address ${d.text},
3089
- last_active_at ${d.timestamp}
3529
+ last_active_at ${d.timestamp},
3530
+ created_at ${d.timestamp}
3531
+ )`);
3532
+ if (layers.database === "postgres") {
3533
+ statements.push("ALTER TABLE sessions ADD COLUMN IF NOT EXISTS created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()");
3534
+ }
3535
+ statements.push(`CREATE TABLE IF NOT EXISTS auth_saml_assertions (
3536
+ assertion_id ${d.keyText} PRIMARY KEY,
3537
+ consumed_at ${d.timestamp}
3090
3538
  )`);
3091
3539
  }
3092
3540
  if (authUsesToken(layers.auth)) {
@@ -3101,16 +3549,26 @@ function renderMigrateTs(layers) {
3101
3549
  created_at ${d.timestamp}
3102
3550
  )`);
3103
3551
  }
3104
- const list = statements.map((sql) => ` \`${sql}\`,`).join(`
3552
+ const rlsOn = layers.tenancy === "rls" && layers.database === "postgres";
3553
+ if (rlsOn) {
3554
+ const rlsTables = authNeedsUsers(layers.auth) ? ["notes", "users"] : ["notes"];
3555
+ const userOwnedTables = [];
3556
+ if (authNeedsUsers(layers.auth)) {
3557
+ userOwnedTables.push("auth_one_time_tokens");
3558
+ }
3559
+ if (authUsesCookie(layers.auth)) {
3560
+ userOwnedTables.push("sessions");
3561
+ }
3562
+ if (authUsesToken(layers.auth)) {
3563
+ userOwnedTables.push("api_tokens");
3564
+ }
3565
+ statements.push(generatedRlsBootstrapSql(rlsTables, userOwnedTables).trim());
3566
+ }
3567
+ const list = statements.map((sql) => ` \`${sql.replace(/`/g, "\\`")}\`,`).join(`
3105
3568
  `);
3106
3569
  const ph = layers.database === "postgres";
3107
3570
  const verifyOn = layers.extras.emailVerification && authNeedsUsers(layers.auth);
3108
- const userColumns = verifyOn ? "name, email, password, is_admin, email_verified_at" : "name, email, password, is_admin";
3109
- const userPlaceholders = verifyOn ? ph ? "$1, $2, $3, $4, $5), ($6, $7, $8, $9, $10" : "?, ?, ?, ?, ?), (?, ?, ?, ?, ?" : ph ? "$1, $2, $3, $4), ($5, $6, $7, $8" : "?, ?, ?, ?), (?, ?, ?, ?";
3110
- const adminFlag = ph ? "false" : "0";
3111
- const adminTrue = ph ? "true" : "1";
3112
3571
  const verifiedNow = nowTimestampLiteral(layers.database);
3113
- const userValues = verifyOn ? `["Demo User", "demo@example.com", password, ${adminFlag}, ${verifiedNow}, "Admin User", "admin@example.test", password, ${adminTrue}, ${verifiedNow}]` : `["Demo User", "demo@example.com", password, ${adminFlag}, "Admin User", "admin@example.test", password, ${adminTrue}]`;
3114
3572
  const seedTenant = tenancyOn ? `
3115
3573
  const [{ count: tenantCount }] = await sql.unsafe<{ count: string | number }>(
3116
3574
  "SELECT COUNT(*) AS count FROM tenant",
@@ -3118,45 +3576,80 @@ function renderMigrateTs(layers) {
3118
3576
  if (Number(tenantCount) === 0) {
3119
3577
  await sql.unsafe(
3120
3578
  "INSERT INTO tenant (slug, plan, region) VALUES (${ph ? "$1, $2, $3" : "?, ?, ?"})",
3121
- ["default", "enterprise", "eu"],
3579
+ ["default", "free", "eu"],
3122
3580
  );
3123
3581
  }` : "";
3124
3582
  const seedUsers = authNeedsUsers(layers.auth) ? `
3125
- const [{ count: userCount }] = await sql.unsafe<{ count: string | number }>(
3126
- "SELECT COUNT(*) AS count FROM users",
3127
- );
3128
- if (Number(userCount) === 0) {
3129
- const password = await hashPassword("password");
3130
- await sql.unsafe(
3131
- "INSERT INTO users (${userColumns}) VALUES (${userPlaceholders})",
3132
- ${userValues},
3133
- );
3583
+ if ((await User.query().value("id")) === null) {
3584
+ const password = await hashPassword("StrataDemo!ChangeMe");
3585
+ await User.create({
3586
+ name: "Demo User",
3587
+ email: "demo@example.com",
3588
+ password,
3589
+ is_admin: false,${verifyOn ? `
3590
+ email_verified_at: ${verifiedNow},` : ""}
3591
+ });
3592
+ await User.create({
3593
+ name: "Admin User",
3594
+ email: "admin@example.test",
3595
+ password,
3596
+ is_admin: true,${verifyOn ? `
3597
+ email_verified_at: ${verifiedNow},` : ""}
3598
+ });
3134
3599
  }` : "";
3135
3600
  const hashImport = authNeedsUsers(layers.auth) ? `import { hashPassword } from "@getstrata/core/auth/password";
3136
3601
  ` : "";
3137
- const seedBlock = `${seedTenant}${seedUsers}`;
3138
- const bindSql = seedBlock.length > 0 ? ` const sql = getSql();
3139
- ` : ` getSql();
3602
+ const rlsBypassImport = layers.tenancy === "rls" && layers.database === "postgres" ? `import { runWithMigrationBypass } from "@getstrata/core/tenant/databaseTenantContext";
3603
+ ` : "";
3604
+ const postgresRoleImport = layers.database === "postgres" ? `import { grantPostgresAppRolePrivileges, openPostgresAdminConnection, postgresDatabaseNameFromUrl } from "@getstrata/core/tenant/enableTenantRls";
3605
+ ` : "";
3606
+ const bindSql = seedTenant || layers.tenancy === "rls" && layers.database === "postgres" ? ` const sql = getSql();
3607
+ ` : "";
3608
+ const seedOpen = layers.tenancy === "rls" && layers.database === "postgres" ? ` await runWithMigrationBypass(async () => {
3609
+ ` : "";
3610
+ const seedClose = layers.tenancy === "rls" && layers.database === "postgres" ? `
3611
+ });` : "";
3612
+ const noteCreate = tenancyOn ? `await Note.create({ body: "Welcome to Strata!", tenant_id: 1 });` : `await Note.create({ body: "Welcome to Strata!" });`;
3613
+ const userImport = authNeedsUsers(layers.auth) ? `import { User } from "../models/User.ts";
3614
+ ` : "";
3615
+ const needsSqlClient = layers.database !== "postgres" || Boolean(seedTenant) || layers.tenancy === "rls" && layers.database === "postgres";
3616
+ const databaseImport = needsSqlClient ? `import { closeDatabase, getSql } from "../bootstrap/database.ts";
3617
+ ` : `import { closeDatabase } from "../bootstrap/database.ts";
3140
3618
  `;
3141
- return `${hashImport}${ensureImport(layers)}import { closeDatabase, getSql } from "../bootstrap/database.ts";
3142
- import { Note } from "../models/Note.ts";
3143
-
3619
+ return `${hashImport}${rlsBypassImport}${postgresRoleImport}${ensureImport(layers)}${databaseImport}import { Note } from "../models/Note.ts";
3620
+ ${userImport}
3144
3621
  const migrations = [
3145
3622
  ${list}
3146
3623
  ];
3147
3624
 
3148
3625
  export async function seed() {
3149
- ${ensureCall(layers)}${bindSql} if ((await Note.query().value("id")) === null) {
3150
- await Note.create({ body: "Welcome to Strata!" });
3151
- }${seedBlock}
3626
+ ${ensureCall(layers)}${bindSql}${seedOpen}${seedTenant}
3627
+ if ((await Note.query().value("id")) === null) {
3628
+ ${noteCreate}
3629
+ }${seedUsers}${seedClose}
3152
3630
  }
3153
3631
 
3154
3632
  export async function migrate() {
3155
- ${ensureCall(layers)} const sql = getSql();
3633
+ ${ensureCall(layers)}${layers.database === "postgres" ? ` const runtimeUrl = process.env.DATABASE_URL ?? "";
3634
+ const admin = await openPostgresAdminConnection({
3635
+ runtimeUrl,
3636
+ migrationUrl: process.env.MIGRATION_DATABASE_URL,
3637
+ });
3638
+ try {
3639
+ for (const statement of migrations) {
3640
+ await admin.unsafe(statement);
3641
+ }
3642
+ await grantPostgresAppRolePrivileges(admin, {
3643
+ database: postgresDatabaseNameFromUrl(runtimeUrl),
3644
+ });
3645
+ } finally {
3646
+ await admin.close?.();
3647
+ }
3648
+ ` : ` const sql = getSql();
3156
3649
  for (const statement of migrations) {
3157
3650
  await sql.unsafe(statement);
3158
3651
  }
3159
- await seed();
3652
+ `} await seed();
3160
3653
  }
3161
3654
 
3162
3655
  /** The CLI calls this after migrate() so pooled drivers do not hold the process open. */
@@ -3172,21 +3665,26 @@ if (import.meta.main) {
3172
3665
  }
3173
3666
  `;
3174
3667
  }
3175
- function renderNoteModel() {
3668
+ function renderNoteModel(layers) {
3669
+ const tenancyOn = usesTenantTable(layers.tenancy);
3670
+ const tenantField = tenancyOn ? `
3671
+ tenant_id: number;` : "";
3672
+ const tenantColumn = tenancyOn ? ', "tenant_id"' : "";
3673
+ const fillable = tenancyOn ? '["body", "tenant_id"]' : '["body"]';
3176
3674
  return `import { BaseRepository } from "@getstrata/core/database/baseRepository";
3177
3675
  import { Model, registerModelRepository } from "@getstrata/core/database/model";
3178
3676
  import { defineTable } from "@getstrata/core/database/table";
3179
3677
 
3180
3678
  interface NoteRecord {
3181
3679
  id: number;
3182
- body: string;
3680
+ body: string;${tenantField}
3183
3681
  created_at: Date | string;
3184
3682
  }
3185
3683
 
3186
3684
  const notesTable = defineTable<NoteRecord, "id">({
3187
3685
  name: "notes",
3188
3686
  primaryKey: "id",
3189
- columns: ["id", "body", "created_at"],
3687
+ columns: ["id", "body"${tenantColumn}, "created_at"],
3190
3688
  defaultOrderBy: { column: "id", direction: "ASC" },
3191
3689
  });
3192
3690
 
@@ -3197,7 +3695,7 @@ class NoteRepository extends BaseRepository<NoteRecord, "id"> {
3197
3695
  }
3198
3696
 
3199
3697
  class Note extends Model<NoteRecord, "id"> {
3200
- static $fillable = ["body"] as const;
3698
+ static $fillable = ${fillable} as const;
3201
3699
  // created_at uses the table default. Sending a JS Date from $timestamps
3202
3700
  // is rejected by SQLite bindings.
3203
3701
  static $timestamps = false;
@@ -3209,6 +3707,160 @@ export type { NoteRecord };
3209
3707
  export { Note };
3210
3708
  `;
3211
3709
  }
3710
+ function renderUserModel(layers) {
3711
+ const tenancyOn = usesTenantTable(layers.tenancy) || layers.extras.scim;
3712
+ const mfaOn = Boolean(layers.extras.mfa);
3713
+ const tenantField = tenancyOn ? `
3714
+ tenant_id: number;` : "";
3715
+ const mfaFields = mfaOn ? `
3716
+ mfa_secret: string | null;
3717
+ mfa_enabled: number | boolean;
3718
+ mfa_recovery_codes: string | null;` : "";
3719
+ const columns = ["id", "name", "email", "password", "is_admin"];
3720
+ if (tenancyOn) {
3721
+ columns.push("tenant_id");
3722
+ }
3723
+ if (mfaOn) {
3724
+ columns.push("mfa_secret", "mfa_enabled", "mfa_recovery_codes");
3725
+ }
3726
+ columns.push("session_valid_after", "email_verified_at", "created_at");
3727
+ const fillable = ["name", "email", "password", "is_admin"];
3728
+ if (tenancyOn) {
3729
+ fillable.push("tenant_id");
3730
+ }
3731
+ if (mfaOn) {
3732
+ fillable.push("mfa_secret", "mfa_enabled", "mfa_recovery_codes");
3733
+ }
3734
+ fillable.push("session_valid_after", "email_verified_at");
3735
+ const hidden = ["password"];
3736
+ if (mfaOn) {
3737
+ hidden.push("mfa_secret", "mfa_recovery_codes");
3738
+ }
3739
+ const fillableLiteral = `[${fillable.map((column) => `"${column}"`).join(", ")}]`;
3740
+ const hiddenLiteral = `[${hidden.map((column) => `"${column}"`).join(", ")}]`;
3741
+ const columnsLiteral = columns.map((column) => `"${column}"`).join(", ");
3742
+ return `import { BaseRepository } from "@getstrata/core/database/baseRepository";
3743
+ import { Model, registerModelRepository } from "@getstrata/core/database/model";
3744
+ import { defineTable } from "@getstrata/core/database/table";
3745
+
3746
+ interface UserRecord {
3747
+ id: number;
3748
+ name: string;
3749
+ email: string;
3750
+ password: string;
3751
+ is_admin: number | boolean;${tenantField}${mfaFields}
3752
+ session_valid_after: Date | string | null;
3753
+ email_verified_at: Date | string | null;
3754
+ created_at: Date | string;
3755
+ }
3756
+
3757
+ const usersTable = defineTable<UserRecord, "id">({
3758
+ name: "users",
3759
+ primaryKey: "id",
3760
+ columns: [${columnsLiteral}],
3761
+ defaultOrderBy: { column: "id", direction: "ASC" },
3762
+ });
3763
+
3764
+ class UserRepository extends BaseRepository<UserRecord, "id"> {
3765
+ constructor() {
3766
+ super(usersTable);
3767
+ }
3768
+ }
3769
+
3770
+ class User extends Model<UserRecord, "id"> {
3771
+ static $fillable = ${fillableLiteral} as const;
3772
+ static $hidden = ${hiddenLiteral} as const;
3773
+ // created_at uses the table default. Sending a JS Date from $timestamps
3774
+ // is rejected by SQLite bindings.
3775
+ static $timestamps = false;
3776
+ }
3777
+
3778
+ registerModelRepository(User, new UserRepository());
3779
+
3780
+ export type { UserRecord };
3781
+ export { User };
3782
+ `;
3783
+ }
3784
+ function renderApiTokenModel() {
3785
+ return `import { BaseRepository } from "@getstrata/core/database/baseRepository";
3786
+ import { Model, registerModelRepository } from "@getstrata/core/database/model";
3787
+ import { defineTable } from "@getstrata/core/database/table";
3788
+
3789
+ interface ApiTokenRecord {
3790
+ id: number;
3791
+ user_id: number;
3792
+ name: string;
3793
+ token_hash: string;
3794
+ abilities: string;
3795
+ expires_at: Date | string | null;
3796
+ last_used_at: Date | string | null;
3797
+ created_at: Date | string;
3798
+ }
3799
+
3800
+ const apiTokensTable = defineTable<ApiTokenRecord, "id">({
3801
+ name: "api_tokens",
3802
+ primaryKey: "id",
3803
+ columns: ["id", "user_id", "name", "token_hash", "abilities", "expires_at", "last_used_at", "created_at"],
3804
+ defaultOrderBy: { column: "id", direction: "ASC" },
3805
+ });
3806
+
3807
+ class ApiTokenRepository extends BaseRepository<ApiTokenRecord, "id"> {
3808
+ constructor() {
3809
+ super(apiTokensTable);
3810
+ }
3811
+ }
3812
+
3813
+ class ApiToken extends Model<ApiTokenRecord, "id"> {
3814
+ static $fillable = ["user_id", "name", "token_hash", "abilities", "expires_at", "last_used_at"] as const;
3815
+ // created_at uses the table default. Sending a JS Date from $timestamps
3816
+ // is rejected by SQLite bindings.
3817
+ static $timestamps = false;
3818
+ }
3819
+
3820
+ registerModelRepository(ApiToken, new ApiTokenRepository());
3821
+
3822
+ export type { ApiTokenRecord };
3823
+ export { ApiToken };
3824
+ `;
3825
+ }
3826
+ function renderAuthOneTimeTokenModel() {
3827
+ return `import { BaseRepository } from "@getstrata/core/database/baseRepository";
3828
+ import { Model, registerModelRepository } from "@getstrata/core/database/model";
3829
+ import { defineTable } from "@getstrata/core/database/table";
3830
+
3831
+ interface AuthOneTimeTokenRecord {
3832
+ id: number;
3833
+ purpose: string;
3834
+ user_id: number;
3835
+ token_hash: string;
3836
+ expires_at: Date | string;
3837
+ consumed_at: Date | string | null;
3838
+ }
3839
+
3840
+ const authOneTimeTokensTable = defineTable<AuthOneTimeTokenRecord, "id">({
3841
+ name: "auth_one_time_tokens",
3842
+ primaryKey: "id",
3843
+ columns: ["id", "purpose", "user_id", "token_hash", "expires_at", "consumed_at"],
3844
+ defaultOrderBy: { column: "id", direction: "ASC" },
3845
+ });
3846
+
3847
+ class AuthOneTimeTokenRepository extends BaseRepository<AuthOneTimeTokenRecord, "id"> {
3848
+ constructor() {
3849
+ super(authOneTimeTokensTable);
3850
+ }
3851
+ }
3852
+
3853
+ class AuthOneTimeToken extends Model<AuthOneTimeTokenRecord, "id"> {
3854
+ static $fillable = ["purpose", "user_id", "token_hash", "expires_at", "consumed_at"] as const;
3855
+ static $timestamps = false;
3856
+ }
3857
+
3858
+ registerModelRepository(AuthOneTimeToken, new AuthOneTimeTokenRepository());
3859
+
3860
+ export type { AuthOneTimeTokenRecord };
3861
+ export { AuthOneTimeToken };
3862
+ `;
3863
+ }
3212
3864
  function dropTables(layers) {
3213
3865
  const ordered = [];
3214
3866
  if (authUsesToken(layers.auth)) {
@@ -3216,8 +3868,10 @@ function dropTables(layers) {
3216
3868
  }
3217
3869
  if (authUsesCookie(layers.auth)) {
3218
3870
  ordered.push("sessions");
3871
+ ordered.push("auth_saml_assertions");
3219
3872
  }
3220
3873
  if (authNeedsUsers(layers.auth)) {
3874
+ ordered.push("auth_one_time_tokens");
3221
3875
  ordered.push("users");
3222
3876
  }
3223
3877
  ordered.push("notes");
@@ -3226,20 +3880,31 @@ function dropTables(layers) {
3226
3880
  }
3227
3881
  return ordered;
3228
3882
  }
3883
+ function renderPostgresAdminDropCall() {
3884
+ return ` await dropPostgresTablesAsAdmin(tables, {
3885
+ runtimeUrl: process.env.DATABASE_URL ?? "",
3886
+ migrationUrl: process.env.MIGRATION_DATABASE_URL,
3887
+ });
3888
+ `;
3889
+ }
3229
3890
  function renderFreshTs(layers) {
3230
3891
  const tables = dropTables(layers);
3231
3892
  const cascade = layers.database === "sqlite" ? "" : " CASCADE";
3232
- return `${ensureImport(layers)}import { closeDatabase, getSql } from "../bootstrap/database.ts";
3233
- import { migrate } from "./migrate.ts";
3893
+ const imports = layers.database === "postgres" ? `import { dropPostgresTablesAsAdmin } from "@getstrata/core/tenant/enableTenantRls";
3894
+ ${ensureImport(layers)}import { closeDatabase } from "../bootstrap/database.ts";
3895
+ import { migrate } from "./migrate.ts";` : `${ensureImport(layers)}import { closeDatabase, getSql } from "../bootstrap/database.ts";
3896
+ import { migrate } from "./migrate.ts";`;
3897
+ const dropBody = layers.database === "postgres" ? `${renderPostgresAdminDropCall()} await migrate();` : ` const sql = getSql();
3898
+ for (const table of tables) {
3899
+ await sql.unsafe(\`DROP TABLE IF EXISTS \${table}${cascade}\`);
3900
+ }
3901
+ await migrate();`;
3902
+ return `${imports}
3234
3903
 
3235
3904
  const tables = ${JSON.stringify(tables)};
3236
3905
 
3237
3906
  export async function fresh() {
3238
- ${ensureCall(layers)} const sql = getSql();
3239
- for (const table of tables) {
3240
- await sql.unsafe(\`DROP TABLE IF EXISTS \${table}${cascade}\`);
3241
- }
3242
- await migrate();
3907
+ ${ensureCall(layers)}${dropBody}
3243
3908
  }
3244
3909
 
3245
3910
  /** The CLI calls this after fresh() so pooled drivers do not hold the process open. */
@@ -3297,16 +3962,21 @@ if (import.meta.main) {
3297
3962
  function renderRollbackTs(layers) {
3298
3963
  const tables = dropTables(layers);
3299
3964
  const cascade = layers.database === "sqlite" ? "" : " CASCADE";
3300
- return `${ensureImport(layers)}import { getSql } from "../bootstrap/database.ts";
3965
+ const imports = layers.database === "postgres" ? `import { dropPostgresTablesAsAdmin } from "@getstrata/core/tenant/enableTenantRls";
3966
+ ${ensureImport(layers)}` : `${ensureImport(layers)}import { getSql } from "../bootstrap/database.ts";`;
3967
+ const dropBody = layers.database === "postgres" ? `${renderPostgresAdminDropCall()} for (const table of tables) {
3968
+ console.log(\`dropped \${table}\`);
3969
+ }` : ` const sql = getSql();
3970
+ for (const table of tables) {
3971
+ await sql.unsafe(\`DROP TABLE IF EXISTS \${table}${cascade}\`);
3972
+ console.log(\`dropped \${table}\`);
3973
+ }`;
3974
+ return `${imports}
3301
3975
 
3302
3976
  const tables = ${JSON.stringify(tables)};
3303
3977
 
3304
3978
  export async function rollback() {
3305
- ${ensureCall(layers)} const sql = getSql();
3306
- for (const table of tables) {
3307
- await sql.unsafe(\`DROP TABLE IF EXISTS \${table}${cascade}\`);
3308
- console.log(\`dropped \${table}\`);
3309
- }
3979
+ ${ensureCall(layers)}${dropBody}
3310
3980
  }
3311
3981
 
3312
3982
  if (import.meta.main) {
@@ -3317,13 +3987,15 @@ if (import.meta.main) {
3317
3987
  `;
3318
3988
  }
3319
3989
  function renderPreloadTs(layers, projectName) {
3320
- const database = appDatabaseName(projectName);
3321
- const fallback = layers.database === "sqlite" ? "sqlite:./storage/app.sqlite" : layers.database === "mysql" ? `mysql://root:root@localhost:3306/${database}` : `postgresql://postgres:postgres@localhost:5432/${database}`;
3990
+ const fallback = defaultDatabaseUrl(layers, projectName);
3991
+ const migrationFallback = defaultMigrationDatabaseUrl(layers, projectName);
3992
+ const migrationLine = migrationFallback ? `process.env.MIGRATION_DATABASE_URL ??= ${JSON.stringify(migrationFallback)};
3993
+ ` : "";
3322
3994
  return `import { join } from "node:path";
3323
3995
  import { configureModulesDirectory } from "@getstrata/bootstrap/discoverModules";
3324
3996
 
3325
3997
  process.env.DATABASE_URL ??= ${JSON.stringify(fallback)};
3326
- process.env.FRONTEND_MODE ??= ${JSON.stringify(layers.frontend)};
3998
+ ${migrationLine}process.env.FRONTEND_MODE ??= ${JSON.stringify(layers.frontend)};
3327
3999
  process.env.SPA_PREFIX ??= ${JSON.stringify(layers.spaPrefix)};
3328
4000
  process.env.CACHE_DRIVER ??= ${JSON.stringify(layers.cache)};
3329
4001
  process.env.QUEUE_DRIVER ??= ${JSON.stringify(layers.queue)};
@@ -3420,11 +4092,10 @@ function renderEnsureDatabaseTs(layers, projectName) {
3420
4092
  if (layers.database === "sqlite") {
3421
4093
  return null;
3422
4094
  }
3423
- const database = appDatabaseName(projectName);
3424
- const fallback = layers.database === "mysql" ? `mysql://root:root@localhost:3306/${database}` : `postgresql://postgres:postgres@localhost:5432/${database}`;
4095
+ const fallback = defaultDatabaseUrl(layers, projectName);
3425
4096
  const resolveUrl = `/**
3426
- * The database name comes from DATABASE_URL. Set APP_DATABASE_URL to point
3427
- * migrations and the app at a different database than DATABASE_URL.
4097
+ * Runtime URL. Superuser CREATE ROLE / CREATE DATABASE / GRANT / migrate uses
4098
+ * MIGRATION_DATABASE_URL when set.
3428
4099
  */
3429
4100
  function resolveAppDatabaseUrl(): string {
3430
4101
  const explicit = process.env.APP_DATABASE_URL?.trim();
@@ -3433,8 +4104,8 @@ function resolveAppDatabaseUrl(): string {
3433
4104
  }
3434
4105
  return process.env.DATABASE_URL?.trim() || DEFAULT_DATABASE_URL;
3435
4106
  }
3436
-
3437
- /** Reject anything we would have to quote before interpolating into DDL. */
4107
+ `;
4108
+ const safeName = `/** Reject anything we would have to quote before interpolating into DDL. */
3438
4109
  function safeDatabaseName(url: string): string {
3439
4110
  let name = "";
3440
4111
  try {
@@ -3456,7 +4127,7 @@ function safeDatabaseName(url: string): string {
3456
4127
 
3457
4128
  const DEFAULT_DATABASE_URL = ${JSON.stringify(fallback)};
3458
4129
 
3459
- ${resolveUrl}
4130
+ ${resolveUrl}${safeName}
3460
4131
  export async function ensureAppDatabase(): Promise<string> {
3461
4132
  const url = resolveAppDatabaseUrl();
3462
4133
  const name = safeDatabaseName(url);
@@ -3475,58 +4146,21 @@ export async function ensureAppDatabase(): Promise<string> {
3475
4146
  }
3476
4147
  `;
3477
4148
  }
3478
- return `const DEFAULT_DATABASE_URL = ${JSON.stringify(fallback)};
3479
-
3480
- ${resolveUrl}
3481
- function adminCandidateUrls(url: string): string[] {
3482
- const names = ["postgres", "template1"];
3483
- try {
3484
- const current = decodeURIComponent(new URL(url).pathname.replace(/^\\//, ""));
3485
- if (current && !names.includes(current)) {
3486
- names.push(current);
3487
- }
3488
- } catch {
3489
- }
3490
- return names.map((name) => {
3491
- const admin = new URL(url);
3492
- admin.pathname = \`/\${name}\`;
3493
- return admin.toString();
3494
- });
3495
- }
4149
+ return `import {
4150
+ ensurePostgresDatabaseAndAppRole,
4151
+ POSTGRES_APP_ROLE_PASSWORD,
4152
+ } from "@getstrata/core/tenant/enableTenantRls";
3496
4153
 
3497
- async function openAdminConnection(url: string): Promise<Bun.SQL> {
3498
- let lastError: unknown;
3499
- for (const candidate of adminCandidateUrls(url)) {
3500
- const adminSql = new Bun.SQL(candidate);
3501
- try {
3502
- await adminSql\`SELECT 1\`;
3503
- return adminSql;
3504
- } catch (error) {
3505
- lastError = error;
3506
- await adminSql.close().catch(() => undefined);
3507
- }
3508
- }
3509
- throw lastError instanceof Error
3510
- ? lastError
3511
- : new Error("Could not open an admin connection to create the app database.");
3512
- }
4154
+ const DEFAULT_DATABASE_URL = ${JSON.stringify(fallback)};
3513
4155
 
4156
+ ${resolveUrl}
3514
4157
  export async function ensureAppDatabase(): Promise<string> {
3515
4158
  const url = resolveAppDatabaseUrl();
3516
- const name = safeDatabaseName(url);
3517
-
3518
- const adminSql = await openAdminConnection(url);
3519
- try {
3520
- const rows = await adminSql\`
3521
- SELECT 1 AS ok FROM pg_database WHERE datname = \${name}
3522
- \`;
3523
- if (rows.length === 0) {
3524
- await adminSql.unsafe(\`CREATE DATABASE \${name}\`);
3525
- }
3526
- } finally {
3527
- await adminSql.close();
3528
- }
3529
-
4159
+ await ensurePostgresDatabaseAndAppRole({
4160
+ runtimeUrl: url,
4161
+ migrationUrl: process.env.MIGRATION_DATABASE_URL,
4162
+ password: process.env.STRATA_APP_PASSWORD ?? POSTGRES_APP_ROLE_PASSWORD,
4163
+ });
3530
4164
  process.env.DATABASE_URL = url;
3531
4165
  return url;
3532
4166
  }
@@ -3535,11 +4169,27 @@ export async function ensureAppDatabase(): Promise<string> {
3535
4169
  function renderSidecarsTs(_layers) {
3536
4170
  return null;
3537
4171
  }
4172
+ function renderPolicyProvider() {
4173
+ return `import { CORE_POLICY_GATE_TOKEN } from "@getstrata/bootstrap/config";
4174
+ import { PolicyGate } from "@getstrata/core/auth/policy";
4175
+ import type { ServiceProvider } from "@getstrata/core/contracts/di";
4176
+
4177
+ const policyProvider: ServiceProvider = {
4178
+ name: "core.policy",
4179
+ register({ container }) {
4180
+ container.set(CORE_POLICY_GATE_TOKEN, new PolicyGate());
4181
+ },
4182
+ };
4183
+
4184
+ export default policyProvider;
4185
+ `;
4186
+ }
3538
4187
  function renderProvidersIndex() {
3539
4188
  return `import type { ServiceProvider } from "@getstrata/core/contracts/di";
3540
4189
  import authProvider from "./auth.ts";
3541
4190
  import cacheProvider from "./cache.ts";
3542
4191
  import configProvider from "./config.ts";
4192
+ import policyProvider from "./policy.ts";
3543
4193
  import queueProvider from "./queue.ts";
3544
4194
  import storageProvider from "./storage.ts";
3545
4195
 
@@ -3549,6 +4199,7 @@ const starterProviders: ServiceProvider[] = [
3549
4199
  storageProvider,
3550
4200
  queueProvider,
3551
4201
  authProvider,
4202
+ policyProvider,
3552
4203
  ];
3553
4204
 
3554
4205
  export { starterProviders };
@@ -3580,10 +4231,11 @@ import {
3580
4231
  ensureModulesLoaded,
3581
4232
  } from "@getstrata/bootstrap/discoverModules";
3582
4233
  import { createHealthRoutes } from "@getstrata/bootstrap/health";
3583
- ${metricsImport}import { assertProductionSecrets } from "@getstrata/bootstrap/secretsGuard";
4234
+ ${metricsImport}import { assertProductionSecrets, assertRlsLiveDatabaseRole } from "@getstrata/bootstrap/secretsGuard";
3584
4235
  import { createWebServer } from "@getstrata/bootstrap/web/server";
3585
4236
  import { setActiveApplicationContext } from "@getstrata/core/runtime/applicationRegistry";
3586
4237
  import { isProductionEnv } from "@getstrata/core/runtime/appEnv";
4238
+ import { isRlsTenancy } from "@getstrata/core/tenant/tenancyConfig";
3587
4239
  import { migrate } from "../db/migrate.ts";
3588
4240
  import { buildRoutes } from "../routes.ts";
3589
4241
  import { loadConfig } from "./config.ts";
@@ -3654,6 +4306,9 @@ export async function bootstrapApp(options: BootstrapOptions = {}): Promise<Boot
3654
4306
  ${needsEnsure(layers) ? ` await ensureAppDatabase();
3655
4307
  ` : ""} const appConfig = loadConfig();
3656
4308
  getSql();
4309
+ if (isRlsTenancy()) {
4310
+ await assertRlsLiveDatabaseRole();
4311
+ }
3657
4312
  configureModulesDirectory(join(import.meta.dir, "../modules"));
3658
4313
  await ensureModulesLoaded();
3659
4314
  const context = createAppContext();
@@ -3754,36 +4409,22 @@ export function plainText(body: string, status = 200): Response {
3754
4409
  }
3755
4410
 
3756
4411
  // src/renderScim.ts
3757
- function ph2(layers, count, start = 1) {
3758
- if (layers.database === "postgres") {
3759
- return Array.from({ length: count }, (_, index) => `$${start + index}`).join(", ");
3760
- }
3761
- return Array.from({ length: count }, () => "?").join(", ");
3762
- }
3763
- function sqlFalse2(layers) {
3764
- return layers.database === "postgres" ? "false" : "0";
3765
- }
3766
4412
  function renderScimModule(layers) {
3767
4413
  if (!layers.extras.scim || !authNeedsUsers(layers.auth)) {
3768
4414
  return null;
3769
4415
  }
3770
- const tenantOn = usesTenantTable(layers.tenancy);
3771
- const insertCols = tenantOn ? "name, email, password, is_admin, tenant_id" : "name, email, password, is_admin";
3772
- const insertPh = tenantOn ? ph2(layers, 5) : ph2(layers, 4);
3773
- const insertTail = tenantOn ? `, ${sqlFalse2(layers)}, tenantId` : `, ${sqlFalse2(layers)}`;
3774
- const emailPh = ph2(layers, 1);
3775
- const idPh = ph2(layers, 1);
3776
- const updatePh = `${ph2(layers, 1)}, ${ph2(layers, 1, 2)}, ${ph2(layers, 1, 3)}`;
3777
4416
  return `import { randomBytes } from "node:crypto";
3778
4417
  import type { AppModule } from "@getstrata/bootstrap/contracts";
3779
4418
  import { routeParams } from "@getstrata/bootstrap/web/routing";
3780
4419
  import { createScimAuthMiddleware } from "@getstrata/core/auth/scimAuthMiddleware";
3781
4420
  import { hashPassword } from "@getstrata/core/auth/password";
4421
+ import { BadRequestError } from "@getstrata/core/errors/http";
4422
+ import { parsePositiveIntParam } from "@getstrata/core/http/validation";
3782
4423
  import { withErrorHandling } from "@getstrata/core/http/response";
3783
4424
  import { withMiddleware } from "@getstrata/core/http/routeMiddleware";
3784
4425
  import { createScimThrottleMiddleware } from "@getstrata/core/http/scimThrottleMiddleware";
3785
4426
  import { currentTenant } from "@getstrata/core/tenant/tenantContext";
3786
- import { getSql } from "../../bootstrap/database.ts";
4427
+ import { User } from "../../models/User.ts";
3787
4428
 
3788
4429
  const USER_SCHEMA = "urn:ietf:params:scim:schemas:core:2.0:User";
3789
4430
  const LIST_SCHEMA = "urn:ietf:params:scim:api:messages:2.0:ListResponse";
@@ -3791,12 +4432,29 @@ const ERROR_SCHEMA = "urn:ietf:params:scim:api:messages:2.0:Error";
3791
4432
  const PATCH_SCHEMA = "urn:ietf:params:scim:api:messages:2.0:PatchOp";
3792
4433
  const CONFIG_SCHEMA = "urn:ietf:params:scim:schemas:core:2.0:ServiceProviderConfig";
3793
4434
 
3794
- type UserRow = { id: number; name: string; email: string };
3795
-
3796
4435
  function scimEnabled(): boolean {
3797
4436
  return (process.env.FEATURE_SCIM ?? "false") === "true";
3798
4437
  }
3799
4438
 
4439
+ function tenantId(): number {
4440
+ const tenant = currentTenant();
4441
+ if (!tenant) {
4442
+ throw new Error("SCIM requires a tenant context.");
4443
+ }
4444
+ return tenant.id;
4445
+ }
4446
+
4447
+ function readScimUserId(request: Request): number | Response {
4448
+ try {
4449
+ return parsePositiveIntParam(routeParams(request).id ?? "", "id");
4450
+ } catch (error) {
4451
+ if (error instanceof BadRequestError) {
4452
+ return scimError("Invalid id.", 400);
4453
+ }
4454
+ throw error;
4455
+ }
4456
+ }
4457
+
3800
4458
  function scimJson(body: unknown, status = 200): Response {
3801
4459
  return Response.json(body, {
3802
4460
  status,
@@ -3808,13 +4466,15 @@ function scimError(detail: string, status: number): Response {
3808
4466
  return scimJson({ schemas: [ERROR_SCHEMA], detail, status: String(status) }, status);
3809
4467
  }
3810
4468
 
3811
- function toScimUser(row: UserRow) {
4469
+ function toScimUser(user: { get(key: "id" | "name" | "email"): unknown }) {
4470
+ const email = String(user.get("email") ?? "");
4471
+ const name = String(user.get("name") ?? "");
3812
4472
  return {
3813
4473
  schemas: [USER_SCHEMA],
3814
- id: String(row.id),
3815
- userName: row.email,
3816
- name: { formatted: row.name },
3817
- emails: [{ value: row.email, primary: true }],
4474
+ id: String(user.get("id")),
4475
+ userName: email,
4476
+ name: { formatted: name },
4477
+ emails: [{ value: email, primary: true }],
3818
4478
  active: true,
3819
4479
  meta: { resourceType: "User" },
3820
4480
  };
@@ -3904,24 +4564,29 @@ const scimModule: AppModule = {
3904
4564
  const url = new URL(request.url);
3905
4565
  const filter = url.searchParams.get("filter") ?? "";
3906
4566
  const match = /userName\\s+eq\\s+"([^"]+)"/i.exec(filter);
3907
- let rows: UserRow[];
4567
+ const startIndex = Math.max(1, Number.parseInt(url.searchParams.get("startIndex") ?? "1", 10) || 1);
4568
+ const count = Math.min(200, Math.max(1, Number.parseInt(url.searchParams.get("count") ?? "200", 10) || 200));
3908
4569
  if (match?.[1]) {
3909
- rows = await getSql().unsafe<UserRow>(
3910
- "SELECT id, name, email FROM users WHERE email = ${emailPh}",
3911
- [match[1].trim().toLowerCase()],
3912
- );
3913
- } else {
3914
- rows = await getSql().unsafe<UserRow>("SELECT id, name, email FROM users");
4570
+ const email = match[1].trim().toLowerCase();
4571
+ const user = await User.where({ email, tenant_id: tenantId() }).first();
4572
+ const users = user ? [user] : [];
4573
+ const slice = users.slice(startIndex - 1, startIndex - 1 + count);
4574
+ return scimJson({
4575
+ schemas: [LIST_SCHEMA],
4576
+ totalResults: users.length,
4577
+ startIndex,
4578
+ itemsPerPage: slice.length,
4579
+ Resources: slice.map(toScimUser),
4580
+ });
3915
4581
  }
3916
- const startIndex = Math.max(1, Number.parseInt(url.searchParams.get("startIndex") ?? "1", 10) || 1);
3917
- const count = Math.min(200, Math.max(1, Number.parseInt(url.searchParams.get("count") ?? String(rows.length || 1), 10) || 200));
3918
- const slice = rows.slice(startIndex - 1, startIndex - 1 + count);
4582
+ const totalResults = await User.where({ tenant_id: tenantId() }).count();
4583
+ const page = await User.where({ tenant_id: tenantId() }).offset(startIndex - 1).limit(count).get();
3919
4584
  return scimJson({
3920
4585
  schemas: [LIST_SCHEMA],
3921
- totalResults: rows.length,
4586
+ totalResults,
3922
4587
  startIndex,
3923
- itemsPerPage: slice.length,
3924
- Resources: slice.map(toScimUser),
4588
+ itemsPerPage: page.length,
4589
+ Resources: page.map(toScimUser),
3925
4590
  });
3926
4591
  }),
3927
4592
  ),
@@ -3934,28 +4599,16 @@ const scimModule: AppModule = {
3934
4599
  if (!email) {
3935
4600
  return scimError("userName is required.", 400);
3936
4601
  }
3937
- const existing = await getSql().unsafe<UserRow>(
3938
- "SELECT id, name, email FROM users WHERE email = ${emailPh}",
3939
- [email],
3940
- );
3941
- if (existing[0]) {
4602
+ const existing = await User.where({ email, tenant_id: tenantId() }).first();
4603
+ if (existing) {
3942
4604
  return scimError("User already exists.", 409);
3943
4605
  }
3944
4606
  const hashed = await hashPassword(randomBytes(18).toString("hex"));
3945
- const tenantId = currentTenant()?.id ?? 1;
3946
- await getSql().unsafe(
3947
- "INSERT INTO users (${insertCols}) VALUES (${insertPh})",
3948
- [name, email, hashed${insertTail}],
3949
- );
3950
- const created = await getSql().unsafe<UserRow>(
3951
- "SELECT id, name, email FROM users WHERE email = ${emailPh}",
3952
- [email],
3953
- );
3954
- const row = created[0];
3955
- if (!row) {
4607
+ const created = await User.create({ name, email, password: hashed, is_admin: false, tenant_id: tenantId() });
4608
+ if (!created) {
3956
4609
  return scimError("Could not create user.", 500);
3957
4610
  }
3958
- return scimJson(toScimUser(row), 201);
4611
+ return scimJson(toScimUser(created), 201);
3959
4612
  }),
3960
4613
  ),
3961
4614
  },
@@ -3963,61 +4616,55 @@ const scimModule: AppModule = {
3963
4616
  GET: kernel.wrap(
3964
4617
  "api",
3965
4618
  wrapScim(async (request) => {
3966
- const id = Number.parseInt(routeParams(request).id ?? "", 10);
3967
- const rows = await getSql().unsafe<UserRow>(
3968
- "SELECT id, name, email FROM users WHERE id = ${idPh}",
3969
- [id],
3970
- );
3971
- const row = rows[0];
3972
- if (!row) {
4619
+ const id = readScimUserId(request);
4620
+ if (id instanceof Response) {
4621
+ return id;
4622
+ }
4623
+ const user = await User.where({ id, tenant_id: tenantId() }).first();
4624
+ if (!user) {
3973
4625
  return scimError("User not found.", 404);
3974
4626
  }
3975
- return scimJson(toScimUser(row));
4627
+ return scimJson(toScimUser(user));
3976
4628
  }),
3977
4629
  ),
3978
4630
  PUT: kernel.wrap(
3979
4631
  "api",
3980
4632
  wrapScim(async (request) => {
3981
- const id = Number.parseInt(routeParams(request).id ?? "", 10);
4633
+ const id = readScimUserId(request);
4634
+ if (id instanceof Response) {
4635
+ return id;
4636
+ }
3982
4637
  const body = (await request.json()) as Record<string, unknown>;
3983
4638
  const email = readUserName(body);
3984
4639
  const name = readName(body, email);
3985
4640
  if (!email || !name) {
3986
4641
  return scimError("userName and name are required.", 400);
3987
4642
  }
3988
- await getSql().unsafe(
3989
- "UPDATE users SET name = ${updatePh.split(", ")[0]}, email = ${updatePh.split(", ")[1]} WHERE id = ${updatePh.split(", ")[2]}",
3990
- [name, email, id],
3991
- );
3992
- const rows = await getSql().unsafe<UserRow>(
3993
- "SELECT id, name, email FROM users WHERE id = ${idPh}",
3994
- [id],
3995
- );
3996
- const row = rows[0];
3997
- if (!row) {
4643
+ const user = await User.where({ id, tenant_id: tenantId() }).first();
4644
+ if (!user) {
3998
4645
  return scimError("User not found.", 404);
3999
4646
  }
4000
- return scimJson(toScimUser(row));
4647
+ await user.update({ name, email });
4648
+ return scimJson(toScimUser(user));
4001
4649
  }),
4002
4650
  ),
4003
4651
  PATCH: kernel.wrap(
4004
4652
  "api",
4005
4653
  wrapScim(async (request) => {
4006
- const id = Number.parseInt(routeParams(request).id ?? "", 10);
4007
- const existing = await getSql().unsafe<UserRow>(
4008
- "SELECT id, name, email FROM users WHERE id = ${idPh}",
4009
- [id],
4010
- );
4011
- const row = existing[0];
4012
- if (!row) {
4654
+ const id = readScimUserId(request);
4655
+ if (id instanceof Response) {
4656
+ return id;
4657
+ }
4658
+ const user = await User.where({ id, tenant_id: tenantId() }).first();
4659
+ if (!user) {
4013
4660
  return scimError("User not found.", 404);
4014
4661
  }
4015
4662
  const body = (await request.json()) as { schemas?: string[]; Operations?: Array<{ op?: string; path?: string; value?: unknown }> };
4016
4663
  if (body.schemas && !body.schemas.includes(PATCH_SCHEMA)) {
4017
4664
  return scimError("Unsupported patch schema.", 400);
4018
4665
  }
4019
- let name = row.name;
4020
- let email = row.email;
4666
+ let name = String(user.get("name") ?? "");
4667
+ let email = String(user.get("email") ?? "");
4021
4668
  for (const operation of body.Operations ?? []) {
4022
4669
  const op = (operation.op ?? "replace").toLowerCase();
4023
4670
  if (op !== "replace" && op !== "add") {
@@ -4038,18 +4685,21 @@ const scimModule: AppModule = {
4038
4685
  name = readName(value, name);
4039
4686
  }
4040
4687
  }
4041
- await getSql().unsafe(
4042
- "UPDATE users SET name = ${updatePh.split(", ")[0]}, email = ${updatePh.split(", ")[1]} WHERE id = ${updatePh.split(", ")[2]}",
4043
- [name, email, id],
4044
- );
4045
- return scimJson(toScimUser({ id: row.id, name, email }));
4688
+ await user.update({ name, email });
4689
+ return scimJson(toScimUser(user));
4046
4690
  }),
4047
4691
  ),
4048
4692
  DELETE: kernel.wrap(
4049
4693
  "api",
4050
4694
  wrapScim(async (request) => {
4051
- const id = Number.parseInt(routeParams(request).id ?? "", 10);
4052
- await getSql().unsafe("DELETE FROM users WHERE id = ${idPh}", [id]);
4695
+ const id = readScimUserId(request);
4696
+ if (id instanceof Response) {
4697
+ return id;
4698
+ }
4699
+ const user = await User.where({ id, tenant_id: tenantId() }).first();
4700
+ if (user) {
4701
+ await user.delete();
4702
+ }
4053
4703
  return new Response(null, { status: 204 });
4054
4704
  }),
4055
4705
  ),
@@ -4131,6 +4781,18 @@ function writeGeneratedFiles(options) {
4131
4781
  } else {
4132
4782
  removeIfExists(join2(targetDir, "docker-compose.yml"));
4133
4783
  }
4784
+ const postgresInit = renderPostgresAppRoleInitSql(projectName, layers);
4785
+ if (postgresInit) {
4786
+ writeText(join2(targetDir, "db/ensure-postgres-app-role.sql"), postgresInit);
4787
+ if (usesComposePostgres(layers)) {
4788
+ writeText(join2(targetDir, "docker/postgres-init/01-strata-app-role.sql"), postgresInit);
4789
+ } else {
4790
+ removeIfExists(join2(targetDir, "docker/postgres-init"));
4791
+ }
4792
+ } else {
4793
+ removeIfExists(join2(targetDir, "db/ensure-postgres-app-role.sql"));
4794
+ removeIfExists(join2(targetDir, "docker/postgres-init"));
4795
+ }
4134
4796
  writeText(join2(src, "routes.ts"), renderRoutesTs());
4135
4797
  writeText(join2(src, "lib/view.ts"), renderViewTs(layers));
4136
4798
  writeText(join2(src, "bootstrap/config.ts"), renderConfigTs());
@@ -4145,9 +4807,22 @@ function writeGeneratedFiles(options) {
4145
4807
  writeText(join2(src, "bootstrap/createApp.ts"), renderCreateAppTs(layers));
4146
4808
  writeText(join2(src, "bootstrap/providers/config.ts"), renderConfigProvider(layers));
4147
4809
  writeText(join2(src, "bootstrap/providers/queue.ts"), renderQueueProvider());
4810
+ writeText(join2(src, "bootstrap/providers/policy.ts"), renderPolicyProvider());
4148
4811
  writeText(join2(src, "bootstrap/providers/index.ts"), renderProvidersIndex());
4149
4812
  writeText(join2(src, "bootstrap/providers/auth.ts"), renderAuthProvider(layers));
4150
- writeText(join2(src, "models/Note.ts"), renderNoteModel());
4813
+ writeText(join2(src, "models/Note.ts"), renderNoteModel(layers));
4814
+ if (authNeedsUsers(layers.auth)) {
4815
+ writeText(join2(src, "models/User.ts"), renderUserModel(layers));
4816
+ writeText(join2(src, "models/AuthOneTimeToken.ts"), renderAuthOneTimeTokenModel());
4817
+ } else {
4818
+ removeIfExists(join2(src, "models/User.ts"));
4819
+ removeIfExists(join2(src, "models/AuthOneTimeToken.ts"));
4820
+ }
4821
+ if (authUsesToken(layers.auth)) {
4822
+ writeText(join2(src, "models/ApiToken.ts"), renderApiTokenModel());
4823
+ } else {
4824
+ removeIfExists(join2(src, "models/ApiToken.ts"));
4825
+ }
4151
4826
  writeText(join2(src, "db/migrate.ts"), renderMigrateTs(layers));
4152
4827
  writeText(join2(src, "db/fresh.ts"), renderFreshTs(layers));
4153
4828
  writeText(join2(src, "db/seed.ts"), renderSeedTs());
@@ -4178,6 +4853,17 @@ function writeGeneratedFiles(options) {
4178
4853
  removeIfExists(join2(src, "bootstrap/pendingMfa.ts"));
4179
4854
  }
4180
4855
  writeText(join2(targetDir, "public/assets/site.css"), renderSiteCss());
4856
+ const htmxSource = join2(options.templateRoot ?? starterPackageRoot(), "public/assets/htmx.min.js");
4857
+ const htmxFallback = join2(starterPackageRoot(), "templates/public/assets/htmx.min.js");
4858
+ const htmxPath = existsSync2(htmxSource) ? htmxSource : htmxFallback;
4859
+ if (existsSync2(htmxPath)) {
4860
+ mkdirSync2(join2(targetDir, "public/assets"), { recursive: true });
4861
+ copyFileSync(htmxPath, join2(targetDir, "public/assets/htmx.min.js"));
4862
+ const versionSidecar = `${htmxPath}.version`;
4863
+ if (existsSync2(versionSidecar)) {
4864
+ copyFileSync(versionSidecar, join2(targetDir, "public/assets/htmx.min.js.version"));
4865
+ }
4866
+ }
4181
4867
  writeText(join2(targetDir, "views/home.eta"), renderHomeView(projectName, layers));
4182
4868
  writeText(join2(targetDir, "views/layouts/app.eta"), renderLayout(layers, projectName));
4183
4869
  if (htmlAuthKit(layers.auth)) {
@@ -4191,6 +4877,7 @@ function writeGeneratedFiles(options) {
4191
4877
  if (layers.extras.mfa) {
4192
4878
  writeText(join2(targetDir, "views/auth/mfa-challenge.eta"), renderMfaChallengeView());
4193
4879
  writeText(join2(targetDir, "views/auth/mfa-setup.eta"), renderMfaSetupView());
4880
+ writeText(join2(targetDir, "views/auth/confirm-password.eta"), renderConfirmPasswordView());
4194
4881
  }
4195
4882
  }
4196
4883
  mkdirSync2(join2(targetDir, "storage"), { recursive: true });